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).
The game-side half of Mischief Makers M3. gogobee polls Pete for storefront
orders and opens the contract itself — the money, the eligibility, the fight
are all its own, exactly as a Matrix !mischief buy.
- roster push now carries each buyer's advisory euro balance (keyed by localpart,
a separate keyspace from the anonymous board token) and the live tier catalog,
so the storefront renders gogobee's current prices and never hardcodes one
- placeWebMischief: the fulfilment path, debit-first-then-refund so the money
state is a pure function of the order guid. DebitIdem + an order_guid stamp on
the contract make a retried claim neither double-charge nor double-open
- resolveRosterToken recomputes the one-way board token per live player to name
a mark; the buyer is @<username>:<homeserver> from Authentik's preferred_username
- a 30s poll loop drives it; a lost verdict just leaves the order pending to
re-run, so the loop is its own retry and needs no durable queue
- euro.HasExternalTx lets the affordability gate tell a first attempt (no debt
for a mischief buy, like Matrix) from a retry of one already paid
order_guid column added to mischief_contracts (schema + idempotent ALTER).
Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.
It is a poll because Pete cannot call us and isn't going to be able to. Every
step is keyed on the escrow guid, because every step can be interrupted in the
worst possible place: die after DebitIdem and before the verdict is queued,
and Pete re-offers the row, we claim it again, the debit replays as a no-op
and reports the same answer. The player is charged once. That is the only
property here that really matters, and there is a test that kills us three
times to prove it.
The verdict rides the queue that already carries adventure facts — it wants
the same durability, backoff and parking, so the row now says where it's going
rather than the queue growing a twin. Flush sends it at once instead of after
a 15s sender tick, because there's a person at the other end watching a
spinner.
First GET gogobee has ever made to Pete.
Every caller of Credit/Debit today is a Matrix message, and a Matrix message
arrives once. The Pete games escrow will claim buy-ins over a poll loop, where
a claim that succeeds but whose ack is lost gets retried — and the player pays
twice. euro_transactions had no external id and no unique constraint to stop it.
Adds external_id + a partial unique index, and CreditIdem/DebitIdem: balance
mutation and transaction log in one tx, keyed by the escrow GUID. A replay is a
no-op that still reports ok; a rejection leaves no trace, so the same GUID can
be retried once the player is good for it.
`paid` carries the buyer's stake AND the escalator's delta, but every
refund path handed all of it to the buyer. Buy an elite (€350), let
somebody bump it to a boss (+€850), let the target walk out of the
dungeon: the 90% fizzle refund pays the buyer €1080. A €730 profit,
funded by the escalator, who loses €850 and is never told. Same in the
unstageable and stranded-sweep refunds, at 100%.
mischiefOutlay splits the pot back into its two stakes — derived, not
stored: an escalation is exactly one rung and happens once, so it is
always the fee gap to the tier below. Both are refunded separately, and
the sink is booked against whoever actually paid it.
Three more from the same review:
- A crash mid-delivery stranded the ward on the target's sheet forever.
clearMischiefBlessings runs on a defer, which is the one thing a crash
skips — and the stale sweep exists because deliveries do die. It takes
the cushion back now.
- dmMischiefVictim told the target to run `!mischief bless @<display
name>`. The command splits on whitespace, so "Misty Blue" resolved on
"@Misty" — or on somebody else. It's the only command the ward window
has. Use the MXID.
- Two blessers landing together both announced "1 of 3" for a contract
carrying 2; the count the CAS is authoritative for was being re-derived
from a stale read.
The M1 close-out sweep (new, skip-gated: n=400/cell through the REAL delivery
path, not SimulateCombat) says three wards buy roughly +40pp of survival — a
level 12 fighter meeting a boss at 70% HP goes from 48% to 90%. At a flat €25
that let three friends halve a €1,200 boss contract for €75, which makes the
tier the entire economy rests on feel like money thrown away.
A ward now costs max(€25, 10% of the contract): €25 at grunt and mob, €35 at
elite, €120 at a boss — so covering someone against a boss costs the town €360.
Still a bargain against €1,200, but it has to be a real rally rather than
pocket change. It reads the contract's current basis, so an escalation raises
the price of saving its target too: the counterplay tracks the threat.
The sweep also shows the M0 fee table was priced on a fight we don't deliver.
Its control arm (full HP) diverges from M0 in both directions — up where an
engine timeout now counts as survival, down where the turn engine loops a boss's
full multiattack profile and SimulateCombat doesn't. And the arm M0 could not
see at all, a wounded mid-run target, is the one that matters: boss tier at 70%
HP collapses everywhere. Fees stand; the tiers still do what they were priced to
do. The real-path table is now the reference.
The live emitters stopped filing priority facts in 1cbd68a, but the launch
backfill kept doing it for historical zone-firsts and deaths. It is inert today
— Pete's handler short-circuits on no_push before the Matrix post, and headlines
key off event_type rather than tier — so nothing on the site or in the room
moves. It was a landmine: the echo rule was being enforced by a guard that
happens to sit in front of it, not by the tier itself, and anyone touching that
guard resurrects the echo at back-catalogue scale.
Bulletin is the rule. Nothing in the back catalogue is news anyway.
M2 — the window between a contract being placed and the monster finding its
target. Anyone can ward the victim (!mischief bless, €25, three max, +10% MaxHP
of temp HP each) or pay the tier delta to send something worse (!mischief
escalate, one step, boss is the ceiling). TwinBee DMs the victim on placement so
they can go and ask for wards.
Escalation money joins the payout basis, so piling on raises the purse the target
walks away with if they live — and an escalator is unsealed on a survival exactly
like the buyer. Cruelty and generosity are the same button.
Both commands are CAS-then-refund: the ward cap and the one-step limit live in
the UPDATE's WHERE clause, because a scramble is precisely when four people press
the button in the same second.
Also fixes a real bug this exposed: the delivery built its fight from the copy of
the contract the due-sweep handed it, but the window keeps writing to an open row
right up to the claim. A last-second escalation was paid for and then delivered
the old tier's monster. The claim is the fence; read on that side of it.
1cbd68a established the rule and I broke it two commits later. Priority
tier is the one thing that makes Pete post a live Matrix beat, and
TwinBee announces every mischief moment in the games room as it happens
— the contract going out, the survival, the maiming. Filing them
priority meant Pete read his version back to the room that had just
watched it.
Bulletin, like death and zone_first: still on the site, still in the
daily digest, no second telling to the people who were there.
The 24h cooldown and the one-boss-a-week cap both keyed off any resolved
contract, and a fizzle resolves. So a target could have a friend point a
grunt at them, extract, and buy a day of immunity for the fizzle rake.
Both caps now only count contracts that were actually delivered.
Delivery also ran a full combat against the target's sheet without their
advUserLock. hasActiveCombatSession only sees the turn engine — the
target's own autopilot walk resolves its fights inline under that lock
and reports no session, so a delivery could race it and lose a fight's
worth of HP writes.
Also: don't tell a buyer a rival beat them when the insert simply failed.
Mischief Makers M1 — the core engine, Matrix-only. `!mischief send elite @user`
debits the buyer, tells the games room a hit is out, and an hour later a monster
from the target's own level bracket walks into whatever dungeon they're in.
Survive it and they keep a cut of the money and the buyer is named; don't, and
they wake up on a cart home.
The monster comes from the target's bracket zone pool, not the arena ladder and
not the dungeon they happen to be standing in — the same selection code the M0
pricing sweep ran through, so the fee table can't drift away from the fight it
priced.
Three things that are load-bearing and don't look it:
* Survival is read off the target's HP, not PlayerWon. The engine's timeout is
a retreat, not a lethal blow — somebody who ran out the clock with HP left
held the thing off, and a bought monster that merely outlasted them hasn't
earned a maiming.
* Nobody dies for money, and that includes the party. The delivery skips
closeOutZoneWin/Loss (the fight is extrinsic to the dungeon — crediting it
would let a buyer unlock the target's kill-gated resources for them), so
nothing else floors a downed seat. Without floorMischiefRoster on BOTH
outcomes, a member the leader outlived is left alive at 0 HP, which every
`HPCurrent <= 0` gate reads as broken rather than dead.
* One live contract per target is a partial UNIQUE INDEX, not a read-then-write
check. Placement holds only the *buyer's* lock, so two buyers racing at the
same victim would both pass an in-code test. The loser is refunded.
Payouts are a percentage of the base fee, never of what the buyer actually paid —
the sign surcharge is a pure sink. Capped at 75%, so a survival purse is always
strictly less than the outlay and collusion loses to !baltransfer, which is free.
That cap is the entire anti-collusion story; no danger multiplier needed.
A crash between claiming a contract and closing it out used to be unrecoverable
in the design: the row would strand, the target could never be targeted again,
and the buyer's money was gone. The stale sweep refunds those in full — that one
is our fault, not a bet they lost.
Contract timestamps bind as Go time.Time, never CURRENT_TIMESTAMP. The driver
stores RFC3339 and SQLite's own stamp is space-separated; the two compare
lexicographically wrong.
Pete learns four mischief_* event types in a separate commit, and has to deploy
BEFORE this does — an unknown event_type is a 400, which retries and then parks
the bulletin forever.
The push logged nothing on success, so during the first deploy an empty board
and a silent log were indistinguishable from a ticker that never started — and
the debug went looking for a bug that wasn't there. (The snapshot was simply
2 minutes out; the first tick hadn't fired yet.)
Logging every push at INFO would be noise forever. Log the transitions instead:
the first time it works, the moment it breaks, and when it recovers.
We only ever told Pete about outcomes. Nothing emitted when an expedition
*started*, which is why the two bored adventurers walked into dungeons and the
news feed said nothing at all — it wasn't broken, it had nothing to say.
Two halves:
A roster snapshot, pushed every 2 minutes. Deliberately NOT on the durable fact
queue: a fact is history and losing it loses it forever, so it retries. A
snapshot is a photograph of the present, and a retried one is a lie — by the
time it lands, she's moved. The next tick carries the truth. That's also what
lets Pete's staleness timer work: if we stay down, nothing arrives, and the
board stops claiming to be live instead of insisting forever that Josie is
still in holymachina.
And a "departure" bulletin when a bored adventurer lets itself out.
The snapshot omits opted-out players rather than anonymizing them, and carries a
board token distinct from every event token, so a standing row can't become the
key that links a player's dispatches back together.
The player_meta scan folds last_player_action_at/created_at in Go instead of
COALESCE()ing in SQL — modernc rebuilds time.Time from the declared column type
and COALESCE erases it. A failed scan here would publish an empty board and
every adventurer would vanish from the page.
Priority tier is the only thing that makes Pete post a live Matrix beat,
and the only two priority facts gogobee filed - zone_first and death -
are both moments TwinBee narrates in-room as they happen. Every live beat
was an echo. File them as bulletins: still on the site, still in the
daily digest, no second telling to the people who were there.
last_player_action_at is NULL for every existing row on the deploy that
adds it, and loadBoredomCandidates COALESCEs the NULL onto created_at.
created_at is account age, not an idle clock: for anyone who has been
playing for a month it is a month old. So the first tick after restart
would read the entire server as idle-since-creation and march all of
them into a dungeon thirty minutes later, coins debited, including the
player who typed a command a minute before the deploy.
Seed the column once from last_active_at instead — the best "did
something recently" proxy that exists at deploy time, recent for the
regulars and stale for the lapsed. It stays unusable as the clock itself
(the autopilot bumps it), which is why this is a one-time seed and not a
fallback in the query. Re-running on every boot is a no-op, and it skips
rows that already have last_boredom_at set, so a restart mid-boredom
can't hand a bored character a fresh idle clock off its own autopilot
writes and un-bore it permanently.
Two smaller ones in the same clock:
- a pending prompt counted as an action regardless of ExpiresAt, and
nothing sweeps p.pending. One offered-and-ignored treasure prompt and
every idle remark that player ever made would read as tending their
adventurer, forever.
- the command test ran 50 IsCommand passes over the body on every
message the bot sees, in every room. One tokenise-and-look-up does the
same job.
A player who stops tending their adventurer doesn't stop having one. After
24h with no action against Adventure, the character gets restless and leaves
on an expedition by itself: the easiest zone its level band allows, the
cheapest supplies it can afford, and whatever gear was already on the rack.
Everything downstream of the start was already autonomous — the autopilot
walks rooms, drives elite and boss fights on the turn engine, camps, harvests
and picks forks. The only thing that ever needed a human was `!expedition
start`, so that's all this adds: a 30m ticker plus a clock.
It never buys or equips anything, and that is the whole mechanic: a neglected
adventurer grinds half-starved runs on rusting gear and comes home taxed. The
prodexercise killed an L4 mage four rooms in on its first run.
The clock is a new column. Every existing timestamp is unusable: last_active_at
is auto-bumped by saveAdvCharacter (the autopilot would refresh a bored
character's own idle clock), loadAdvDailyActivity counts the autopilot's own
expedition logs, and user_stats.updated_at is chat presence, not a game action.
last_player_action_at is written only by markPlayerAction, from a real player
action against Adventure — any interface, not just Matrix.
Raid zones (raidContentWarning: the party-tuned T5 bosses with a 0% solo clear)
are avoided while anything else is in band. At L13+ they're all that's left, and
the adventurer goes in anyway and loses. That's intended.
dnd_expedition.boredom + isBoredomDriven stop a run nobody asked for from
shielding an absent player from the idle reaper or holding their streak. A
manually-started expedition still holds it while the autopilot walks it.
Robbie visits and pays silently for idle players — he was going to file a daily
public bulletin about people who stopped playing weeks ago.
Note for anyone touching the time-scanning queries here: modernc.org/sqlite
rebuilds a time.Time from the column's declared type, and COALESCE()/MAX() erase
it. playerIsIdle fails open, so a broken scan there declares the whole server
idle. Both it and lastExpeditionByZone select declared columns and fold in Go,
and the tests seed real rows so the scan actually executes.
The lazy store embedded appservice.StateStore, but the client's store is
also type-asserted to crypto.StateStore -- a wider interface -- and
NewCryptoHelper refuses to start when that assertion fails. Embedding the
narrower interface dropped GetEncryptionEvent and FindSharedRooms from the
concrete type, so the bot died on startup with "the client state store
must implement crypto.StateStore".
Embed both, assert both at compile time, and resolve GetEncryptionEvent
lazily like IsEncrypted since the crypto machine reads megolm rotation
settings through it.
Without /sync nothing backfills the state store, so a room the bot had
not yet heard from looked unencrypted -- IsEncrypted answers false with
no error -- and any message the bot started on its own (ambient DMs,
expedition beats, briefings) went out in the clear. Under /sync the
cryptohelper registered StateStoreSyncHandler and the initial sync
carried every joined room's state, so the store was warm before the bot
ever spoke; the appservice port replicated the crypto plumbing but not
that backfill.
Resolve encryption and membership from the server on first read instead,
and refuse the send when they cannot be resolved: a message that fails is
recoverable, a plaintext one that has landed is not. Members are resolved
the same way, since an unfetched room shares the group session with
nobody.
Pete's entire taxonomy was arrival, companion_hire, death, milestone,
rival_result and zone_first — every one of them a win, a death, or an
introduction. An expedition that ended with the player walking out alive emitted
nothing at all, so the feed showed a realm where adventurers only ever triumph
or die.
That is not a rare gap. Before the §6 slot work, a cleric retreated on 167 of
500 simulated expeditions: a third of that class's runs ended in a way the news
was structurally incapable of reporting. Casters did not read as unlucky in the
feed. They read as absent.
So: a `retreat` bulletin, filed from forceExtractExpeditionForRunLoss — the one
chokepoint every bad ending already passes through. It carries who, where, and
the day they got to before it came apart.
Gated on the reason, not on "the expedition ended":
- a death already files its own priority dispatch, and must not ALSO be
reported as a retreat;
- an idle reap is not a retreat. A player who closed their laptop did not flee
anything, and Pete announcing by name that they were driven from the field
would be a lie about a person.
The four reason strings were bare literals at their call sites; they are
constants now, because the gate cannot be allowed to drift from them.
The day count is read BEFORE forcedExtractExpedition, which stamps the row
'abandoned' and takes the live fields with it.
Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
The first cut let a caster cast in auto-resolved rooms and capped nothing but
upcasting. The sweep said that was half a fix. Room deaths fell for every caster
— mage 131 -> 105, sorcerer 118 -> 105 — and fleeing collapsed, which is what §6
predicted. But elite and boss deaths exploded behind it: mage 7 -> 38 and 19 ->
61, sorcerer 14 -> 51 and 15 -> 65. They were winning the trash rooms and
arriving at the thing that matters with an empty pool. Net, mage and sorcerer
came out of §6 worse than they went in (-34 and -37 runs cleared).
"Never upcast" is not a reserve. A mage's native-level spells ARE its boss kit,
so casting them at native level in a goblin room still spends the dragon's
slots. Only a level cap reserves anything.
So an ordinary room may reach for a 2nd-level slot and no higher. Every caster
still owns a real spell down there — a cleric's inflict_wounds, everyone else's
scorching_ray — but fireball, spirit_guardians, flame_strike and every slot the
turn engine would upcast into stay in the caster's pocket until there is
something worth spending them on.
Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
§6 was filed as "clerics are weak in solo — lift the trailer". It is not a balance
problem and a tuning dial would have buried it.
Re-baselined on HEAD: cleric still last, 46.4% vs fighter 81.4%. But cleric *dies*
less than mage, sorcerer or warlock. Its entire deficit is FLEEING — 167 of 500 runs
end with the player alive and the expedition over, where fighter and ranger flee
zero. Instrumenting the stopEnded sites: every one of those runs ends in an ORDINARY
room fight. Not a patrol, not an elite, not the boss.
An ordinary room is runZoneCombatRoster → SimulateCombat on an 8-round clock: one
breath, no turn engine, no action picker. The only spell that could ever land there
was a PendingCast the player queued BY HAND before walking in. On autopilot nobody
queues one — so for every room of every expedition, a caster fought with a weapon and
nothing else. A cleric has no Extra Attack, a mace, and its whole kit unusable: it
grinds the 8 rounds out, and a wounded one starts losing fights a fighter never
loses. One room loss ends the whole expedition.
The tell is that dnd_class_balance.go — the harness the class corpus was tuned on —
ALWAYS hands a caster their best damage spell before it simulates. The numbers that
say "cleric is a weak class" modelled a cleric who casts. The live room modelled one
who does not. The corpus and the game disagreed about what a cleric is.
Same defect as the rest of this plan: the action model is narrower than the kit. §1
(the picker never healed), §3 (the companion never acted), Pete (LoadDnDCharacter →
nil → "attack"), and now every caster in every room.
The spell is additive pre-damage, exactly as a hand-queued PendingCast has always
been, so this stays comparable to the corpus instead of being a new mechanic.
It is slot-aware and NOT free. pickBestDamageSpell reads slotsForClassLevel — the
theoretical class table — so reusing it would have cast an unlimited leveled spell
every room: the same "no row to persist onto, so it arrives fresh" free lunch that
gave the companion an infinite body. The new picker reads the seat's real remaining
slots and spends one.
It never upcasts. The big slots are what the elite and the boss are for and the turn
engine spends them there; a picker that nukes a goblin with a 5th-level slot leaves
the caster swinging a stick at the thing that matters.
Both goldens byte-identical — they pin the engine, and this changes its inputs at the
zone layer. Martials provably untouched.
UNMEASURED: the verification sweep is still in flight. Read cleric's fled count, and
watch bard/druid for overshoot — they get the same buff and were not the problem.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy