4 Commits
Author SHA1 Message Date
prosolis fcd4368631 games: the sit button that never looked twice, and the phrase that ran off the phone
UNO synced its sit panel once, at boot, before the chip bar had said what you
have. Land on the table with nothing, buy chips right there on the page, and Sit
down stayed greyed out until you reloaded — the money came in and nothing looked
at the button again. Getting up had the same shape: the panel came back last
synced while the leave request was still in flight, so it came back dead.

Every fresh view of the money now re-syncs the panel while you're off a table,
and controls() re-syncs it whenever a request finishes — which is the moment
getting up hands your stack back.

Hangman's phrase was laid out at a fixed tile width and never broke inside a
word, so a twelve-letter one was 475px of tiles in a 306px felt and took the
whole page sideways with it. Tiles scale with the screen now, and a word breaks
inside itself when it has to, with the gap between words kept several times the
gap inside one so a break still reads as a break in the word. The keyboard was
over the edge too — P, M and 9 were off the right of a phone and unclickable —
so a key lays out at a width the narrowest screen can afford and grows into
whatever room there is.

Desktop is unchanged: same tile, same key, same felt.

Claude-Session: https://claude.ai/code/session_01R7MuRkXEEGS564aFo5Rtjs
2026-08-25 23:06:13 -07:00
prosolis 15e229b6c3 adventure: stay out of the room when TwinBee already said it
gogobee announces treasure finds, duels, mischief contracts and the
Siege to the games room in TwinBee's voice, and files the same moments
here as facts. Both land in the same room, so the realm heard every one
of them twice, in two voices, minutes apart.

Hold those types back from Matrix. They are stored and published on the
site exactly as before, and the push alerts still fire; only the live
priority beat and the digest sweep skip them, retired against the digest
the same way a no_push backfill is. Types with no room announce behind
them (zone clears, arrivals, deaths, milestones) are untouched and stay
Pete's to report.

