Commit Graph
79 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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
prosolis a5b7e41929 games: the card the dealer never turned over, and the bet that came back doubled
Bust every hand and the dealer doesn't draw, which is right, but it was also
not turning over: reveal is only emitted by dealerPlay, and busting out skips
the dealer entirely. The browser kept the hole card face down while the settled
state printed the dealer's whole total under it. Emit the reveal on that path.

And standing your bet back up after a reload read the hand's bet straight off
the settled state, which a double has already doubled. Reload, double 200, and
the next hand starts with 400 on the spot.

Plus: the share card was hand-writing a Content-Length that ServeContent
overwrites anyway, and serving a zero-byte 200 for a room with no card.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 14:22:26 -07:00
prosolis 6f34a89622 games: the hand that becomes two, and the bet that has to follow it
Blackjack has a split. It was the last rule missing from a game that has been
live for a week, and it is the only move in blackjack that takes chips out of
your stack *after* the cards are out — which is most of what there is to get
wrong about it.

So the state stops pretending. State.Player is gone; there is a slice of Hands,
each with its own cards, its own bet, its own outcome and its own payout, and an
Active index the player works left to right. Settle runs per hand and rakes per
hand: netting them against each other first would mean a player who won one and
lost one paid no rake at all, which is not a rake, it's a discount for
splitting. The web layer takes the second bet before the move and hands it
straight back if the engine refuses — the same shape double already used, except
double was staking st.Bet, the whole table's stake, which was the same number as
the hand's until today and is now emphatically not. DoubleCost/SplitCost are the
active hand's, and the felt would have found this by charging you 300 to double
the third hand of a split.

The rules that cost money if you guess them: split aces get one card each and no
say (a pair of aces is otherwise the best hand in the game, forever), 21 on a
split hand is twenty-one and not a natural (it does not pay 3:2 — the test that
pins this is the most expensive one in the file), same rank rather than same
value (a king and a queen are not a pair), four hands maximum, double after
split allowed, and if every hand busts the dealer does not turn over.

A live hand outlives a deploy, so State.UnmarshalJSON still reads the old blobs:
"player" with no "hands" becomes one hand holding the whole stake. Without it, a
player mid-hand at restart is a player whose cards vanished — which is not a
decode error, and would not have looked like one.

On the felt a hand is now a box with its own spot, and a split is a card lifting
out of one hand into a new one with a second stack of chips flying after it from
your pile. Verified in a browser against a real pair: chips 4738 -> 4638 on the
split, two hands played out, one push and one loss, "Down on the deal. -100",
4738 back. Three hands stack without collision at 390px. Settled hands come back
to full brightness — dimming means "not your turn", and when the deal is over
they are the thing you are reading.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:54:55 -07:00
prosolis 39ed293f4f games: the word you owe the table, and the hand you were already holding
Three things, and the first one was a bug.

Your own hand didn't move until the lap ended. bump() keeps the bots'
fans honest and has always refused seat zero, and nothing else touched
yours — so a +4 landing on you at the top of a lap put four backs into
your hand and then nothing, and the cards themselves turned up seconds
later when the script finished and paint() finally ran. You spent the
whole lap looking at a hand you no longer held. The engine now stamps
your hand onto every event that changes it (Event.Hand, seat zero only,
which is the one hand the browser is already entitled to see) and the
table redraws as the cards land. Measured in the running app: 2 -> 3
cards at 414ms into a 1791ms lap.

You couldn't call UNO, and not because the button was missing: going
down to one card *was* the call. discard() fired the uno event by
itself, which made it a thing that happened to you rather than a thing
you did, and a rule nobody can fail is not a rule. So now you say it or
you don't (Move.Uno), and if you don't, every bot still in the game gets
one look at you before any of them plays — because a bot that has moved
on is a bot that has stopped watching your hand. It runs the other way
too, and that half is the fun one: a bot forgets often enough to be
worth watching for, and when it does it says *nothing*. No event, no
badge, no tell on the felt except the count beside its fan reading
"1 card". Catch it and it takes two; call a seat that had nothing to
hide and you take two yourself, which is what stops the catch button
from being a thing you simply mash.

