The morning digest looked up log entries by e.CurrentDay - 1, which
assumed the day rollover happens in deliverBriefing. That only holds for
legacy UTC-anchored runs; event-anchored expeditions roll current_day at
night camp, so by 06:00 CurrentDay is already the day that is starting
and the digest reported a near-empty day. Window on the last briefing
timestamp instead, which also covers nights the autopilot never camped.
Also count real rooms out of the auto-walk summary instead of counting
ticks, and only emit the site footer when the Pete seam is on, since the
roster push is what creates the page the link points at.
Adventure's web feed now carries a live per-room run log, so the bot no
longer narrates every beat into Matrix. The three per-player DM sources
that fired on a clock collapse into the 06:00 briefing:
- ambient events (6h cooldown, up to ~3/day)
- the 21:00 recap
- the autopilot's Night-camp end-of-day digest
Only the messaging goes quiet. Every mechanical effect still fires on
exactly the schedule it always did: ambient still applies its ±SU and
threat nudges, the recap still runs the night wandering check and its
threat bump, the night camp still burns supply and rolls the day. Each
now writes its outcome to the expedition log and stops there, and the
next morning's briefing reads that log back as a "Since yesterday"
block -- walks collapsed to a count, frame and narration types skipped,
capped with an explicit overflow note so a truncated digest never reads
as a complete one.
The briefing carries the reader's own /adventure/who/{token} link,
reusing the salted one-way roster token the board already publishes, so
a DM link and a board link resolve to one page and neither leaks a
Matrix handle. PETE_PUBLIC_URL overrides the default; it is deliberately
not PETE_INGEST_URL, which is Headscale-only and unreachable from a
player's phone.
N1/A6's digest-anchored mid-day event roll moved to deliverBriefing
along with the digest. An anchored roll's whole premise is firing at a
moment the player is demonstrably reading a DM, and the night camp no
longer sends one; leaving it in place would have quietly starved the
anchor for exactly the autopilot-only player it was added for.
Interrupt-driven DMs are untouched: a fork needs a human, a death and a
run-completion are terminal, and a boss-safety hold is an explicit
stop. Batching those would strand a decision behind the 8h auto-pick or
report a finished story.
Net effect for a player with an active expedition: roughly 6-10 DMs a
day down to one, plus whatever genuinely interrupts.
Tests: the recap and ambient send paths had no coverage at all, so
nothing would have caught them silently dropping their mechanics along
with their message. Adds behavioural tests via the message sink for all
three silenced paths, digest rendering, token-not-handle in the URL,
and a keep-set regression guard.
GetDMRoom's lookup chain could never hit. The in-memory cache died with the
process, and the m.direct account data it fell back on was never written by
the bot -- room creation only ever populated the map. As an appservice user
there is no /sync to populate it either, so after each restart the first DM to
a user fell through both checks and created a fresh room.
Persist the mapping in a new dm_rooms table and read it before anything else.
Creation now also publishes m.direct, so clients label the room as a DM.
A stored room is checked for liveness before reuse, counting the user as
present while merely invited -- they often never accept, and treating that as
gone would recreate the room on every send, which is the original bug wearing
a different hat. The same check now guards the m.direct path, which previously
took the last entry on faith.
Two adoption paths keep existing rooms from being orphaned: user-initiated DM
invites are claimed on join, and a room the user is already talking in is
adopted on first message (negative-cached so group rooms cost one member
lookup, not one per message). For users predating the table, a one-shot sweep
of joined rooms runs only on the path that would otherwise create a duplicate.
The sweep cannot rank duplicates -- without /sync there are no timestamps, so
it takes the newest by room-list order. First message from the user corrects
any wrong pick.
Replaces the per-plugin Ollama HTTP calls with internal/llm, which picks a
backend from the environment (vLLM or Ollama) behind one Chat interface, plus
internal/plugin/llm_client.go as the plugin-facing wrapper.
Startup now logs llm_backend/llm_endpoint/llm_model instead of the two
OLLAMA_* vars, which no longer describe where inference actually goes.
These files were already running in prod from the vLLM migration but had never
been committed; this is that live state, byte-for-byte.
advTreasureDropRates stopped at tier 5, so every Mythic-zone roll returned
nil before it reached a pool: postgame zones have dropped no treasure at
all since T6 shipped. Add the rate, deliberately above T5's rather than
continuing the downward curve — a Mythic run is gated behind L18 and both
T5 bosses, so the same declining rate would mean a postgame player never
sees one.
The rate alone drops nothing: there is no advAllTreasures[6] yet, and
writing those four items is content work, not a code fix. TODO in place
where the pool goes, and a test that goes red the day it lands.
An empty pool now reports a zero rate instead of the configured one. The
caller reads a close roll as "treasure: just missed" — with a rate but no
pool that DM fires on the rolls the player actually won, for a treasure
that cannot be won. Better to say nothing until there is something to win.
Every Tier 5 treasure line said the Abyssal Maw, whichever zone the drop
came from. A find in Dragon's Lair announced itself as the Maw while
Pete's dispatch, which reads the real location off the fact, named the
Lair. Two tellings of one moment that disagreed about where it happened.
Substitute the location instead, in the room announce and the discovery
DM both. {location_mid} lowercases the leading article so a line reads
"found in the Abyssal Maw" after a preposition; {location} keeps the
display form for sentence-initial and "The {location}" positions, which
the T1-T4 pools already rely on.
- Rewrite the LootDropCommon offenders and ~20 other lines that had the
cadence but no concrete detail (per VOICE_CANON craft rules)
- Fix 7 grammar scars from the third-person-to-first-person B2 pass
("...and am not worth picking up" where the subject wasn't TwinBee)
- Replace generic "every RPG" references with specific ones
- Add new lines across ambient/GM/housing/NPC pools, incl. funny-word
register (kerfuffle, skedaddle, lollygagging) played straight
- New Thom Krooke line follows the pet-first arc
The party list is omitempty, so a solo run and a game box too old to push
seats reach Pete as the same empty slice, and those two want opposite
buttons. Pete's page was reading the empty one as "solo, therefore leader"
and offering a party member the button that throws away everyone's day.
One boolean, set on every sheet a build makes — in town, solo, or seated
with three others. It says something about the sender, never about the
character, so it is set unconditionally: a conditional flag would read as
"this player is solo" and land straight back in the hole it closes. An
older build sends no key at all, which decodes to false, which is the
answer Pete wants when it cannot tell.
Pete's half shipped already and withholds the button until this arrives.
Verified end to end against a local Pete, not just in tests: a solo leader
on a live expedition gets "Call the whole thing off" back, a member still
gets "Turn back alone" and never the abandon, and stripping the flag from
the same push takes the solo button away again.
The run summary had sixty seconds, which is a generation budget, and
this call almost never gets to just generate. Runs end hours apart,
Ollama drops an idle model after about five minutes, so the steady state
is weights on disk and a cold load before the first token. The old
budget expired during that load every time and filed the empty beat that
means "no summary, ever" — there is no retry, the beat itself is the row
that stops the sweep re-picking the run. Five minutes now, sized for
load-then-generate, so a timeout means what the comment always claimed
it meant: the box is down.
That can't sit on a two-minute ticker, so it doesn't. The sweep starts
beside the ticker behind a single-flight flag; ticks that land during a
load skip instead of queueing. Nothing reorders — the summary is written
to the local buffer with the next seq and still ships behind the run's
own log.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
Four things the review found, all of them the code telling a player
something that isn't true.
A run that walks into a dead end filed its ending as "cleared" whatever
the caller said, so both the liveblog and the summary reported a clear
for a party that merely ran out of map — and the end beat is
first-writer-wins, so nothing later could take it back. It says
"cleared" only for a boss now.
The re-offer branches of babysit and resume returned a zero cost, so a
player who was in fact charged read "0 coins" in the verdict. Both
re-quote the price they actually took.
And the realm-firsts reseed retired its one-shot job even when the read
under it had failed, which on a transient fault at Init would have left
the ledger mis-dated permanently. The read now says whether it worked,
and the job stays open when it didn't.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
The web's own refusals named commands it had no button for: "!expedition
abandon first", "!expedition leave to walk out alone", "no refund if you
cancel early". Three verbs, no arguments, no money.
Each Matrix handler becomes framing over a headless twin the order path
shares, so the web runs the game's real path: an abandon still disbands
the roster, still retires the region runs, still DMs the party, still
rolls the emergence pet.
The lock asymmetry is per verb and is the thing to be careful about.
performExpeditionAbandon and performExpeditionLeave cannot take
advUserLock — their Matrix caller holds it across the whole !expedition
switch — so their web wrappers do. performBabysitCancel takes it itself,
because its caller does not, so its wrapper must not. Both directions are
pinned by tests that hang rather than fail on regression.
Also flips the two W5a extract tests off setupZoneRunTestDB: it copies
data/gogobee.db and skips when it is missing, so they have been
green-by-skipping since they were written.
The one-shot that seeded news_realm_firsts filtered on `abandoned = 0`, and
`abandoned` does not mean anybody gave up: abandonZoneRunByID exists to retire a
run whose boss is already dead when the expedition travels onward, which is how
30 of prod's 32 boss kills are stored. So the ledger holds 6 zones where the run
history knows 9, and it dates every row to the minute the job ran rather than to
the clear.
The ledger is what claimRealmFirst tiers live dispatches against, so each missed
zone is a spurious PRIORITY "realm first" waiting to fire the next time somebody
clears it, months late.
Fix the shared query, and add a re-seed (not a re-run) under its own job name: it
writes the ledger, backdated to the real earliest clear, and emits nothing at
all. It runs regardless of the news switches, because a ledger that is right only
while emission is on mis-tiers the first dispatch after somebody flips it. Kept
in place afterwards as an ordinary bootstrap for fresh deploys.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
Party seats ride the roster detail, beside the supply and threat numbers the
same expedition already publishes. Leader first, the hireling named without a
token he has no board row for, and an opted-out player's seat kept and
anonymised — dropping it would make a party of three read as a pair while the
burn rate and enemy scaling beside it still felt three bodies.
The board itself was wrong about this: it resolved an expedition with a lookup
keyed on dnd_expedition.user_id, which is blind to members, so for the whole
life of N3 parties a seated player has shown as idle in town while standing in
a dungeon.
Pets also send the engine's XP threshold for their current level band, so the
web can draw progress without keeping a copy of a curve that would drift.
The game-side half of setting out, going back in, and hiring the sitter from
the web. Each is the existing command minus its framing: performExpeditionStart,
performResume and performBabysitPurchase now hold the guards and the money, and
!expedition start, !resume and !adventure babysit are what is left over. So a
departure booked from a phone is the same departure - same eligibility chain,
same supply freebies, same opening log line - rather than a second one that
drifts.
Refusals travel as advRefusal, which wraps a sentinel AND carries the finished
sentence. That is what lets the commands keep the exact copy they always sent
while the web gets a machine-readable verdict.
All three spend coins on a retrying wire, so the debit is keyed to the order
guid and a re-offer cannot charge twice. The subtle half is what a re-offer
should ANSWER: a settled debit plus an already-started expedition means the
order worked and lost its ack, not that the player is busy, so it reports
applied instead of refusing the thing it did. Nothing refunds-then-retries -
after a refund the keyed debit will not charge again, so a retry would hand over
the goods for free, and every failure past the debit is therefore permanent.
Also fixes a deadlock that predates all of this: !expedition extract and
!expedition resume are aliases for two commands that take the per-user lock
themselves, and the alias dispatcher already held it. Since it is a plain
sync.Mutex the handler blocked forever and, because the deferred unlock never
ran, every later adventure command from that player wedged too. It does not
fail loudly on regression - it hangs - so the new test asserts with a timeout.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
Pete now records two verbs an owner can ask for: pull out of a run, and
take today's bout against the Siege. This is the game-side loop that
answers them, modelled on the equip poller down to the applied-order
ledger, because neither action is idempotent: replaying an extraction
would end a run they had resumed, and replaying a bout would spend a day
they never got back.
The web verbs run the game's own paths, not lookalikes. !extract and
!adventure worldboss fight are now thin command framing over
performExtraction and takeSiegeBout, which both take the per-user lock,
so a click and a command race each other exactly as two commands do. A
web extraction still DMs the whole party, still writes the log line, and
still resumes for seven days.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
Adds the realm snapshot behind Pete's map, board and hall of firsts: every
zone with its clear history and who is standing in it, the ledger of things
that have happened exactly once, and one line per adventurer.
Snapshot semantics like the roster and the Siege — pushed whole, replaces
Pete's copy, dropped rather than retried. It rides the roster ticker but at a
ten-minute stride: these are aggregate scans over the whole run history, and a
first clear does not move.
Two things the real data corrected, neither of which any unit test would have
caught:
`abandoned` does not mean the player gave up. It means the run row was
retired, and abandonZoneRunByID exists precisely to retire a run whose boss is
already dead when the expedition travels on. 30 of prod's 32 boss kills carry
abandoned = 1, so filtering on `abandoned = 0` — as this did, copying the
shipped backfill — drew a realm in which almost nothing had ever been beaten.
boss_defeated = 1 is the clear.
And news_realm_firsts is not a usable history book on its own. It only covers
what happened after the news seam went live, its one-shot seeder carried that
same wrong filter, and its first_at is when the claim was written down, not
when the thing happened — every backfilled row in prod shares one timestamp.
So the zone half of the hall is derived from the run history instead, which is
complete and correctly dated, and the ledger supplies the kinds the run
history knows nothing about. That also makes the hall agree with the board by
construction rather than by coincidence.
Opt-out follows the rule that fits each surface: a first-clearer is anonymised
(deleting the claim would redraw a conquered zone as unbeaten), a player is
dropped from the board outright, and presence is dropped entirely — who is in
a dungeon right now is the live-location fact the liveblog already refuses.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
Two halves of the same gap. A dispatch announcing a clear, a retreat or a death
said how an expedition ended and gave a reader no way to reach what happened in
it — so those three facts now carry the run they concluded, and Pete's permalink
can open the log behind the headline.
Finding that run is done by asking what is actually true at the moment a
dispatch is filed rather than by threading a run id through five call sites that
have already let go of it. Both guards on that lookup are load-bearing: a run
with no beats behind it would mint a link to a 404, and without the recency
window a campaign death at the Empty Throne would attach itself to whatever
dungeon that player last walked.
The other half is the summary. Every line of the liveblog is assembled by Pete
out of a beat's own nouns, which is right for a log — it has to be exactly what
happened, in order. But a report is read afterwards and the question it answers
is not "what happened", it is "what was that run", and that is a judgement. So
this is the one piece of prose on the channel, and it earns the model far better
than a dispatch headline does.
It runs on the roster ticker, not at the moment the run ends. A run ending is
already a chokepoint with a dispatch being authored against it, and a second
generation there would stall the command that killed the boss. One per tick, and
a run that can't be summarised is closed out with an empty beat rather than
retried forever — the row is what stops the sweep picking it up again, and a
report with no summary is still the log and the numbers.
The prompt states the one permitted name twice and forbids arithmetic: a model
asked to write warmly about a party will invent a second member of it, and a
total it works out itself will contradict the exact totals printed beside it.
Both were seen against the real box before this was tightened.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
The engine narrates every fight, trap and haul to one Matrix DM and then
discards the shape underneath it. This records that shape as it happens so
Pete can retell the run to somebody who wasn't in the room.
Beats ride the roster ticker (one extra request per two minutes, not one per
room) but on their own table, never pete_emit_queue: they are high-volume and
low-stakes, and a chatty run must not be able to spend the retry budget a
death dispatch depends on. Unlike the snapshots they ARE retried — a dropped
beat is a hole in a story, not a stale number the next tick corrects.
Recording is a leaf everywhere it is called. If a beat can't be written the
walk carries on exactly as it did before this existed.
Privacy is stricter here than on the board. The board omits an opted-out
player from a snapshot; a liveblog would be an account of where they are and
what is happening to them, so their beats never leave the box at all — and a
run whose owner can't be resolved is refused rather than published.
Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
The Siege had no web presence at all, and three dispatch templates
(siege_start / siege_win / siege_loss) have been sitting written and
unemitted in Pete's renderAdventure since the adventure section shipped.
Both halves fixed here.
The snapshot rides the existing roster ticker with the roster's rules:
pushed whole, replacing Pete's copy, dropped rather than retried on
failure. Retrying would be a lie about how much HP is left, and the next
tick carries the truth anyway.
The muster carries EVERY alive adventurer, not just contributors. The
zero-fight rows are the point: one bout per person per day means somebody
who hasn't swung today is a hit the town hasn't taken, and Pete can only
draw that column if the people in it are on the wire.
Opt-out departs from the board's rule on purpose. The board omits an
opted-out player outright — class + level + zone re-identifies them. A
contributor here is anonymised instead: the damage they did is on the
boss and is part of what the town accomplished, so deleting it would
understate the shared effort and stop the totals adding up. They keep
their damage and their rank, and carry no board token, so nothing links
back to a page that names them. An opted-out player who never fought is
still dropped — nothing to account for.
The three dispatches key their GUID on the boss row id, so a resolution
path re-entered (a redeploy mid-window, the ticker's safety net firing
after an inline kill) files the same guid and Pete dedupes it, rather
than announcing one Siege to the room twice.
Also retires the stale "deploy Pete first, an unknown event_type is a
400" note on emitBoredomDeparture. Pete now publishes an untemplated
type on a neutral fallback and counts it for the operator, so the
ordering is a property of the system rather than a rule to remember.
Review fallout from the locked-doors commit. Five defects, all in the
seams that commit opened:
backtrackFromDeadFork stepped to VisitedNodes[idx-1]. That slice is a
first-entry ordered *set*, not a path stack (see appendVisited), so once
a run has doubled back once the entry before CurrentNode can sit on a
different branch entirely — the party teleports across the map to a room
no edge connects. `!revisit` refuses exactly that move via
adjacentNodes; the autopilot has no business doing what the player is
forbidden from doing. Now routed through backtrackTarget, which also
refuses to fall back into a corridor whose only exit is the sealed fork:
the lock rolls are seeded per (run, edge), so walking back in gets the
same answer every time, forever.
The autopilot spent the player's thieves' tools *before* committing the
move, so an advanceZoneRunNode failure left them charged and then had
the caller's backtrack clear the fork they had just paid to open. The
tools are now reported by autoPickWithTools and only removed once the
party is actually through the door.
`sell all` never learned the new "tool" type and turned a €600 set into
€300 of loot — the same silent deletion Robbie was taught to avoid two
commits ago. It was already doing this to "key" quest tokens, so both
now sit with the special gear.
The shop's tools branch asked "is the reply a substring of Thieves'
Tools", which is true for a bare "s", for "to", and for an empty message
body — and it runs ahead of the consumable list, so a stray keystroke in
the Supplies view bought a set. isThievesToolsReply matches on the
item's own words instead.
Plus two cleanups in the same code: Robbie built his haul gifts by
calling consumableCache(tier, 1) in a loop when it already takes a
count, and the unlock flow read the whole inventory three times per
command.
Locks were fully implemented as pass/fail gates and nothing else. A
Perception or stat check rolls once per (run, edge), seeded so it can't
be reload-scummed — that half shipped in G5, the counterweight never did.
A bad roll simply deleted a branch of the graph for the rest of the run,
worst for a solo low-WIS character who quietly loses routes they never
learn existed. Three changes, one theme: a die roll should not be able to
permanently wall a player.
Party's best stat answers the check. evaluateEdgeLock read only the
acting character's mods, which made a party's rogue and its hired scout
decorative at every lock. Fold the whole roster — Pete included, since
excluding him would make hiring a scout worth less than the coins it
costs — and credit whoever got it open in the fork menu.
Thieves' tools as the escape hatch. A utility item (not a ConsumableDef,
or the fight engine would spend them for you) sold on Luigi's supplies
shelf, consumed by `!zone unlock <n>`. Deliberately not a skeleton key:
tools answer the two dice-driven locks only. A key lock is a quest token,
a level-min lock is progression, a region-clear lock is structure — none
of those are "you rolled badly", so none of them are pickable.
Autopilot picks a route instead of parking. The fork timeout was 8h,
which reads as "the player gets first say" and behaves as "the expedition
stops for a third of a day, at every fork" — a multi-day expedition
crosses a lot of forks. 30m keeps a genuine first say for anyone at the
keyboard. It now ranks by unvisited-then-edge-weight rather than taking
whatever the graph author happened to list first, spends tools when every
route is locked, and backtracks a room when it can't do even that, rather
than idling into the 24h reaper and losing the player days of progress to
a roll they never saw.
Sim A/B, same seeds, 90 runs/arm: 43.3% -> 42.2% clear, a single run
flipping and well inside the documented noise floor. A party+companion+pet
arm runs 31/32 clean through the new roster-folding path.
The gift was one consumable every 10th visit, flat. A visit is a 40%
daily roll, so that works out to one item per ~25 real days — and it paid
exactly the same for a stockpile of sixty items as it did for one rock.
The player controls volume, not visit count, so volume is what the new
track pays on: one consumable per 15 items carried off, capped at 3,
stacking with the existing loyalty gift.
Also stop Robbie stealing thieves' tools. He skips keys already, for
exactly this reason — a key is bought to open something later, and a
bandit who pockets it between the purchase and the door has taken the
thing the player paid to still have. Tools are the same shape of promise.
The Siege has never once spawned in prod. `select count(*) from
world_boss` is 0 and daily_prefetch has no worldboss_spawn row at all.
worldBossTick only auto-spawned when now.Day() == 1. The world boss
landed on main 2026-07-10..13 and the first deploy carrying it was after
July 1, so prod has never run a first-of-the-month tick with the code in
it. The feature has been live and unreachable for weeks, and the next
natural spawn would have been August 1.
The same gate also silently skipped any month where the bot happened to
be down or redeploying across the 1st, with no catch-up — one missed
minute costs the town a month.
The month key is already the whole dedup, so drop the day check and let
the rule be what it always read as: one Siege per calendar month, as
early as the process is up to run it. A missed 1st now self-heals on the
next tick.
petGrantXP has been dead code since R1 deleted the legacy daily activity
loop it used to ride. Nothing replaced the call, so for the whole life of
Adventure 2.0 the only pet XP in the game came from a paid babysitter.
Prod bears it out: the one player who never subscribed has a pet sitting
at level 1 with 0 XP after months of play.
That is not cosmetic. DerivePlayerStats scales PetAttackProc,
PetDeflectProc and PetAttackDmg off pet level, so a frozen pet is a
permanently dead combat slot that the player has no way to revive.
Wire it into postCombatBookkeeping — the one seam all four combat
close-outs already meet, so a pet cannot level differently depending on
whether the fight auto-resolved or was played a round at a time. Both
slots earn on the same win, matching the babysit trickle: combat only
reads the two pets' averaged procs, so leveling both is not a spike.
Writes go through the narrow per-slot pet upserts rather than
saveAdvCharacter, because this runs on a path that does not hold the
per-user lock and a full-row write could clobber a concurrent save.
Verified against the sim: a level-3 pet finishes one L10 expedition at
level 4 with carryover, where before it finished exactly where it started.
Code review of the Ask-7 web equipment-management path surfaced three
correctness issues, all fixed here:
- applyEquipOrder ran the poll-goroutine equip mutations without the
per-user advUserLock that every Matrix-side mutation (!give, !equip,
arena, …) holds, so the lock gave no mutual exclusion against the web
path. A concurrent !give of the item being equipped could duplicate it.
Now takes advUserLock(owner) for the whole apply, matching the DM path.
- applyMasterworkEquip evicted the displaced occupant to the pack BEFORE
the destructive slot write, so a fault left the piece both worn and in
the pack — and the 30s equip poll retry re-evicted it every tick. Now
removes the incoming row, writes the slot, then re-packs the occupant
last as a best-effort step: once the slot no longer references it, the
re-pack cannot duplicate, and a failure is logged not aborted on (the
DM confirm handler's tolerance).
- PlayerDetail.Balance dropped omitempty: a real €0 balance is an
informative fact, not an absent one, and omitting it left the web
confirm dialog with no balance to show.
Mirror of Pete's ask 7. gogobee polls the equip queue and applies the new
actions against the five standard gear slots:
- equip: routes MasterworkGear/ArenaGear to applyMasterworkEquip (evicts
any special occupant back to the pack; downgrade-blocked), else the
existing applyMagicEquip.
- unequip: EquipmentSlot vocabulary -> applyMasterworkUnequip (resets the
slot to its tier-0 default, keeps the row), else applyMagicUnequip.
- upgrade: purchaseEquipmentTier, euro-idempotent (DebitIdem keyed on the
order GUID), downgrade + max-tier guarded.
- repair: repair(), euro-idempotent, recomputes blacksmithRepairCost.
Detail push now carries Slots (EquipSlotView x5) + Balance; itemViews gives
masterwork/arena backpack rows an equip id; the compare decorator is guarded
to magic-only. buildDetailSnapshot is a method so it can read the euro balance.
Retry-safety: no CreditIdem refund on a later save fault (would double-pay a
guid-guarded retry) — we return retry=true and let the next poll re-run, since
the debit is guid-idempotent and the slot write is idempotent. Matches the
casino escrow precedent. Unit tests cover downgrade block, max-tier,
insufficient funds, idempotent replay, eviction, and take-off reset.
Deploy AFTER Pete: Pete's ingest must accept the new verdict strings before
this side emits them.
Review follow-up on the caster-floor rebaseline: the survival-half doc in
casterBlasterFloor described an HP add the code never makes (Defense only),
and the pre-existing Druid *0.95 rider still read as a damage cut when, for a
player-defender through calcDamage, DamageReduct<1 raises damage taken. Comment
-only; no runtime change, guardrail test unaffected.
The arcane-blaster "sustained floor" passives (CantripPerRound, DamageBonus,
FlatDmgStart from casterBlasterFloor) were built for the swing-based engine
(SimulateCombat, combat_engine.go:590). But every live expedition auto-resolves
through the turn engine (autoDriveCombat -> session -> combat_turn_engine),
where casters autocast every turn and never weapon-swing -- so CantripPerRound
never fired and DamageBonus was inert. Casters fought at bare cantrip dice
(~4d10~=22 at L20) instead of their intended floor, in sim AND in prod. This is
why every caster damage dial read as a dead lever across the whole rebaseline.
Fix (combat_cmd.go): bridge the already-computed CantripPerRound into the
turn-engine damage-cantrip cast, hit-gated (only lift a cast that already
connected, so the ~35% miss variance survives and the floor isn't a guaranteed
flat hammer). Self-targeting: only Mage/Sorcerer/Warlock carry a nonzero
CantripPerRound -- martials swing (untouched), cleric/bard/druid have floor 0.
Tuning (dnd_passives.go): casterCantripBase 9 -> 3, now a live, class-specific
lever. Mage/Sorcerer take base 3; Warlock passes 0 (its bare-dice cantrip plus
a structural edge already lands it mid-band, so an added floor overshoots).
Removed the dead casterHPPerLevel rider (it inflated the truncation-fraction
denominator without adding startable HP -- a bug).
Also lands the deterministic-seeding infra (sim_seed.go + simIntN/simFloat64
threading) used to read these deltas out of the process-seed noise; prod is
byte-identical (unseeded -> package rand).
Confirmation (expedition-sim, L20 T5 dragons_lair+abyss_portal, n=250):
casters now in the 35-45 floor -- sorcerer 39, mage 38, warlock 36; martial
leaders undisturbed (rogue 68, druid 66, ranger 65, fighter 64, ... paladin 55).
A caster's at-will cantrip that lands the killing blow returned via a raw
enemyHP<=0 read, skipping enemyDown -- so a survive_at_1 boss or T6 Valdris's
phylactery rebirth died instead of cheating death. Route it through enemyDown,
matching resolvePlayerAttack and the concentration-tick path.
Also add the missing renderEvent "cantrip" case: the CantripDesc narration
hook was set but never rendered, so cantrip damage dropped enemy HP with no
log line.
For each backpack magic item, compute the per-stat diff against whatever
occupies the slot it would equip into (mi.Slot — the same slot the web
Equip button targets, so the card describes the trade that actually
happens). The verdict is strict dominance: all-gain is an upgrade, all-loss
a downgrade, mixed a sidegrade with no winner claimed (the case the two
opaque effect strings could never show). Empty slot reads 'new'; an
attunement item with no free bond reads 'inert', which overrides the stat
verdict because wearing it does nothing.
The diff is over tempered effects on both sides and reuses the engine's
own magicItemEffectFor, so nothing here re-derives power math that could
drift from the game. Rides an additive Compare object on the private
backpack ItemView — no migration, no endpoint, no public surface.
Rings collapse to the same path: every ring equips to ring_1 (ring_2 is
declared but never assigned by any live code), so a backpack ring simply
compares against the ring_1 occupant.
emitFact now runs the final fact through authorDispatch, which asks the local
Ollama model for a warm-reporter headline and lede and ships them on the Fact.
Pete guards and publishes them, falling back to its own templates on anything
it rejects — so authoring is best-effort by design: LLM off, a timeout, a
malformed generation, or an over-length pair all return an empty prose pair and
Pete templates the fact. The names allowed in the prose are the fact's Actors,
built from the post-opt-out subject/opponent, so what the model may say and what
Pete's guard permits are the same list. Synchronous like the holdem tip rewrite,
but on a tight 15s budget: news facts are infrequent and a template now beats a
voiced dispatch late. With no route for Pete to call back into this box, the
voice lives in the prompt here rather than in a Pete-owned inference endpoint.
A treasure that earns a public room announce now also files a treasure_found
fact, so Pete can count it on the finder's trophy case. The emit rides inside
announceTreasureToRoom, reusing the RoomAnnounce != "" gate as the newsworthiness
filter — a copper-piece pickup never becomes news, and a reversed auto-swap never
emits, because the announce it shares is cancelled on undo.
The realm's first finder of a given treasure is a priority hoard; a later finder
of the same item is a bulletin, keyed on the treasure across the realm via
claimRealmFirst, the same first/repeat split zone_first uses. The item name rides
in stakes and the tier-derived rarity in outcome.
treasure_found is a new event_type, so Pete's ingest must deploy first or the
first finds park on the retry ladder forever.
Poll Pete for equip/unequip orders an owner placed on the web, run them through
the real magic-item equip path so bond caps and slot eviction still hold, and
file a verdict. Same reverse pipe as mischief, with one difference that matters:
the equip action is not idempotent, so a re-offered order after a lost ack would
double-move the item. An equip_applied_orders ledger keyed on the order guid is
the guard: applied once, re-offers only re-file the stored verdict.
The delicate remove-before-equip ordering that prevents item duplication is now
one shared applyMagicEquip/applyMagicUnequip core, called by both the DM
resolver and this poller, so the anti-dup ordering can't drift between two
copies. Items now carry their inventory row id to Pete as the equip handle.
The self-view listed a name, a tier and a price — everything except what a
player decides on. The facts were all there, just not on the wire.
Three things the contract spec got wrong, found by reading both sides:
Equipping *moves* the row out of adventure_inventory into
magic_item_equipped, so the two sets are disjoint. The spec's `attuned` on a
backpack item can never be true — bond state isn't false there, it's
undefined. The real gap was that worn items weren't sent at all: the panel
showed the backpack and hid the sword. Hence Equipped, where Attuned means
something and an inert item can be seen.
Stat modifiers ARE modeled. The spec said they weren't, and that shipping
them meant either an engine change or a display-only approximation that lies
the first time it disagrees with the engine. But magicItemEffectSummary is
the engine's own summary — the same function the game speaks with. Sending it
can't drift, because there's nothing to drift from.
SkillSource is two different things: "mining" on masterwork gear, and the
internal "magic_item:<id>" registry pointer on magic-item rows. Sending it
raw would put gogobee's IDs on a page, and Pete couldn't tell them apart to
filter them. Only the skill name goes out.
Desc and Effect resolve at the push site because an inventory row carries
neither — descriptions live on MagicItem/EquipmentDef, and the combat delta
is computed, never stored. Shop gear resolves by (slot, tier); Name is
decorative there.
All additive and omitempty on the private /api/ingest/detail push, so neither
side has to deploy first.
Second in-combat Layer-2 mechanic, reusing Amendment's round-end seam. In the
Seamstress's phase 2 (<=35% HP) the room sews inside-out in a repeating
warn(1)->sting(2) cadence, telegraphed one round ahead: during a pulse player
heals (self + ally) invert to damage, floored at 1 HP. State
inversionActive/inversionTelegraph round-trips through CombatStatuses so a
suspend/resume can't drop or double a pulse.
Sim surfaced that unplace is reach-bound (only ~37% reach the boss, then cleared
her ~98%), so the mechanic alone is sub-noise. Re-strengthened the Seamstress,
the weakest T6 boss (over-softened by P7 for a zone lift that never came):
HP 385->460, Atk 39->45, Needle Rain proc 0.40->0.45. Sim-validated at
fighter zone 37.5% (in range), and a real boss fight now instead of a victory lap.
Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
First in-combat Layer-2 mechanic. applyBossInCombatRoundEnd is a new
round-boundary seam called from the turn engine's stepRoundEnd, the
counterpart to the pre-combat applyBossRunModifiers. The Custodian snapshots
its HP at end of round 3 and rewinds to it once on the phase-2 crossing
(refunding front-loaded burst); a soft midnight timer past round 20 climbs
its Attack via the existing enemyAtkBuff. New state (EnemyRewindHP/Used)
round-trips through CombatStatuses so a suspend/resume can't replay the
rewind. Sim A/B (n=120 L20 party): last_meridian fighter 65->37.5% clear,
a clean -27.5pp swing attributable to the mechanic; shipped as-is per the
opt-in-endgame difficulty call.
Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
The T6 Valdris phylactery rebirth (and the pre-existing survive_at_1
one-shot) live in enemyDown, on the premise that it is the single death
chokepoint. It isn't: the round-end concentration pulse ended the fight
on a raw enemyHP<=0 read, so a cleric's Spirit Guardians landing the
lethal blow robbed a revive-armed boss of its rebirth — exactly the
cleric-party arm P8 is tuned around. Route that win-check through
enemyDown so the boss gets its chance to stand back up. Regression test
covers both the armed (revives) and charge-less (still wins) paths.
Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
The second Layer-2 postgame boss mechanic, and the first stateful in-combat one.
Valdris ("boss_valdris_ascendant", the Ossuary Ascendant) is bound to three
Verses hidden on the zone's secret nodes: every Verse the player finds and walks
before the fight unbinds one rebirth, every Verse they skip leaves it armed. A
full-clear explorer strips all three and fights a mortal lich; a speedrunner who
blows past the secrets fights a god who will not stay down.
New engine primitive: stackable rebirth. The stock survive_at_1 is a one-shot
1-HP stay; Valdris needs several rebirths that each restore a real pool.
combatState/CombatStatuses gain EnemyReviveCharges/EnemyReviveHP (round-tripped
through the turn engine so a suspend/resume keeps the live count), and enemyDown
consumes a charge after the survive_at_1 check, reviving to 25% of the
party-scaled max and emitting a phylactery_rebirth event. Zero for every
non-Valdris fight.
Unlike Greed Tax (a pure per-round recompute in applyBossRunModifiers), rebirths
are spent mid-fight, so the charge count is seeded ONCE at session creation
(seedBossRunStatuses, from unvisited secret Verses) and never re-derived on the
per-round enemy rebuild. Seeded from handleFightCmd after startPartyCombatSession.
Sim A/B (millenia, n=120 L20 party+Pete+pets, same binary, control neuters the
dispatch): mortal end (verses found) fighter 42.5% -- reproduces the deployed P7
ossuary baseline, confirming the mechanic doesn't touch the validated full-clear
path -- and the god end (0 verses) fighter 12.5%, a deadly-but-beatable flex arm.
The sim walks 0 verses (autopilot takes the first unlocked fork; Verses are
behind Perception locks), so the A/B brackets the whole player-agency gradient.
Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
Add the pre-combat boss-hook seam for Tier-6 postgame bosses and the first
bespoke mechanic on it.
Seam: applyBossRunModifiers(bossID, enemy, run) in postgame_boss_hooks.go —
a pure, idempotent, bestiary-ID-dispatched hook that folds run-state-derived
adjustments into the freshly-built boss Combatant. No-op for every non-hooked
enemy or nil run. The turn engine (which resolves both prod bosses and the
sim) rebuilds the enemy every round via partyCombatantsForSession, so the hook
must be a pure function of run state — fine at a terminal boss room, where the
route is frozen. Wired at both enemy-finalization points: the per-round rebuild
and buildFightSeats' initial HP persist (threaded a run param; the caller
already had it).
Greed Tax (boss_aurvandryx / first_hoard): her Attack rises with the richness
of the route walked to reach her — the summed excess LootBias (>1.0) over the
run's visited nodes. Note run.LootCollected is the wrong signal (BossOnly
signature manifest, empty at the boss); the gilded veins on the graph are.
Attack += min(2.0*richness, 12). Same-binary A/B sweep (n=150, L20 party +
Pete + pets): taxless 66.7% -> taxed 48.0%, landing first_hoard mid-band.
Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
Code-review fixes on the stuck-adventurer minting path:
- AdminBuildConfirmedCharacter now runs race/class through parseRace/
parseClass (same as !setup), so a typo or non-playable class errors
instead of silently minting a 1-HP/AC-10/no-spell sheet; inputs are
normalized too.
- Clamp level to dndMaxLevel to match the L20 cap enforced elsewhere.
- CLI parses/validates every spec before db.Init, so a malformed spec
mid-batch no longer leaves earlier specs already committed.
The boredom ticker needs a confirmed dnd_character, so two populations
never leave on their own: veteran legacy players with an
adventure_characters row but no dnd_character (auto-migration only fires
on active play, which an idle player never does), and players who
abandoned !setup at race pick (pending_setup=1). Both are correct
guards in tryBoredomStart, not bugs — the fix is to hand them a finished
sheet.
AdminBuildConfirmedCharacter forces a race/class/level and reuses the
same constructors as auto-migration and !setup confirm (class-tuned
standard array, racial mods, HP/AC, resource pool, caster spells+slots),
with auto_migrated=1 so the player can freely !setup-rebuild the class
that was chosen for them. cmd/char-migrate is the one-off that drives it
against a live gogobee.db.
Five post-game dungeons above the T5 ceiling, gated on both T5 bosses beaten
+ level 18. Opt-in endgame: deadly solo, clearable by a party with Pete + pets.
- P1 gating: postgameUnlocked (T5 clears + level floor), zonesForLevel excludes
T6 unconditionally; wired into startZoneRun, !zone/!expedition, party accept,
boredom picker, and the list dividers.
- P2 bestiary: 15 elites + 5 signature bosses (Layer-1 stat blocks).
- P3 zone defs + 4-region registries; ZoneLootEntry.BossOnly.
- P4 five zone graphs on a shared builder (44–52 rooms, no soft-lock; Ossuary
secret Verse nodes).
- P5 loot: BossOnly enforced; signature items are real registry magic items;
five Thom pity recipes off the per-zone crafting anchors.
- P6 narration/flavor (5 files), T6 achievements, Pete stays zone-parametric.
- P7 (in progress): sim can now reach gated T6 (SimRunner.SeedPostgameUnlock +
IsPostgameZone). First calibration pass on millenia — hardened ossuary +
drowned_star, softened first_hoard + unplace; last_meridian in band.
Fix: party members were refused from every T6 zone because expeditionCmdAccept
ran the level gate (which excludes T6) before the postgame check — the intended
party endgame was unreachable. Route T6 through postgameUnlocked. Regression
tests added.
An attunement item equipped while at the 3-bond cap sat permanently inert:
nothing re-bonded it when a slot opened, and the equip picker only lists
inventory, so a slotted item could never be reached again.
- reconcileMagicAttunements bonds worn-but-inert items whenever bond
capacity is free (bonding is strictly beneficial; inert should only
exist at the cap). Runs on equip-magic open and after any swap.
- New !adventure unequip-magic picker takes a worn item off and returns
it to inventory at full value, freeing its bond slot (which then heals
any straggler). Destructive-op-first ordering mirrors the equip path.
The board on Pete shows flat rows; this hands it two more channels so a name
can become a page. Public stats + equipped gear ride the roster snapshot
(RosterDetail on each entry, keyed by the anonymous token, no handle). The
private self-view — inventory, vault, house, pets — rides its own push keyed
by localpart, so Pete only ever serves it back to the one signed-in owner it
belongs to; the board token rides along so the ownership check is a join, never
a reversal of the one-way token. The private set skips no one for opt-out (that
governs the public board only) and skips the dead (no live page to own).