room_silent_types overrides the default set; an explicit empty list
turns the suppression off.
2026-07-25 10:21:43 -07:00
prosolis 5b07199631 Merge party-known-flag: retire the party_known write-up, gogobee sets it now 2026-07-24 23:16:30 -07:00
prosolis a14859c5ee adventure: retire the party_known write-up, gogobee sets it now
The flag was built and tested on this side first and the contract was left
in the tree for the game box to pick up. It has (gogobee 10d1e4a, set
unconditionally on every detail sheet), and a solo leader on a live
expedition has their abandon button back, so the note has nothing left to
ask for.
2026-07-24 23:04:05 -07:00
8 changed files with 141 additions and 95 deletions
-75
View File
@@ -1,75 +0,0 @@
# Wire contract — `party_known` on the public detail blob (gogobee side)
Status: **Pete side BUILT + TESTED. gogobee side NOT BUILT.** Written 2026-07-24 out
of a code review of the W9 verbs branch. Small: one boolean, set unconditionally,
no new endpoint and no new verdict. Companion to `adventure_expansion_spec.md`.
## The hole
`offersToUndo` in `internal/web/who.go` decides which of "Call the whole thing off"
(abandon) and "Leave the party" (leave) an owner's page offers. On a live expedition
with an EMPTY party list it took the "solo run ⇒ this player is the leader" branch,
because a solo run publishes no seats at all.
But a gogobee too old to push party seats sends a perfectly valid detail blob with no
`party` key, and that decodes to the same empty slice. So a party MEMBER served by an
old game box was shown the leader's button — the one that throws away four people's
day. gogobee refuses it with `rejected_not_leader`, so the live cost was a misleading
offer rather than a lost expedition, but the page was asserting something it could
not know.
Nil and empty cannot be told apart on the current wire, hence a new field.
## What gogobee must send
The public detail blob on the roster push (the one Pete decodes into `whoDetail`)
gains:
```json
"party_known": true
```
**Set it unconditionally**, on every sheet, from any gogobee build that knows what a
party seat is:
- on a solo run — YES, this is the case the flag exists for;
- when the party list is omitted because there is nobody else — YES;
- when the player is standing in town — YES;
- when the sheet does carry seats — YES, harmless there.
It is a capability flag about the **sender**, not a fact about the character. It must
never be computed from whether there is a party, whether the run is shared, or
anything else on the sheet. A conditional `party_known` is worse than none: it would
read as "this player is solo" and put the flag straight back into the hole it was
added to close.
Anything that omits it (old builds, JSON that fails to decode) lands as `false` in
Go, which is the fail-closed answer Pete wants.
## What Pete already does with it
- `whoDetail.PartyKnown bool \`json:"party_known"\`` — `internal/web/who.go:47`.
- `offersToUndo`'s third parameter is `partyKnown`; the call site passes
`page.HasDetail && page.Detail.PartyKnown`, so a blob that failed to decode is
still excluded.
- The flag gates the `len(party) == 0` branch **only**. The `len(party) > 0` branch
reads the viewer's own seat and is self-evidencing — a seat that says "member"
cannot be mistaken for leadership — so it keeps working against every sender,
including ones that never set the flag.
- Cases pinned in `internal/web/orders_undo_test.go`.
## Rollout
Pete is already deployed-safe: it ships first and costs nothing but one button. Until
gogobee sets the flag, a genuine SOLO leader on a live run does not see the abandon
button on the party branch. They are not stuck — the `self.Resume != nil` clause
still offers it for an extracted run from town, and abandoning over Matrix was never
affected. The moment gogobee ships the flag the button comes back with no Pete change.
## Done when
- gogobee sets `party_known: true` on every public detail sheet it builds.
- A solo adventurer on a live expedition sees "Call the whole thing off" on their web
page again.
- A party member on the same build still sees "Leave the party" and never the abandon.
- Delete this file.
+42
View File
@@ -38,6 +38,48 @@ type AdventureConfig struct {
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from // 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
// "unset" and doesn't get silently rewritten to the default. // "unset" and doesn't get silently rewritten to the default.
DigestHour *int `toml:"digest_hour"` DigestHour *int `toml:"digest_hour"`
// RoomSilentTypes lists event types gogobee already announces in the games
// room in TwinBee's own voice. Pete stores them and publishes them on the
// site, but never posts them to Matrix — neither the live priority beat nor
// the daily digest — so the room hears each moment once.
//
// Unset falls back to defaultRoomSilentTypes; an explicit empty list turns
// the suppression off and restores the double-post.
RoomSilentTypes []string `toml:"room_silent_types"`
}
// defaultRoomSilentTypes are the event types TwinBee announces to the games room
// itself as of gogobee HEAD. Each has a matching room announce on the gogobee
// side (announceTreasureToRoom, the duel broadcast, announceMischief*,
// announceWorldBoss), so a Pete post is a second telling of the same beat.
//
// Types NOT listed here have no room announce behind them and are Pete's alone
// to report: zone_clear, zone_first, boss_kill, arrival, departure, death,
// retreat, milestone, companion_hire.
var defaultRoomSilentTypes = []string{
"treasure_found",
"rival_result",
"mischief_contract",
"mischief_survived",
"mischief_downed",
"mischief_fizzled",
"siege_start",
"siege_win",
"siege_loss",
}
// RoomSilentSet returns the room-suppressed event types as a lookup set,
// resolving the unset case. Safe on a zero-value AdventureConfig.
func (a AdventureConfig) RoomSilentSet() map[string]bool {
types := a.RoomSilentTypes
if types == nil {
types = defaultRoomSilentTypes
}
set := make(map[string]bool, len(types))
for _, t := range types {
set[t] = true
}
return set
} }
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the // DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
+26 -7
View File
@@ -76,6 +76,13 @@ const advSource = "Pete"
// Matrix; the row exists only so the digest skips it. // Matrix; the row exists only so the digest skips it.
const advBackfillEvent = "adv-backfill" const advBackfillEvent = "adv-backfill"
// advRoomSilentEvent is the synthetic post_log event id used to retire a
// dispatch whose event type gogobee announces in the games room itself. Like
// advBackfillEvent it never went to Matrix; the row exists so the digest skips
// it, and the distinct id keeps "TwinBee said it" separable from "backfilled"
// when reading post_log later.
const advRoomSilentEvent = "adv-room-silent"
// handleAdventureIngest receives a game-event fact from gogobee, templates it // handleAdventureIngest receives a game-event fact from gogobee, templates it
// into a deterministic story, publishes it to the /adventure section, and posts // into a deterministic story, publishes it to the /adventure section, and posts
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID. // PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
@@ -210,13 +217,25 @@ func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier) slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only // Two reasons a dispatch never reaches Matrix, both retired the same way:
// the live post isn't enough: the digest collects adventure rows that carry no //
// post_log entry, so a backfilled bulletin would still be swept into the next // - NoPush: a cold-start backfill, the back-catalogue dump it exists to
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid // prevent.
// against the digest up front instead. // - A room-silent type: gogobee already announced this exact moment to the
if f.NoPush { // games room in TwinBee's voice, and relaying it is the room hearing one
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent) // beat twice in two voices.
//
// Suppressing only the live post isn't enough: the digest collects adventure
// rows that carry no post_log entry, so a held-back bulletin would still be
// swept into the next roundup. Retire the guid against the digest up front
// instead. The row was stored above either way, so the site, the permalink
// and the push alerts keep the full record.
if f.NoPush || s.roomSilent[f.EventType] {
retiredAs := advBackfillEvent
if !f.NoPush {
retiredAs = advRoomSilentEvent
}
storage.MarkAdventureDigested([]string{f.GUID}, retiredAs)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
return return
+41 -2
View File
@@ -162,10 +162,12 @@ func TestAdventureDigest(t *testing.T) {
now := time.Now() now := time.Now()
// Two bulletins + one priority (which posts live and must be excluded). // Two bulletins + one priority (which posts live and must be excluded).
// Both bulletin types are ones TwinBee does NOT announce itself — a
// room-silent type never reaches the digest (see TestAdventureRoomSilent).
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin", postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()}) Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin", postFact(t, s, token, AdvFact{GUID: "milestone:b:2", EventType: "milestone", Tier: "bulletin",
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()}) Actors: []string{"Kif"}, Subject: "Kif", Milestone: "Ten zones cleared", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority", postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()}) Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
if len(*posted) != 1 { if len(*posted) != 1 {
@@ -191,6 +193,43 @@ func TestAdventureDigest(t *testing.T) {
} }
} }
// TestAdventureRoomSilent: an event type gogobee announces in the games room
// itself is published to the site but never reaches Matrix — not as a live
// priority beat, and not swept into the next digest either.
func TestAdventureRoomSilent(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
now := time.Now()
postFact(t, s, token, AdvFact{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority",
Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20,
Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()})
if len(*posted) != 0 {
t.Fatalf("room-silent beat posted live: %d posts, want 0", len(*posted))
}
// The site keeps the full record — suppression is Matrix-only.
got, err := storage.GetStoryByGUID("treasure_found:e:5")
if err != nil || got == nil {
t.Fatalf("room-silent beat missing from the site: %v", err)
}
// And it doesn't come back around in the roundup.
s.postDailyDigest(now.UTC())
if len(*posted) != 0 {
t.Errorf("room-silent beat swept into digest: %d posts, want 0", len(*posted))
}
// An explicit empty list turns suppression off: the same beat posts live.
s.roomSilent = config.AdventureConfig{RoomSilentTypes: []string{}}.RoomSilentSet()
postFact(t, s, token, AdvFact{GUID: "treasure_found:e:6", EventType: "treasure_found", Tier: "priority",
Actors: []string{"Rurina"}, Subject: "Rurina", Zone: "Dragon's Lair", Level: 20,
Stakes: "The Cartographer's Final Map", Outcome: "legendary", OccurredAt: now.Unix()})
if len(*posted) != 1 {
t.Errorf("suppression off: %d posts, want 1", len(*posted))
}
}
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint // TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink // returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image. // page is noindex with an og:image.
+2 -1
View File
@@ -69,6 +69,7 @@ type Server struct {
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient() pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
adv config.AdventureConfig // gogobee adventure-news seam adv config.AdventureConfig // gogobee adventure-news seam
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
roomSilent map[string]bool // event types TwinBee announces itself: site yes, Matrix never
channels []Channel // live sections: the catalogue minus anything gated off (adventure) channels []Channel // live sections: the catalogue minus anything gated off (adventure)
// Daily-rotated salt for the privacy-preserving unique-visitor estimate. // Daily-rotated salt for the privacy-preserving unique-visitor estimate.
@@ -144,7 +145,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
live = append(live, ch) live = append(live, ch)
} }
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}} s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, roomSilent: adv.RoomSilentSet(), channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the // Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than // provider is unreachable at boot we log and serve anonymously rather than
+18 -8
View File
@@ -1049,27 +1049,33 @@ html[data-phase="night"] {
40%, 60% { transform: translateX(5px); } 40%, 60% { transform: translateX(5px); }
} }
/* The phrase. Tiles wrap between words, never inside one. */ /* The phrase. Tiles wrap between words; a word only breaks inside itself when
it is wider than the felt, which on a phone a long one is. The gap between
words stays several times the gap inside one, so a break still reads as a
break in the word rather than the end of it. */
.pete-board { .pete-board {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem 1.1rem; gap: 0.35rem clamp(0.6rem, 3vw, 1.1rem);
min-height: 5rem; min-height: 5rem;
align-items: center; align-items: center;
max-width: 100%;
} }
.pete-word { .pete-word {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.3rem; gap: 0.3rem;
max-width: 100%;
} }
.pete-tile { .pete-tile {
display: grid; display: grid;
place-items: center; place-items: center;
height: 2.9rem; height: clamp(2rem, 8.6vw, 2.9rem);
width: 2.2rem; width: clamp(1.5rem, 6.5vw, 2.2rem);
border-radius: 0.5rem; border-radius: 0.5rem;
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif; font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
font-size: 1.4rem; font-size: clamp(1rem, 4.2vw, 1.4rem);
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.22); background: rgba(0, 0, 0, 0.22);
@@ -1153,19 +1159,23 @@ html[data-phase="night"] {
.pete-keys { display: grid; gap: 0.35rem; } .pete-keys { display: grid; gap: 0.35rem; }
.pete-key-row { .pete-key-row {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 0.35rem; gap: 0.35rem;
} }
.pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; } .pete-key-row[data-digits="1"] { margin-top: 0.25rem; opacity: 0.75; }
.pete-key-row[data-digits="1"] .pete-key { .pete-key-row[data-digits="1"] .pete-key {
height: 2rem; height: 2rem;
min-width: 1.8rem;
font-size: 0.8rem; font-size: 0.8rem;
} }
/* Ten keys have to fit the row on a phone, so a key is laid out at a width the
narrowest screen can afford and grows into whatever room the screen actually
has. Wrapping is the last resort under that, not the first answer. */
.pete-key { .pete-key {
height: 2.75rem; height: 2.75rem;
min-width: 2.2rem; min-width: clamp(1.15rem, 5vw, 2.2rem);
flex: 0 1 2.4rem; max-width: 2.4rem;
flex: 1 1 clamp(1.15rem, 5vw, 2.4rem);
border-radius: 0.6rem; border-radius: 0.6rem;
background: color-mix(in srgb, var(--ink) 6%, transparent); background: color-mix(in srgb, var(--ink) 6%, transparent);
border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent); border: 2px solid color-mix(in srgb, var(--ink) 10%, transparent);
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -390,6 +390,10 @@
if (deckEl) deckEl.disabled = busy || !yours || drawn || stack; if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
if (dealBtn) dealBtn.disabled = busy; if (dealBtn) dealBtn.disabled = busy;
if (leaveBtn) leaveBtn.disabled = busy; if (leaveBtn) leaveBtn.disabled = busy;
// With no table under you the sit panel is what's on screen, and Sit down is
// the button that has to come back out of busy. Getting up hands your stack
// back, so it is also affordable again the moment the request lands.
if (!game) syncSit();
} }
function setPhase(v) { function setPhase(v) {
@@ -1045,13 +1049,19 @@
pickRules("normal"); pickRules("normal");
// Every fresh view of the money re-syncs the sit panel, not just the first one.
// Buying chips from the chip bar happens on this page, and a Sit down button
// that was greyed out when you had nothing has to notice that you now have
// something — otherwise the only way to sit down is to reload.
G.onUpdate(function () { if (!game) syncSit(); });
var resumed = false; var resumed = false;
G.onUpdate(function () { G.onUpdate(function () {
if (resumed) return; if (resumed) return;
resumed = true; resumed = true;
G.refresh().then(function (v) { G.refresh().then(function (v) {
if (v && v.uno) { paint(v.uno); seated(); } if (v && v.uno) { paint(v.uno); seated(); }
else { paint(null); loadLobby(); syncSit(); } else { paint(null); loadLobby(); }
}); });
}); });
})(); })();