Which cards owe the call is the engine's answer, not a count of your
hand: No Mercy's "discard all" takes every card of its colour with it,
so a six-card hand can land on one, and a browser subtracting one from
six walks you into a catch it never warned you about.

And the room was silent. Every sound in here is *made* — an oscillator,
a burst of filtered noise, an envelope — the same bargain the weather
engine takes with its clouds. A card is a slap of noise through a
bandpass, a chip is two detuned sines with a knock on the front, a win
is four notes going up. No asset files, no round trips, and a sound can
be pitched and detuned per call instead of being the same wav three
hundred times. Hooked into the FX layer rather than into the games, so
every table that throws a chip or turns a card got it at once.

The multiples moved, and the test that exists to catch that caught it.
The naive strategy now calls UNO, because calling is a button and not a
strategy — what these tiers price is bad card play, not a player who
ignores the felt shouting at them — and on that footing the normal
tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full
House did not: it was paying a *negative* house edge, which is the house
paying you to sit down. Re-priced 3.8 -> 3.5.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 13:15:11 -07:00
prosolis 8db8845feb games: no mercy on the felt, and the bill that went to the wrong window
The engine has been able to play No Mercy since aca523e. Now a browser can.

The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.

The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.

A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.

Two bugs, both found in a browser and neither findable anywhere else:

The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.

And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 11:10:07 -07:00
prosolis b96879d25c games: the short stack that could win money it never matched
A review pass, and it found the one that would have cost somebody real chips.

Side pots were only ever cut in runout() — the path taken when the betting
stops because nobody is left able to bet. But a hand reaches a showdown with an
all-in player in it and the betting having finished perfectly normally: a short
stack shoves, two players who still have chips behind call, and then keep
betting past them street after street to the river. Nothing was cut. One pot,
everybody eligible, and the short stack takes the lot — every chip the deep
players put in after they were already all-in, money that could never have been
lost to them. All-in for 100 against two players who each put in 500, and the
best hand collects 1,100 instead of the 300 it was playing for.

Chip conservation never saw it. The chips balance perfectly; they just land in
the wrong seat. And every browser session went through runout(), because a
player shoving is what ends the betting. It took reading the code.

Also from the review: play() dereferenced a table it had just been handed as
null, the top-up button offered chips the wallet could not cover, and the
trainer's ETA was sixty thousand hands optimistic on the first line it printed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:16:52 -07:00
prosolis e6c1bd3b54 games: the poker table opens, and the bots go back to school
Phase 4. Hold'em, and it's the only table in the casino that is a session
rather than a game: you buy in, play as many hands as you like, and leave with
what's in front of you. So the live row spans hands and chips cross the border
exactly twice. Everything in between is inside the engine.

The bots move inside ApplyMove, as UNO's do, which is what keeps poker off a
socket: shove all-in and the flop, turn, river, showdown and payout all come
back in one response, as a script the felt plays back.

The CFR policy the plan called "the single highest-value asset in either repo"
was never read. Not once, in the whole life of the game: the trainer wrote its
info-set keys under IP/OOP and the runtime looked them up under BTN/SB/BB, so
every lookup missed and fell silently through to a pot-odds heuristic. Nothing
looked broken, because a policy miss is not an error. And it was the wrong
policy anyway — ten big blinds deep, trained on a tree where a call always ends
the street, which is not poker. So the trainer is rewritten to play the real
engine through the real reducer, at every stack depth the table deals, and the
trainer and the table now build the key with the same function so they cannot
drift apart again. A test fails if the bots stop finding themselves in it.

Three money bugs, and the tests earned their keep. Chip conservation across a
hundred sessions caught an uncalled bet that minted chips. A var-init ordering
trap meant every card was identical, every showdown tied and every bot believed
it held exactly 50% equity. And the browser caught the rake being silently
zero — the tier said 5 meaning percent, the casino handed it 0.05 meaning a
fraction, and integer division took the house's cut down to nothing.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:08:59 -07:00
prosolis 6e20883e5d games: the table that couldn't end, and the lock that let go too early
A code review of the uno table found the stuck guard had never once fired.
It counted how many bots had passed in a row and wanted more of them than
there are seats — but the bot loop hands the turn back the moment it comes
round to you, so the count could never get there, and your own empty-handed
pass was never in it. A dead table just passed the turn round forever. That
is not an ugly ending, it's a game you cannot finish, and a game you cannot
finish is chips you cannot cash out. So it asks the real question now: is
there anything to draw, and is anyone holding a card that goes.

And the table let go of itself too early. busy came off when the request
landed, not when the script it came back with had finished playing — so for
the seconds a bot lap takes, you could click a card at a board the server
had already moved past. It comes off at the end now, like the other tables.

Also: left: 0 was being dropped on its way out the door, which is the one
number that matters (the seat that just went out), the deck counter didn't
come back after a reshuffle, and hoisting fly() into flyNode() had quietly
flattened the chip arc on every other table in the room.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:50:52 -07:00
prosolis d7e63d86a6 games: the uno table opens its doors
Driven in a browser for the first time, which is where three bugs were.

Every visit to /games/uno was a 500: the page was never added to the list
server.go parses into the games template set, so render() answered "unknown
page". The casino tests all call their handlers directly and never go through
render(), so nothing saw it. TestEveryCasinoPageRenders now walks the mux and
asks for every page the casino routes to.

The play script hid the first card that lit up rather than the one you clicked,
so playing any other playable card made an innocent card vanish. And on a phone
the discard sized its box but not its card, which takes its size from --uno-h,
so a full-size card hung out of a small hole and covered the colour in play.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:38:03 -07:00
prosolis 79c857023f games: a table of bots you have to beat to the last card
UNO, played for chips. You stake once, sit down against one to three bots,
and going out first pays the table: 2.2x heads up, 3.6x against a full house.
Anybody else going out first takes the stake. The table size is the tier,
because it is the only dial UNO has.

The bots move inside ApplyMove. A game with opponents is normally where you
reach for a socket, and the plan says solo UNO must not — so one request plays
your move and every bot turn behind it, and hands back the whole lap as a
script the felt plays in order.

The RNG is in the state rather than an argument to it: the bots choose and a
spent deck reshuffles, so the engine needs randomness mid-game, and there is no
generator alive across requests to pass in. The seed rides in the state and each
step derives its own. The game still replays exactly as it fell.

The zero value of Color is Wild, and that is the whole point of it: a wild
played with the colour field missing from the JSON must be refused, not
quietly played as a red one. It was red for an hour.

The browser never sees a bot's card — not the deck, not a hand, not the face of
a card a bot drew, which is most of the deck. Seats cross the wire as a name and
a count.

The multiples are measured, not guessed: playing the first legal card you hold
wins 43/32/27% of the time against these bots, so the tiers price that to lose
about 8% a game and leave good play worth roughly the house's edge.

PeteFX.flyNode is the throw with the chip taken out of it, so a card can be
thrown across the felt the same way. fly() is now that with a chip in it.

Not yet driven in a browser, which in this room means not yet finished.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:07:17 -07:00
prosolis 3e9b93af55 games: the clock beats the walk button, and the rack isn't betting
The trivia ladder handled a walk before it looked at the clock, so the
timeout only ever bit if the browser volunteered it. Sit on a question,
look it up, answer if you find it and walk if you don't, and you never
lose a ladder. The clock is now the first thing that happens to a move.

The house's chip rack was wired up as bet buttons on blackjack and
hangman: it's four spans with data-chip on them and nothing said the
handler only wanted the real ones. Clicking the house's money raised
your bet.

Hangman had two definitions of "a letter you'd guess" — unicode in the
engine, ASCII in the renderer — and a phrase with an accent in it would
have had no tile to fill and no key to fill it with. One definition now.

Plus: trivia's countdown no longer freezes at zero when the server turns
down a timeout report it was early for, questions whose wrong answer
decodes into the right one are dropped at the door, and hangman bets on
PeteFX's spot like every other table instead of its own copy of it.
2026-07-14 06:28:38 -07:00
prosolis 2d653bf439 games: the ladder gets played, and the rack learns where to stand
Trivia had every Go test passing and had never been in a browser, which
this plan's own rule says means nothing. So: play it.

The game itself holds up. The clock drains honestly and does not restart
on a reload, the multiple compounds, walking pays exactly what the felt
quoted, the reveal marks the right answer, and the auto-submit at zero
lands as a timeout rather than an illegal move. The next question's
answer never crosses the wire.

Two bugs only the browser could show:

- The spot printed double the stake after every settled game. standing()
  set spot.amount and *then* poured the chips on, and pour grows the pile
  from what it is told is already there. The money was always right; the
  number under the chips was not, which is the one rule the felt is built
  on.

- The house rack sat on top of the multiplier at 390px. Its 5.75rem inset
  is not a margin, it is the width of blackjack's shoe — so on a phone the
  rack sits in the middle of the felt. It now shrinks on small screens and
  pulls into the corner where the corner is empty; data-at says which rack
  is which, because pulling blackjack's to the edge slides it under the
  deck.

The dev rig seeds its own question bank now (one real OpenTDB batch per
difficulty), because a fresh dev database 503s every start otherwise.
2026-07-14 02:33:28 -07:00
prosolis c62d736223 games: a ladder you climb against the clock 2026-07-14 02:11:09 -07:00
prosolis 5ca056bf20 games: you buy the deck, and win it back a card at a time
Solitaire, Vegas rules — the only shape solitaire has ever had as a
gambling game. You don't win or lose the deal: the stake buys the deck
outright, and every card you get home to a foundation pays a fifty-second
of the tier's multiple back. Cash the board whenever you like and keep
what you've banked, so a board that has gone dead is a decision rather
than a wall. No undo: the stake is spent the moment the deck is bought,
and an undo would be a way to walk a losing board backwards until it wins.

Three deals, and the two dials are the whole difficulty of Klondike.
Patient draws one with unlimited passes and pays 1.4x, so it takes 38
cards home to get square. Vegas draws three, three times round, 2.2x,
square at 24. Cutthroat draws three and gives you one pass, 3.4x, square
at 16 — most of those boards never clear, and you're ahead long before
they would.

internal/games/klondike is the same pure reducer as the other two, and
Pays() is one function for the same reason hangman's is. Two fuzzers hold
the deck together: no sequence of moves can lose or duplicate a card, and
the board stays well-formed. They earned their keep immediately — the
first thing they caught was a recycle that reversed the waste. It flips as
a block, so the card drawn first comes out first, and reversing it would
have dealt a different game on every pass and quietly broken the seed in
the audit log.

The browser never sees the stock or a face-down card, which here is most
of the deck rather than blackjack's one hole card: a column sends how many
cards are under it, never which.

The table re-renders and animates the difference. Blackjack plays back a
script because a hand only ever grows at one end; solitaire moves runs
from anywhere to anywhere and an auto-finish moves eleven cards at once,
so a script of "append this card there" would be a second engine over here
and it would be the one that's wrong. Instead the board on screen is
always exactly the board the server says exists, and each card is played
from where it just was to where it now is. The events supply only what a
diff can't: where a newly-revealed card came from, and what the board is
worth.

The rules are mirrored in JS on purpose, and only to light up the columns
a held card can go to. Being shown where a card goes is the game teaching
you; being told no after you commit is the game scolding you. The server
still decides, and a disagreement snaps the board back to what it says.

Two things came out into the open rather than being copied, which is the
rule this room runs on: casino-cards.js (the deck — faces, pips, the flip)
and PeteFX.spot() (the pile of chips and the number under it, which now
owns the rule that the number is a readout of the pile). Blackjack uses
both.

Not yet driven in a browser.
2026-07-14 01:40:14 -07:00
prosolis fe2195e85f games: a gallows you can bet on
Hangman, and it plays for chips — which the plan had down as a free game, on
the grounds that trivia has no euro coupling in gogobee. But a free game in a
casino reads as a demo, so it stakes like everything else.

The idea that makes it a casino game rather than hangman with a wager stapled
on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a
tenth off what a win is worth, because those are the same event and showing
them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer
letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands
back less than the stake, and the rake still comes out of winnings only.

State.Pays() is the number the felt quotes and the number settle() lands on.
They were briefly two sums, and the table spent an afternoon advertising a
pre-rake payout it didn't honour.

Two things the storage layer had already decided for us, and one it hadn't:
game_live_hands is keyed on the player, so "one game at a time" holds across
games for free (a live hangman 409s a blackjack deal, stake intact). But
table() unmarshalled every live row as a blackjack hand, which does not fail on
a hangman row — it quietly yields an empty hand. It dispatches on the game now.

commit() is the settle path both games share, and casinoRoutes() the one route
list, since the dev rig wires its own mux and a second copy is a copy that stops
including the newest game.

Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
2026-07-14 01:19:05 -07:00
prosolis 6961f90634 games: the money moves
The table dealt cards but settled money by editing a number. So the felt got
the two things it was missing: a bet spot in front of you, and the house's rack
beside the shoe. Every chip is now always travelling between one of those and
the other.

You build a bet by throwing chips onto the spot — the chip you clicked is the
chip that flies. The stake sits there through the hand. The house pays out of
its rack into the spot, and the pile is then swept back to your stack. A loss
goes to the rack and does not come back.

Two rules hold it together. The number under the pile is a readout of the pile,
never the other way round: the bet starts at nothing rather than at a default
nobody put down, and a settled hand leaves your stake back up on the spot,
because otherwise the panel prints "your bet: 300" over an empty circle. And
the chip bar does not move until the chips that justify it have landed — a
counter that pays you before the dealer turns over is a counter that has told
you the ending.

casino-fx.js is the engine underneath: chips fly on an arc, out of a fixed
overlay so no container clips one crossing from a button to the felt. It knows
nothing about blackjack.

Also: cards land with weight and a degree or two of tilt, so a hand looks dealt
rather than typeset; the dealer takes a beat before drawing out; and a natural
gets confetti, which is the only thing in the room that does.

Driven in a real browser, which is the only way to review an animation — and
which is what caught the verdict pill rendering white on white in a dark room,
a chip rack sitting on top of the dealer, and Hit being offered over a table
that was still being paid out. devcasino_test.go is that harness, kept.
2026-07-14 00:33:49 -07:00
prosolis b00da21a47 games: draw the card, don't type it
The last attempt built a card face out of text: a "♠" in a span for every
pip. At the size a card actually is, a suit character renders as a speck —
the shape is whatever font answered, it doesn't scale, and it can't be put
on the half-row a real pip layout needs. The result read worse than the
plain rank it replaced.

So each face is one SVG on a 100×140 field, suits as vector shapes, pips at
the coordinates a printed deck puts them. Courts get a framed panel with the
suit above the letter and again below it upside down — mirroring a letter,
which is what the first pass did, just stacks two of them into a blob; a real
court mirrors a figure.

