Commit Graph
169 Commits
Author SHA1 Message Date
prosolis b07abc1d13 adventure: let a player back out from the web, not only in Matrix
Three verbs to match gogobee's: call off an expedition, turn back out of
somebody else's party, send the pet sitter home. Which one this page
offers is derived here rather than pushed — leadership is already legible
in the party seats and the sitter's standing is already in the babysit
offer, so nothing new crosses the wire.

Two things running it turned up that no test would have. An applied
abandon left "Pull out of the run" sitting under a verdict saying the
expedition was over, so an applied verb now also hides the other verbs it
just made untrue. And a party member was being offered that same button
in the first place, beside the one that actually works — Pete knows from
the seat it just read that gogobee would refuse it, so it is withheld.

Also: heal the Matrix handle onto push rows stored before the column
existed, on its own endpoint rather than through the subscribe upsert,
which resets both watermarks and would have silenced the digest for
anybody who reads the site regularly. And stack the board row below sm —
four flex columns that wrapped to six lines on a phone, pre-existing.
2026-07-24 21:42:59 -07:00
prosolis 0d8dba90df web: make a purged CSS class fail the build instead of the page
tailwind.config.js has input.css in its own content glob, so a hand-written
component class survives the purge only if its literal name can be extracted
from that file. A rule written solely as `.foo::before` cannot be, and Tailwind
drops it silently: no error, just an unstyled element on one page.

That has cost three phases. The mitigation everybody reached for — grep
output.css after `make css` — is the discipline that failed, and it is worse
than it looks, because Tailwind escapes class names in its output and a raw-name
grep reports a false negative that looks exactly like a purge failure.

So parse the `@layer components` blocks for declared class names and assert each
one reaches output.css as a selector, comparing escaped forms. No list to
maintain: the list is input.css. 220 classes covered today. Two self-tests pin
that the check catches a pseudo-only class and is not fooled by escaping.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 20:35:55 -07:00
prosolis c2a40dad64 adventure: show the party, the pets, and what you missed
Four small surfaces the game has had all along and the web has never shown.

The party roster on the adventurer page is the one with a bug behind it. The
board resolved an expedition by owner id, so a player seated on somebody else's
run has been reading as "idle in town" while standing in a tier-4 dungeon.
Seats now ride the roster detail beside the supply and threat numbers, and an
opted-out player's seat is anonymised rather than dropped: a party of three
rendered as a pair contradicts everything printed next to it.

Pets show their levelling. They have earned XP from every won fight since that
wiring was fixed and the only place a level ever appeared was a Matrix line
that scrolled away. The threshold comes from the engine, in the engine's own
centi-XP, because a copy of the curve here would drift the first time a band
moved.

"While you were away" is the one panel on the site about the reader rather than
the realm. Two stamps behind it, not one: a single column would show the news,
move the clock, and render an empty box over the same events on the reader's
first refresh.

And the story permalink names its region, which has been hardcoded empty behind
a `// reserved` comment since the page shipped.
2026-07-24 20:26:19 -07:00
prosolis 868a29e992 adventure: let a player leave town from the web, not only read about it
W5a gave the web two verbs that cost nothing. These are the three that take
arguments and spend coins: set out for a zone with a supply loadout, walk back
into the run you extracted from, hire the pet sitter for a week or a month.
Between them they cover the most common thing anybody does in the game, which
until now could only be typed into Matrix.

Arguments are the new surface, so they are the thing to be careful with. Nothing
in a request is trusted: every zone, loadout and duration is looked up in the
offer list gogobee pushed onto that owner's own private row, and the order stores
what was found there rather than what was sent. A forged zone resolves to nothing
and never becomes an order. gogobee then re-resolves all of it anyway, because an
offer is a quote and a quote is not a permission.

The money confirm is the equip panel's, lifted: cost, balance, and the balance it
leaves. When it does not cover, it says so instead of printing a negative.

Verified in a browser rather than only in tests, which is where both real defects
came from: the confirm box was appending to the whole panel and so appeared at
the bottom of the section instead of under the button that raised it, and button
prices printed as EUR45000 above a dialog reading EUR45,000.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 19:47:26 -07:00
prosolis 6b0aae9f4a adventure: let a player act from the web, not just read about it
The equip queue proved the reverse pipe works. This gives it verbs that
play the game: pull out of a run from the adventurer page, take today's
bout from the war room.

Its own table and its own poll, not more actions on equip_orders. Every
column of that table is equip vocabulary (item, slot, tier) and these
verbs act on the character rather than on something it is carrying.

Nothing in a request names an adventurer. The session maps to one
localpart and a localpart to one adventurer, so Pete resolves the
character itself and there is no id on the wire to forge.

The panel's copy is kept honest by the verdict: an applied action hides
the offer it has just spent, a refusal puts the button back. Watching it
run is what put that there, along with the strip's layout — gogobee
answers a bout with a whole sentence of damage, which the equip strip's
two-column row squeezed into a column and wrapped the verb.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 18:55:42 -07:00
prosolis b19ab5eff0 adventure: tell a player what happened while they were away
The site could only reach somebody who was already looking at it. Push
existed and adventure used none of it, so the one communal event in the
game -- the Siege -- was invisible to anyone not sitting in Matrix, and a
player whose adventurer died found out whenever they next opened a tab.

Four opt-in categories, every one of them off until asked for: the Siege
(realm-wide, begins and ends), your expedition ending, your adventurer
wandering off, and a contract landing on you. Turning on news
notifications is not consent to be told about the game, so nothing here
enrolls anybody automatically.

No new wire. Every trigger is a dispatch already landing in
adventure_events, so this is Pete-side only and gogobee is untouched.

Two things it needed from storage. push_subscriptions now keeps the
Matrix localpart alongside the OIDC subject, because every ownership
join in the schema is keyed on the localpart and the sender runs on a
ticker with no session to read one from -- without it there is no way to
answer "whose adventurer is this". And the alerts carry their own
watermark, kept apart from the digest's: the two run on different clocks
and one column would let each consume the other's backlog.

The ownership join is re-read on every pass rather than trusted from the
subscription row, so an opt-out or a removal closes the channel at once.
It fails closed in both directions, and an unresolved owner can never
fall through to a broadcast -- a game alert naming somebody's adventurer,
delivered to the wrong phone, is a privacy leak dressed as a feature.

An existing subscription carries watermark 0, which read literally means
"has never been told anything" and would page every subscriber for the
whole history of the realm on the first tick after deploy. Those rows are
stamped to now and start from the next dispatch.

Verified against a running Pete with a real push service, real P-256
client keys and real encryption: the right person is notified, the wrong
one is not, a second pass is silent, and dropping the player from the
board takes the channel with it.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 18:22:34 -07:00
prosolis 1dfd3ac9fb adventure: give the realm a map, a board and a history
Everything Pete published so far was either the present moment (the roster,
the Siege bar) or one thing that happened (a dispatch, a run report). None of
it said what this place IS — how many zones there are, which are harder, or
whether anybody has ever actually beaten them.

Three pages off one snapshot, because they are one question and every number
on all three comes off the same scan of the same run history upstream:

  /adventure/realm      the world, in difficulty order, with who is inside it
  /adventure/standings  the board, plus Pete's own duel record
  /adventure/firsts     the hall of firsts, as a dated history

The map is deliberately not who_map.go's layout engine. That lays out a graph,
and the realm has no edges — zones aren't connected, you pick one and go.
Forcing a graph onto a set would imply a topology the game doesn't have. What
it has is an order, so tier bands are what get drawn.

A zone nobody has ever cleared looks different rather than saying so in small
text, and that styling keys off the clear count, never off whether a name is
attached — otherwise an opt-out would silently redraw a conquered place as one
nobody has come out of.

The per-kind first dot goes through a custom property instead of a
.firsts-entry-zone::before rule: input.css is in Tailwind's content glob, so a
hand-written class survives the purge only if its literal name can be lifted
out of the file, and a name glued to ::before cannot. It failed silently.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 17:54:49 -07:00
prosolis b4a276da36 adventure: give a finished run a report worth sharing
The liveblog answers "what is happening" — it is capped, it scrolls, and six
hours after a run ends it is gone, because the adventurer page is about now.
Nothing answered the question asked afterwards, usually by somebody who wasn't
watching: what WAS that run. So a dispatch announcing a clear or a death was a
paragraph about an outcome with no way back to what produced it.

The report is that way back. The whole log uncapped, the numbers rolled up, and
the single worst hit the party took pulled out of the middle where it otherwise
reads as one line among forty. It is stable for a fortnight, which is what makes
it a thing worth linking from a dispatch and worth sending to somebody.

It is assembled from the same beats through the same renderer as the liveblog.
A report that told a different story from the log it was built out of would be
the more convincing of the two and the less true.

The summary is the exception and the only prose on the channel: gogobee's model
reads the finished run back and says what it was about, which is a judgement no
template makes. It rides a summary beat rather than its own endpoint, so it
inherits the whole channel — idempotent, retried, impossible to attach to a run
that doesn't exist — and it passes the same class of guard a dispatch lede does
before it reaches a public page.

Visibility is the adventurer page's rule exactly, and that matters more here
than anywhere: the report outlives the log by a fortnight and is linked from a
public dispatch, so it is the surface most likely to still be reachable after
somebody opts out. Coming off the board closes it, including through links
minted days earlier.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 17:11:04 -07:00
prosolis 8c3f2b0d07 adventure: tell the story of a run, not just how it ended
Pete only ever heard that an expedition happened once it was over — a zone
cleared, a retreat, a death. The run itself was narrated into one Matrix DM
and thrown away. The map on the adventurer page has always shown where
somebody is; this shows what happened there.

Beats arrive on their own channel, append-only and idempotent on
(run_id, seq). They are the one thing gogobee pushes that is history rather
than state, so they accumulate instead of replacing — and they stay off the
dispatch queue so a chatty run can never spend the retry budget a death
dispatch depends on.

The run header is derived from the beats rather than pushed: a run whose
start beat never arrived still gets a readable, unattributed log instead of
being dropped for want of a name.

An unknown beat kind renders as its own noun rather than 400ing. That is the
same lesson the dispatch ingest learned the hard way, and the regression test
covers the class, not the case.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 16:33:48 -07:00
prosolis 7051e8ffff adventure: give every dispatch a card that looks like what it is
Every dispatch rendered the same image: one violet gradient, a swapped
emoji, a label. A death, a first-ever clear and a legendary hoard were
visually identical — and this image is not decoration. It is the
og:image on every link Pete puts in Matrix and the thumbnail on every
feed card. The most interesting thing that has ever happened in the
realm looked exactly like the most routine.

The card is now keyed on the dispatch GUID instead of the event type,
so the renderer can read the fact behind it and put the actual nouns on
it — the boss's name, the zone, the item, the level. Each family gets
its own palette, and treasure is tinted by the rarity gogobee already
computes and currently spends on an adjective in a sentence.

A realm-first gets visible ceremony. The priority/bulletin split is
already computed upstream and until now it only decided whether Matrix
got pinged; a thing nobody has ever done should also look different
from the ninth time somebody did it. It earns a ribbon on the card and
a badge and a heavier ring in the feed.

Siege cards carry the HP bar, which is the W1 deferral landing. The
siege fact has the boss and the defender count but never the HP, so the
bar comes from the war-room snapshot: the live row while that boss is
still camped, the history once it has closed. When neither has it yet —
the real two-minute window between a win being filed and the push that
explains it — the card renders barless rather than wrong, and caches
for five minutes instead of a day so the unfurl isn't pinned that way.

Nothing needs backfilling. A story with a type-keyed image_url still
serves, and a dispatch with no stored fact degrades to the old emblem.

Two things learned by looking at the rendered output rather than at the
tests, both of which unit tests would never have caught: the feed
thumbnail is a 16/10 crop of a 1200x630 card and was eating the chip
("EGENDARY"), hence advSafeX; and an empty bar under a victory headline
reads at a glance as a wipe, hence the event-aware caption.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 15:47:44 -07:00
prosolis 23563b6a6a adventure: give the Siege a war room instead of a Matrix-only rumour
The Siege is the one mechanic where the whole town works on a single
object, and it existed exclusively in Matrix — so anybody not in the room
at the time never knew it happened. A communal event nobody can see is a
communal event that fails.

gogobee now pushes the war room on the roster tick and Pete replaces its
copy, the same snapshot contract the board has and for the same reason:
the shared pool is state, not history, and a retried snapshot would be a
lie about how much HP is left.

/adventure/siege draws it. The load-bearing detail is one CSS line — the
health bar has a width transition, so a poll that lands a lower pool
slides the bar down instead of snapping it. That is the difference
between watching the town chip a boss down and reading a report about it.
Alongside: a countdown to the window's close, past sieges with the bar
each one ended on, and the muster split into who took today's bout and
who still has one going spare — the mechanic is one fight per person per
day, so that second column is a hit the town hasn't taken yet.

The opt-out rule here deliberately differs from the board's. The board
omits an opted-out player outright, because class + level + zone
re-identifies them. A Siege contributor is anonymised instead: their
damage is part of what the town did to the boss, and deleting it would
understate the shared effort and stop the totals adding up. They keep
their rank, lose their name, and carry no token — so there is no link
back to a page that names them. An opted-out player who never fought is
still omitted; there is nothing to account for.

siege_start / siege_win / siege_loss are wired on the gogobee side; the
templates for all three have been sitting unused in renderAdventure
since the section shipped.
2026-07-24 15:21:14 -07:00
prosolis 91d25e9da1 adventure: stop dropping dispatches Pete has no words for
An event_type with no template was a 400 at ingest. That reads like caution
and behaves like deletion: gogobee retries a 400 to its cap and then parks the
row forever, so rejecting a type Pete hadn't learned to phrase didn't defer the
event, it destroyed it.

companion_hire went that way. It has been emitted from `!expedition hire` since
the combat-engine work landed and has never once reached the site — the game
logged a successful emit every time, and the queue row simply never sent. The
mitigation on the books was "always deploy Pete first", which is a thing a
person has to remember rather than a property of the system.

So invert it. An unknown type now warns, gets counted, and publishes on a
neutral fallback. 400 is kept for facts that are actually invalid: no guid, or
a name that failed the fact-guard. gogobee can ship a new event type any day of
the week now; the worst case is a thin card until Pete learns the words.

It is thinner than it sounds in practice. gogobee authors dispatch prose from
the fact's fields with no per-type switch, so an unrecognised type still
arrives with a real headline and lede and is allowed to use them. The fallback
only shows through when the model is off or the prose-guard refused the output.

Untemplated types never post live to Matrix, whatever tier they claim. A thin
card among cards is cheap and reversible; pinging everyone in the room with a
dispatch Pete couldn't phrase is neither. The daily digest still carries it,
one line among many, which is the right volume for something we don't
understand yet.

And give companion_hire its template. Pete is the one being hired, so it is
first-person like his duels — third-person Pete filling in as a cleric reads as
somebody else reporting on him.

The admin status page grows a "dispatches with no template" panel, so the next
one of these is a to-do list Pete can see rather than an archaeology dig.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
2026-07-24 14:46:02 -07:00
prosolis 8aa3e762ca solitaire: let cards be dragged, not only tapped
Tapping stays exactly as it was. A press that travels more than a few
pixels becomes a drag instead, and both paths go through the same pick()
and accepts(), so what lights up and what a move means never depends on
how you did it. The run follows the pointer as a copy while the real
cards stay on the felt, faded, and letting go over nothing keeps the run
in your hand with the targets still lit.

Capture happens when the press becomes a drag, not on pointerdown:
capturing early retargets the click that a tap ends with, and tapping
quietly stops working.
2026-07-18 09:03:08 -07:00
prosolis 19255d933a solitaire: fly the cards home instead of blinking them there
A foundation only ever draws its top card, so the FLIP diff had nothing to
animate for the fifty-one cards buried underneath: an auto-finish was every
card vanishing at once, four kings stranded in the tableau row, and the
foundation flashes firing on empty slots.

Cards bound for a foundation now fly as ghosts, lifted out of the old DOM
into a fixed layer so the board underneath can re-render in one go while
they are still in the air. The card that lands is held invisible until its
ghost arrives, so the destination doesn't show the ending first.

The home counter was jumping straight to 52/52 over a board with fifty-one
cards still travelling. It walks up behind them now, on the same step ladder
as the ghosts, the flashes and the sound.
2026-07-17 23:41:26 -07:00
prosolis 2593b11112 adventure: fix owner gear regression and drop em-dashes from equip copy
Show the public Gear panel whenever the owner's Equipment panel won't
render (no pushed Slots), so an owner whose detail predates ask 7 still
sees their standard gear instead of a blank. Replace em-dashes in the
equip feature's user-facing strings with plain commas.
2026-07-17 20:48:02 -07:00
prosolis b0aeffd218 adventure: ask 7 — full equipment management from the web
Extends equip-from-the-web (ask 5, magic-only) to all five standard gear
slots. Owners get an Equipment panel on their own who page with:
  - Take off for worn masterwork/arena pieces (round-trippable to pack)
  - Upgrade to the next shop tier (spends euros, confirm-gated)
  - Repair a damaged slot (spends euros, confirm-gated)
The public Gear panel is hidden for the owner since this supersedes it.

Wire: equip_orders gains a tier column; new actions upgrade/repair; new
verdicts rejected_downgrade / rejected_insufficient_funds / rejected_max_tier.
PlayerDetail carries Slots (EquipSlotView x5) + Balance for the confirm
dialogs. handleEquipOrder resolves take-off/upgrade/repair from pd.Slots
server-side and rejects a client-forged tier (409), same as ask 5 trusts
only Pete's own record.

Verified: full suite green, headless render of the panel + confirm dialog
in both day and night phases. gogobee ships the poll-apply half separately;
Pete deploys first so its ingest accepts the new verdicts before gogobee
emits them.
2026-07-17 20:34:24 -07:00
prosolis 1159e64505 auth: clear session cookie under both host-only and parent-domain scope
Logout emitted a single Set-Cookie scoped to the configured cookie domain
(parodia.dev). A browser holding the session under the older host-only scope
(news.parodia.dev, from before the cookie domain widened for the games site)
was never cleared, so logout looked like a no-op and stranded the user on a
stale session logout couldn't reach. Clear both scopes.

Also surface the who-page owner unlock's previously-silent misses: a genuine
lookup/decode error, and a signed-in session that carries no username. Both
used to fail with err discarded and no log, making a stuck owner undiagnosable.
2026-07-17 18:57:01 -07:00
prosolis 9d9cfd9f9a adventure: fix three review findings on the who page
- timelineLine named the viewer as their own rival on a lost duel: a
  rival_result arrives on the loser's page via the opponent column, so
  naming the opponent field pointed at themselves. Thread the page
  character's name through and name the other party.
- buildMapView drew a frontier room twice when two visited rooms both
  had a door to it (Nodes carried the id twice). Place each id once.
- proseGuard's board query ran before the IsGUIDSeen dedup, so a retried
  dispatch paid for it and threw it away. Check dedup first.
2026-07-17 10:31:15 -07:00
prosolis 8a5fea78ba adventure: mark ask 6 shipped, record the one-ring correction 2026-07-17 10:05:04 -07:00
prosolis 6c6de56539 adventure: show whether a backpack item is an upgrade over what's worn
On the owner's own page, each wearable backpack item now carries a compare
card: a verdict chip (upgrade / downgrade / sidegrade / new / inert / same),
the per-stat deltas, and the name of the worn item it's measured against.
gogobee computes all of it — the power math needs tempering and bond state,
which live in the engine — so Pete only colours the result and does no
arithmetic. It rides the owner-private inventory, so it never reaches the
public page; the item names are game-authored, so unlike the LLM dispatch
prose there's no injection surface.

The verdict chip is always visible (a phone has no hover to lean on) with
the deltas beside it. Chip colours mix a fixed hue into --ink for text and
--card for fill, so they land on the readable side of the card in all four
phases — the same by-construction contrast trick the dungeon map uses, not
a Tailwind dark: variant. Screenshot-verified day and night, all six
verdict states, including the purple 'new' chip that the theme-contrast
history warned about. output.css rebuilt and committed.
2026-07-17 10:04:38 -07:00
prosolis 3230939c51 adventure: scope ask 6 (item upgrade comparison), verified both sides 2026-07-17 09:42:18 -07:00
prosolis 4f4bd9fbc1 adventure: mark spec 2 shipped, record the voice-doc/network resolution 2026-07-17 09:28:43 -07:00
prosolis eeeac08db7 adventure: publish gogobee's LLM prose, guarded, template as the net
gogobee's LLM now authors a dispatch's headline and lede; Pete prefers them
over its template render when both are present and pass a prose-level guard,
and falls back to the template otherwise. factGuard only ever checked the
structured Subject/Opponent fields, which was the whole safety story while a
Pete template was the renderer — a template prints nothing Pete did not
interpolate. LLM prose breaks that: the guard would be validating fields that
are no longer what's rendered. proseGuard checks the rendered text itself,
rejecting a known adventurer named outside the fact's Actors (a live way to
put words in a real player's mouth) and anything past the length caps. The
templates stop being the renderer and become the safety net; they are not
deleted.
2026-07-17 09:27:53 -07:00
prosolis 6219224ea9 adventure: count the treasures an adventurer has actually found
A treasure_found fact turns loot into a trophy: a story-grade find lands on the
who page as a stat tile, a named row in a Treasures found showcase, and a trail
entry linking back to the dispatch. A realm-first hoard rides the priority tier
the way zone_first does, and gets the same star and callout.

The item name travels in the fact's stakes field, which the events log now keeps
(a new nullable column, backfilled to NULL for older rows). Counting is only ever
from the fact, never the vault snapshot, so a bought sword is never a trophy and a
history that predates the fact is a clean zero.

This is a new event_type, so Pete's ingest must be deployed before gogobee emits
one, or the first finds park forever on the retry ladder.
2026-07-17 09:02:19 -07:00
prosolis 1589c36e96 adventure: let an owner equip and unequip from their own page
The one adventure ask that carries intent back to the game box, built the
mischief way: no new network route, Pete records the equip/unequip and gogobee
polls it and files a verdict. The who page grows Equip / Take off buttons on the
owner's own worn and backpack panels, and a pending-changes strip that shows
'queued' until the verdict lands, never claiming a change it can't see.

The item handle is the inventory row id, sent only on wearable magic items, so a
non-zero id is also what gates the button. gogobee resolves the owner by
localpart, never a name.
2026-07-17 08:44:28 -07:00
prosolis e90deda498 adventure: draw the dungeon as a fog-of-war map, not a room tally 2026-07-17 08:18:17 -07:00
prosolis dcd68ebdcd adventure: write down the text-colour lead before it evaporates
Found while fixing the night-phase contrast, not fixed there. The evidence
points two ways — no such rule in the built CSS and a phase-ignoring computed
colour, but a screenshot showing that same text rendering correctly — and the
contradiction is the first thing to resolve, because it decides whether there
is a bug here at all. Recorded as a lead rather than dressed up as a finding.

Also records how the live-page measurement misleads, since every one of those
traps cost time and would cost it again.
2026-07-17 07:43:38 -07:00
prosolis 7d2d9910cf web: make the night phase's text colours survive a dark card
The adventure purple was unreadable on night, and measuring the family showed
it was not alone: every one of the eleven .text-theme-* colours lands between
1.08:1 (eu, effectively invisible) and 3.12:1 (finance) on night's #2d365a.
They were all picked against a light card and never checked against a dark
one. Same hue, lifted lightness, saturation floored so the dull ones stay a
colour instead of going grey — all now >=5.5:1.

Night only. Dusk and dawn are lit cards despite their names, so they keep
today's values exactly and the light phases are pixel-identical. Lego can't
stay pillar-box red and be legible on navy; a red light enough to pass reads
as salmon, which is the honest trade against a red nobody can see.

Also adds --warn as a phase variable for the new "inert" chip, for the same
reason --ink is one. Tailwind's dark: variant cannot do this job: darkMode is
unconfigured, so dark: follows the OS's prefers-color-scheme, which knows
nothing about which phase Pete is showing. That mismatch left the chip at
2.34:1 on a dark phase under a light OS, and 1.55:1 on a light phase under a
dark OS — half the combinations unreadable, decided by a setting outside the
page. Now 4.52-7.04 across all four phases, OS irrelevant.

Still open, and NOT fixed here: text-[color:var(--ink)]/NN appears not to
compile — there is no such rule in output.css, and the item description
computes to a fixed rgb(74,46,42) that ignores the phase entirely. If that
holds it is a pre-existing site-wide no-op, not confined to these panels.
Recorded rather than guessed at.
2026-07-17 07:28:05 -07:00
prosolis 1425033047 adventure: correct the spec where building it proved it wrong
Three claims in §4 were wrong, and each was only visible by reading both
repos together: equipped and inventory are disjoint sets (so the spec'd
attuned field was undefined, and the worn set it never asked for was the
actual gap); effects are modeled after all, by the engine's own summary
function; skill_source is dual-use and needs filtering, not forwarding.

Keeping the wrong text with the corrections above it — the pattern is worth
more than the fix. The spec was written a side at a time, so §§1-3 and 5
should be assumed to have the same class of error until checked.
2026-07-17 06:45:51 -07:00
prosolis 4ce025a82c adventure: show the item, not just its name and price
Renders what gogobee now sends: descriptions, the engine's own effect
summary, slot and skill tags, and a Worn panel with the bond count. One row
template for all three panels — worn, backpack, vault show the same facts and
only the frame differs.

The one distinction worth the wrapper struct: "inert" is a real problem state
— the item is on you doing nothing because all three bonds are spoken for.
The same item in a backpack isn't inert, it's just not worn yet. Rendering
both the same would invent a problem the player doesn't have, on the exact
panel they'd go to to fix it. An ItemView can't tell you which panel it's in,
so itemRow carries it, and TestWhoInertOnlyWhenWorn pins it.

Effect arrives resolved and Pete renders it as given. If it ever disagrees
with what an item does in a fight, that's a gogobee bug — deriving it here
from tier and slot would just be a second opinion that drifts.

No migration: detail rides as a JSON blob.
2026-07-17 06:45:06 -07:00
prosolis cd84f64a22 adventure: write the handoff down where the next session will find it
The spec says what the wire should look like. This says where the work
actually stands, which is the part that rots silently: two unpushed
commits, nothing deployed, and a trophy case that can only count
forward from a deploy that hasn't happened.

Also records the decisions already settled (room graph over
coordinates, server-side fog cut, mischief shape for equip, no faking
treasures from vault contents) so they don't get relitigated, and the
landmines that cost money to learn the first time.
2026-07-17 06:29:33 -07:00
prosolis 5fac1630f6 adventure: pin the gogobee contract before Pete assumes it
The five blocked asks all cross the gogobee seam, so spec the wire
before writing code against an imagined shape. Mapping both sides
first changed three answers:

- treasure_found is a gogobee feature, not a contract gap. No loot
  fact exists. But the story-grade filter already does, in the tier-5
  RoomAnnounce path.
- The room graph already exists, typed nodes and locked edges and all.
  pete_roster.go flattens it to "4 / 9" at the last moment, using a
  legacy index that isn't even persisted anymore.
- Item stat modifiers aren't modeled anywhere, so they can't be sent.
  Slot, SkillSource, Desc and Attunement can, today.

Deploy order is a data-loss rule, not a preference: an unknown
event_type 400s, gogobee retries to maxAttempts and parks the bulletin
forever. Pete's handler ships first or the first treasures are gone.

equip_orders copies mischief, not escrow. Mischief has no claimed
state because the poll loop is its own retry and the guid makes a
replay a no-op; an equip is a desired end state, not a delta, so it
converges the same way. Escrow needs claimed because money moves and
someone's watching a spinner. Neither is true here.
2026-07-17 00:48:07 -07:00
prosolis cbe9e67b3e adventure: keep the facts, not just the sentence we made of them
gogobee already sends boss/opponent/zone/outcome/level on every dispatch.
renderAdventure melted them into prose and only the prose was persisted, so
"how many bosses has she downed" was answerable only by parsing English back
out of a headline.

adventure_events keeps the fact as fact. It's the one adventure table that's a
log rather than a snapshot: the roster answers where Josie is now and is
replaced every tick; this answers what she has ever done, which no snapshot
can. INSERT OR IGNORE on the guid because gogobee retries a fact whose ack it
lost, and this is the only adventure store where a duplicate is permanently
wrong — the roster forgives one by replacing itself, a double-counted kill is
in the tally forever.

On top of it the who page grows two public sections, counted from dispatches
that were already public: the record (tallies, per-boss and per-zone, realm
firsts, milestones) and the trail (the last 40 facts, each linking to the
dispatch that told it). One read feeds both — the pool is MaxOpenConns(1), so
six COUNT queries would serialize for an answer that fits in memory.

Only the trail is capped. A limit on the read would truncate a *tally* rather
than a list, and a veteran's kill count frozen at 40 reads as a fact instead of
a missing page. Only the subject of a fact earns a trophy: a duel you lost
still names you, and it belongs on your trail but not in your record.

Trophies count forward only. Every adventurer's past is prose in the feed and
can't be counted back out, so they show no record until they next do something
— a clean absence rather than a wall of zeroes.

No treasures: there's no loot fact on the wire, and inventory is current-state
with no history, so counting the vault would score a bought sword as a trophy.
Needs a fact type upstream.
2026-07-16 23:57:23 -07:00
prosolis b814f936a8 solitaire: detect a won board and finish it in one press
A drained, all-face-up board is a guaranteed clear that auto() sweeps home
in a single cascade, but the only way to trigger it was double-clicking
thirty cards home by hand. Add State.Won(), surface it in the view, and
swap the ordinary controls for one pulsing finish button when it's true.
2026-07-15 18:50:43 -07:00
prosolis 8504c4f47a games: burial send-offs, money rain on wins, and honest stack targeting
Three front-end changes to the casino games:

- uno: a draw-card stack now names the seat it's actually pointed at
  ("+N on Beep") instead of always reading "+N on you". The bill reads
  v.turn, which the engine advances to the target when a draw card lands.

- uno: the No Mercy rule buries a seat with some ceremony now. bury()
  rolls one of four send-offs over the seat (or your hand, if it's you) —
  a rockslide that piles up, a tombstone, a coffin, or the Mega Man death
  burst — each with its own synth sound, and a static headstone under
  reduced motion. The "Buried on N" badge still rides alongside.

- all games: winning makes it rain. FX.moneyRain drops a curtain of bills
  and coins with a jackpot coin-cascade sound, gated by significance so it
  reads as a win and not as noise: hold'em keeps its light per-pot confetti
  and only rains on a real haul (>= 20bb), while the confetti stays the
  rare cherry (natural 21, full board clear, phrase solved outright).
2026-07-15 18:37:19 -07:00
prosolis e0d90ff7cc pwa: serve static assets network-first so redeploys stop serving stale JS/CSS
Asset URLs are not content-hashed, so the cache-first strategy pinned
whatever bytes were first cached and never noticed a redeployed file
changed — leaving stale weather-gl.js/output.css in place until a manual
CACHE_VERSION bump. Only a hard reload (which bypasses the SW) showed the
new assets. Switch /static/ to network-first (fall back to cache offline)
and bump CACHE_VERSION v4->v5 to flush the stale shell.
2026-07-15 17:45:53 -07:00
prosolis 790a118273 games: widen foley pitch variance so takes are actually distinct
vary was a per-step multiplier of ~0.05, capping the swing at a barely
audible +/-0.10. Redefine vary as the max deviation itself (chips +/-0.22,
cards +/-0.20) and spread it over seven pitch steps; give the single-take
shuffle a wobble too.
2026-07-14 23:51:21 -07:00
prosolis 8df2212aad games: drop chipLay3, it clashed with the other two chip takes 2026-07-14 23:48:58 -07:00
prosolis 1ca794ea1a games: rotate through foley takes on every play, not just by v
Take-selection was keyed only on the caller's v, but callers pass small
constants (chip is always v:0), so only the first take of each sound ever
played. Add a per-name cursor that advances every call: consecutive plays
now walk the takes, v just offsets simultaneous sounds and drives pitch.
2026-07-14 23:44:24 -07:00
prosolis 30b0e8debb games: real recorded foley for cards and chips, synth for the rest
The card and chip sounds never convinced as oscillators, so they're now
CC0 recordings (Kenney's casino pack) under /static/audio/casino, embedded
in the binary. Six names (card, deal, flip, shuffle, chip, sweep) play
several rotating takes with a little pitch jitter; everything melodic stays
synthesised. PeteSFX's API is unchanged and synthesis is the fallback while
an ogg is still decoding, so no caller changed and the table is never silent.
2026-07-14 23:28:10 -07:00
prosolis 4189c03a82 mischief: tighten the detail page's live poll and drop dead state
The who-page poll now keeps the location line honest both ways (a mark
that came back to town stops showing its old expedition) and stops
polling once the adventurer leaves the board. Also collapses a redundant
branch in ResolveMischiefOrder and removes the always-false whoPage.Stale.
2026-07-14 23:12:01 -07:00
prosolis 16711e13e6 adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
2026-07-14 22:22:52 -07:00
prosolis 2ac6ec6b91 mischief: a storefront where money buys a stranger some trouble
The web half of Mischief Makers M3. A signed-in buyer picks a mark off the
anonymous roster board and pays for a monster to find them; gogobee does the
real work and hands back a verdict Pete files against the order.

- mischief_orders: intent in, verdict out, idempotent on a guid that is the
  end-to-end key gogobee passes to DebitIdem and stamps on the contract
- user_euro + mischief_tiers: advisory balance and the live price list, pushed
  on the roster tick so the storefront never hardcodes a number that can drift
- OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire
- roster board grows a 'send trouble' button, a tier picker, and a status panel

Pete never touches money and never runs a game rule. It records what a buyer
wants and what gogobee said happened.
2026-07-14 20:55:15 -07:00
prosolis 983748ea98 games: brighter marquee bulbs, a chase that loops, and no more subtitle
The Casino Night light strip was a dim blurred stripe that jumped every
cycle (it slid further than the bulb pattern repeats). Now it's a row of
round bulbs with a white-hot core and a real glow, and the chase travels
exactly one bulb-period so it loops seamlessly. Dropped the 'Chips are
euros' line under the room name; the welcome card and house rules already
say it.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 20:26:48 -07:00
prosolis e5af5326d5 games: stop tracking the holdem-train binary
It is a build output of cmd/holdem-train, not source. Ignore it.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 19:32:08 -07:00
prosolis dbde827f75 games: the chat line that shows up once, not twice
Your own message came back twice — once from the POST that sent it, once
echoed over your own SSE stream — so the felt printed it on the rail twice.
Drop any chat id already seen; reset the seen-set when the log is cleared
(unseated, and on a full chat reload).

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 19:32:08 -07:00
prosolis 18049f6f59 games: the UNO felt other people can sit at, and the pot on the rail
Phase D frontend: uno.html and uno.js rewired from the solo bet-builder to the
session shape hold'em already ships. You sit with a buy-in (or join a table from
the lobby), ante into a pot each hand, deal when ready, and get up with what's in
front of you. Everything is keyed on your_seat now, not seat zero: the felt puts
your hand at the bottom whatever chair you took, and one EventSource per seat
refetches your own redacted view when the table changes. Chat runs along the rail.

The card rendering and the event-script animation are unchanged — a move still
plays back a whole lap of the table — but the money is simpler than solo: a pot
won moves your on-table stack, not your purse, so the chip bar only stirs at
sit-down and get-up. Page renders; the two-browser pass is still to come.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:44:10 -07:00
prosolis 927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.

The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.

The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 18:37:51 -07:00
prosolis f8b07d8e6c games: the buy-in and the rake each player sees are their own, not the table's
The two-browser pass found it: at a table two humans share, the felt quoted
each of them the pair's total. "Bought in for 200" to a player who put in 100,
and a session-rake line that climbed on a pot the other one won.

Both were table totals the view read straight off the engine — correct while a
table had one human, wrong the moment it had two. Fixed along the border it
already draws: bought_in is border accounting, so it comes from the viewer's own
game_seats.staked; session rake is a within-table event, so it rides a new
per-seat Seat.Paid beside the audit's table-total s.Paid.

And top-up never grew game_seats.staked, so the storage invariant drifted by
every top-up and the felt under-reported the buy-in — it does now.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 17:17:25 -07:00
prosolis 4ad96dcb5e games: the felt that knows which seat is yours, and the rail you can talk on
Phase C frontend: the hold'em felt runs on the shared-table runtime.

- holdem.js reads view.your_seat instead of assuming seat zero — every "you"
  test (layout, your cards, the burst on a pot you win, the verdict) is keyed on
  it now, so a joiner at seat 2 sees their own hand at the bottom.
- Leaving is its own endpoint, and a bust closes a solo table; play() animates a
  session-ending hand (the last showdown) before the felt clears.
- A live table: one EventSource per seated player. The server pushes a nudge on
  every table change and a chat line as it is said; a nudge refetches the player's
  own redacted view (a hole card must never ride a frame that fans to the table),
  and a frame that lands mid-animation is held until the script finishes.
- Chat on the felt (a _chat panel, messages only) and a lobby that lists tables
  with a seat going spare. Two-cookie dev rig (reala + bob), with the turn clock
  and reaper live under it.

Browser-confirmed for solo: sit renders your seat and the rail, a hand deals and
conserves to the chip (bought in 100, 100 in front), chat sends. The two-browser
multiplayer pass (join, live sync between windows, shared-table conservation) is
still owed before this deploys.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 16:49:27 -07:00