Also restores .pete-card-back, which went out with the text rules it was
sitting among: without it a face-down card had no back at all, so the
dealer's hole card was invisible on the felt. Caught by driving a hand.
2026-07-13 23:49:17 -07:00
prosolis 8ec13eab5b games: the casino moves out, and gets a clock of its own
The tables were living in the news app's shell: Pete's face in the header
and the footer, the channel nav, search, the reader, the weather canvas,
the PWA. A casino is not a news page with a felt on it.

So it gets its own layout. What carries over is the design language — the
four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped
shadow. What doesn't is every control it has no use for. gamesPage stops
embedding the news pageData, which is what keeps the furniture from
drifting back one convenient field at a time.

It keeps a clock, but tells a different joke with it: Casinopolis by day,
Casino Night Zone from six, palette and felt and the sign over the door all
changing together. The rule lives in roomAt() for the first paint and again
in the browser, so a player abroad gets their own evening.

And the cards are cards now — corner indices in both corners, the bottom
one upside down as printed, pips on the three-by-seven grid every real deck
has used for four hundred years, courts as a letter with the suit over each
shoulder. Driven in a real browser, both rooms, dealt through to a payout.
2026-07-13 23:40:33 -07:00
prosolis c69fbb63db games: a blackjack table you can actually sit down at
The engine, the escrow and the wire were all in place; nothing had a browser on
the end of it. This is that end: a lobby, a table, and the five endpoints between
them.

The browser holds no game. It sends intents and gets back a view — the cards it
is entitled to see, and the script of how they arrived, one event per card off
the shoe. The dealer's hole card is not in the payload at all until the reveal,
because a field the client is told to ignore is a field somebody reads in
devtools. The shoe lives in game_live_hands, which also means a redeploy
mid-hand no longer costs a player their stake: the hand is still there when they
come back.

The money is ordered so nothing can be spent twice. The stake leaves the stack in
the same statement that checks it exists, before a card is dealt. Every new hand
is seated with a plain INSERT, so a double-clicked Deal is decided by the primary
key rather than by a read that raced — it loses, gets its chips back, and the
hand in progress is untouched. A double takes its raise up front and hands it
straight back if the engine refuses the move.

Cards are dealt rather than swapped in — they fly out of the shoe and turn over,
which was a requirement and not a flourish. The faces and the chips are still
plain; that's next.
2026-07-13 23:20:42 -07:00
prosolis 6ccd18452c css: rebuild — the stale-roster dimmer had no rule behind it
99574db added the live roster card to the adventure page but shipped the CSS
bundle it was built against, which is generated and checked in. Three utility
classes the new markup uses were never compiled: mb-3, divide-y, and opacity-60.

The last one isn't cosmetic. channel.html toggles opacity-60 from JS to dim the
card when the roster data has gone stale — that dimming IS the staleness signal,
and with no rule behind it a stale roster rendered identically to a live one.
Same failure as the ticker: the thing that's supposed to look wrong looks fine.

Pure `npm run build:css` output, byte-reproducible from the committed templates.
2026-07-13 20:05:16 -07:00
prosolis 4c671fb410 Adventure section: ingest, permalink, digest, emblems, noindex
Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.

- adventure.go: bearer ingest + fact-guard + 13 event templates;
  /adventure/{guid} permalink (story.html); per-event SVG emblems at
  /adventure/art/{type}.svg (card image + og:image); NoPush suppresses
  the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
  unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
  avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
  web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
  adventure pages are noindex (player-named; gap #5).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:59 -07:00
prosolis 0a723418ff Redo moon and clouds as procedural shaders in the GL engine
The baked atlas sprites (blurred-circle clouds, gradient moon with flat
maria blobs) read as cheap. Clouds are now built per-instance in the
particle fragment shader from fbm density with a flattened base and lit
tops; the moon renders in the sky shader with sphere shading, emboss
relief, maria and a halo. Stars skip the moon disc since the sky pass
draws first.
2026-07-08 00:19:55 -07:00