Commit Graph

426 Commits

Author SHA1 Message Date
prosolis
2ab6e7843a roster: a quietly-succeeding ticker looks exactly like a dead one
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.
2026-07-13 18:19:16 -07:00
prosolis
ced75786b9 news: tell Pete who's out there right now, not just who died
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.
2026-07-13 18:05:49 -07:00
prosolis
1cbd68a718 news: Pete stops repeating what TwinBee just said in the room
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.
2026-07-12 22:01:09 -07:00
prosolis
e377e0c85c boredom: the idle clock has to be seeded, or the deploy empties the town
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.
2026-07-12 19:08:22 -07:00
prosolis
7c379b298c adventure: bored adventurers go into dungeons on their own
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.
2026-07-12 18:57:50 -07:00
prosolis
e98029e6ac Pete can finally report a run that simply fell apart
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
2026-07-12 10:31:34 -07:00
prosolis
064ecb1848 Combat engine §6: the rooms get the small change, the boss keeps the big slots
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
2026-07-12 10:01:56 -07:00
prosolis
bae83271fe Combat engine §6: a caster casts in the rooms, not just at the boss
§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
2026-07-11 16:30:41 -07:00
prosolis
c9128fb0d6 Combat engine §1 (the other half): a cleric can heal a friend at camp
`--target @user` has been advertised by !help and swallowed by the parser since
SP2 — "reserved for SP3, accept and ignore". §1 wired ally heals inside a fight;
out of combat the cleric still could not put a hit point on anybody but himself,
which is most of where a party is actually hurt: between the rooms, not in them.

The target set is the expedition, not the world. In a fight splitCastTarget
resolves against the people in the fight; the standing-around equivalent is the
people you are travelling with, so both `!cast` paths answer the same question.

Only a heal may name somebody else. UTILITY resolves on the caster and everything
else queues as a PendingCast for the *caster's* next fight, where an ally target
has nothing to mean — so a target on those is refused outright rather than
silently dropped, which is exactly what the old parse did.

The ally's row is mutated with one guarded UPDATE inside a transaction, not a
read-modify-write under a second lock (gifting sets the precedent). Two clerics
healing each other at the same instant would otherwise take their advUserLocks in
opposite orders and deadlock the pair of them. The max-HP clamp lives in the SQL
for the same reason.

Refunds the slot on every path that heals nobody — full-HP ally, downed ally, no
sheet, stranger. A slot spent for zero HP is the kind of thing players do not
forgive. All four are pinned end-to-end through the real handler.

A heal is still not a resurrection: it will not raise the dead, same rule the
combat path holds.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:38:05 -07:00
prosolis
055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00
prosolis
27b9de5936 Companion: he carries his wounds, rations his slots, and heals himself
Three defects, all the same mistake, all found by sweep and not by tests: the
companion has no database row for a thing to persist onto, so the thing "arrives
fresh next time" — which for a resource means infinite.

1. His spell slots refilled every fight. The ledger went on his combat SEAT, and
   a seat is per-session. A human rations one pool across a 30-room run and gets
   it back at camp; rationing it IS the caster's game. Now on
   expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone —
   a run holds only ~2 real fights, so the pool never binds. I predicted this was
   the whole answer. It was not.)

2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and
   the close-out skipped him — "he arrives fresh next time", said the comment.
   That is an infinite body: he soaked a share of every fight's incoming and then
   reset, while the humans beside him bled all the way to camp. THIS was the
   carry. Now expedition_party.companion_hp; healed at camp; a dropped companion
   returns on 1 HP rather than as a corpse, because there is no companion-death
   rule and inventing one inside a bug fix would be a second feature.

3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped
   `i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for
   a whole run and never once cast it. Now simPickHeal: heal whoever is worst off,
   which is sometimes you.

Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a
Cleric, against a human Cleric follower of the leader's own level):

  solo                    69.0%
  + a human cleric        77.6%   (+8.6pp)
  + Pete                  66.1%   (-2.9pp)

The reference arm is the point. Against SOLO even a mace-only Pete looked like a
carry — but parties are designed to be safer, so solo is the wrong yardstick.
Against a human peer the real bug appeared: a gearless, level-penalized hireling
was out-clearing a fully-geared human cleric of the leader's own level by 15pp,
because he was the only combatant in the game who healed to full between fights.

With the free lunches gone he is honest, and honestly a net negative — which is
exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its
own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1).
§2(a) is next, and the sweep now argues FOR it; before this commit it would have
made things worse.

Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no
re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing
consumable first and the sim stocks them, so a human rarely falls through to the
spell. Pete carries no consumables, so it is his only heal.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 14:56:19 -07:00
prosolis
01c2cb2f0b Combat engine §1: the hired companion can cast
Every spell lookup in the engine is keyed on a Matrix user id and answered
by a dnd_* table. The companion has rows in none of them, deliberately — a
sheet on disk is what would turn him into a real character everywhere. So
the auto-picker's first statement, LoadDnDCharacter(uid), came back nil and
returned "attack", every turn, for the whole fight.

A hired Cleric swung a mace while the party died. Role-fill hands a lone
martial a Cleric, so that was the common case of the feature.

Adds a seat-scoped spellbook: seatKnownSpells / seatSpellSlots /
seatKnowsSpell / consumeSeatSlot / refundSeatSlot. A human seat delegates to
the DB functions verbatim — same queries, same order — so solo combat and the
balance corpus are untouched (both goldens byte-identical). A companion seat
is answered from his synthetic sheet and a slot ledger on his seat's
persisted statuses. The seat is the correct home and not merely the available
one: every expedition hires the same @pete, so a store keyed on his user id
would have two parties sharing one pool of slots.

He gets the same default kit a real character of his class and level gets.
The below-median stays where it was — the level penalty, the never-Masterwork
gear, the absent subclass and magic items. A bespoke weaker spell list would
be a second nerf hidden in a different file.

castActionForSeat was also a live hazard: it loaded the caster through
ensureCharForDnDCmd, whose auto-migration branch, handed a user with no sheet,
builds one at level 1 and *saves* it. Pointed at the companion that silently
makes him a player. He now takes a branch that never reaches it, and a test
counts rows in dnd_character / dnd_known_spells / dnd_spell_slots /
player_meta to keep it that way.

Measured, 640 runs/arm (10 classes x L10,L12 x 4 zones):

  solo                      66.1%
  + Pete, mace-only (HEAD)  83.4%  (+17.3pp)
  + Pete, casting            95.9%  (+29.8pp)

The fix does what it should. It also lands on top of an unpaid §2(a): the
mace-only arm shows Pete was ALREADY a carry, taking the trailing band from
6.8% to 63.6% without casting a thing. The tell is the cleric leader, who
role-fills a *Fighter* Pete — a seat this commit cannot touch — and still goes
26.6% -> 98.4%. That is enemy scaling undercharging for a seat, not spells.
§2(a) is next, and is not optional.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 13:44:47 -07:00
prosolis
d538f91cf7 Combat engine: pay off the N-body debt, and hire Pete
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.

The party golden did not exist (§5)
  Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
  nothing. That is why a healer class that cannot heal shipped without a test
  going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
  weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
  N-body path can never quietly stop being a superset of the balance corpus.
  Regenerate only on purpose: -update-party.

No action could target another seat (§1)
  Every heal in the engine was self-scoped. A party cleric could not put one hit
  point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
  @user` and `--target @user` -- the latter has been advertised in !help and
  silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
  picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
  behave like competent ones. It will not raise the dead.

Corpses kept buffing the boss (§2b)
  enemyActionsThisRound counted len(st.actors), dead included -- so a party that
  lost a member kept paying for them, and the survivor faced a boss still swinging
  at two-player cadence, alone. A death spiral with the arrow pointing the wrong
  way. Now counts livingActors(). Party golden moved deliberately for this.

An engine-driven seat was a bool any command could clear (§3)
  autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
  a companion's own auto-played move arrived at beginCombatTurn looking like a
  player returning to the keyboard and cleared the latch that was moving him. He
  then stood in the fight doing nothing while the boss he had inflated killed
  everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
  command clears, and the driver calls driveEngineSeat instead of impersonating.

"The party" could be empty (§4)
  A solo expedition has no expedition_party rows, so asking the roster who was in
  the party answered "nobody" -- and every caller fell back to something plausible.
  That is how the companion got hired at level 1 for exactly the player the feature
  exists for. expeditionParty()/partyHumans() always include the owner.

The companion himself (!expedition hire [class] / !expedition dismiss)
  Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
  NPC seat and must never become a player: no player_meta, no dnd_character, no
  inventory, no DM room -- mint him a player_meta row and
  ensureDnDCharacterForCombat will auto-build the news bot a real character, and
  he starts appearing in the graveyard and filing death notices about himself.
  Mail and seats are different sets: he fights, he does not get written to.

Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.

The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.

Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 12:39:01 -07:00
prosolis
1634bb1970 Pete news code-review fixes: realm-first ordering, parked-row reap, death-emit gate
- emitZoneClearNews: claim realm-first before the name guard so an unnamed
  straggler's first clear seeds news_realm_firsts (else the next named
  clearer is mis-announced as first-ever). Mirrors backfillZoneFirsts.
- RunMaintenance: reap permanently-parked pete_emit_queue rows (unsent,
  >30d) so a durable Pete outage can't accrete rows forever.
- emitDeathNews: gate on Enabled()/newsEmissionOn() before the char DB
  reads; markAdventureDead fires per-member on party wipes + in the sim.
2026-07-11 07:56:41 -07:00
prosolis
ae7ff38996 Pete news: salted per-event GUID token + zone_clear taxonomy
Closes the two deferred code-review follow-ups on the adventure-news
seam, plus folds in two pre-committed WIP fixes.

A. Privacy — the public GUID no longer leaks a stable per-player id.
   Replaced userHash(userID)=sha256(userID)[:6] with
   eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc).
   The salt is 32 random bytes, auto-generated once and persisted in the
   durable news_config table (cached via sync.Once). Because each event
   uses a distinct HMAC message, tokens are a PRF output and are BOTH
   uncomputable from a Matrix handle (no enumeration of a player's
   events, incl. ones anonymized after !news optout) AND mutually
   unlinkable (a named event can't be walked back to a player's other,
   anonymized events). Updated all emit sites: pete.go zone, dnd_combat
   death, adventure_duel rival, dnd_setup arrival, achievements
   milestone, bootstrap x3.

B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit
   zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted
   the invariant GUID-prefix == event_type, which also fixes latent
   permalink mislabels (achv->milestone, rival->rival_result rendered a
   neutral "Dispatch" on their permalink pages).

Folded-in WIP fixes: create the news_config table b42beec's
newsEmissionOn reads but never created; reap sent pete_emit_queue rows
in RunMaintenance; don't burn a retry attempt when shutdown cancels an
in-flight send.

Tests: TestEventToken (salted/stable/per-event/persisted),
TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear),
updated pete_test.go prefixes. Full internal suite + vet green.

Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.
2026-07-11 07:43:57 -07:00
prosolis
b42beec348 Pete Adventure News: emit seam, !news command, cold-start backfill
gogobee's game-side of Pete's push-based Adventure news feed. Emits
structured event *facts* (not prose) to Pete's ingest endpoint over a
durable queue; Pete owns voice/authoring/publishing.

- internal/peteclient: durable pete_emit_queue + retry sender; Fact
  payload; FEATURE_PETE_NEWS master switch.
- pete.go: emitFact enforces runtime kill-switch + per-player opt-out
  (anonymize, never drop); zone-clear + realm-first tiering; !news
  command (player optout/optin, admin on/off).
- bootstrap_pete_news.go: cold-start backfill replays deaths +
  single-holder achievements + zone realm-firsts, backdated + NoPush,
  idempotent, fires only once the seam is live; seeds news_realm_firsts.
- Emit sites: death, zone_first, rival_result, milestone, arrival.
- db.go: pete_emit_queue, news_optout (+opted_out_at), news_realm_firsts.

Unshipped. Deploy Pete first, then set FEATURE_PETE_NEWS + ingest env.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:25 -07:00
prosolis
fedd357a29 Seed player_meta on auto-migration to prevent player_meta-less stragglers
The !setup confirm path seeds the canonical player_meta row (commit
667f87f), but the auto-migration path (ensureDnDCharacterForCombat) writes
a confirmed dnd_character without touching the legacy layer. Commands that
trigger it — !rest, !cast, !abilities, !skills — derive the adventure char
via a bare loadAdvCharacter that is nil when player_meta is absent, and
autoBuildCharacter tolerates a nil char. So a brand-new player whose
first-ever adventure action is one of those gets a confirmed character with
no player_meta, which then fails every legacy-layer command (expeditions,
arena, world boss, town, duels) with "sql: no rows" — the same state
@camcast was found in.

Fix: ensurePlayerMetaSeed guarantees the seed row (+ tier-0 equipment)
exists at the fresh auto-migration point. Conditional on player_meta being
absent, so it's idempotent and never duplicates a legacy player's gear
(createAdvCharacter's equipment insert has no conflict guard).

Regression tests cover both the straggler repro (first-ever !rest seeds
player_meta + loads cleanly) and idempotency (no equipment duplication).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:50:48 -07:00
prosolis
3f4b4ece5c Headless real-character sim + new-feature exercise harness
Run the actual Adventure module against a copy of the prod DB with no
Matrix client, to smoke-test before deploy.

- expedition-sim: -real-user @mxid runs an EXISTING character loaded from
  -data's gogobee.db instead of a synthetic build. SimRunner gains
  PrepareRealCharacter (heals to full + tops up bankroll; keeps real
  race/class/subclass/level/gear/spells).
- plugin.SendReply now honors the MessageSink like SendMessage/SendDM.
  Reply-based handlers (duels, !town, !rivals, !achievements) previously
  bypassed the capture seam and hit a nil client under the sink. Prod
  behavior is unchanged (sink is nil in production).
- exercise_prod_test.go (build tag: prodexercise) drives every N-series
  feature — world boss, duels, Shadow, Renown, achievements, journal,
  town registries, vault, gifting — against a prod DB copy with all
  outbound messages captured. Gated on GOGOBEE_PROD_DB_DIR; never runs in
  normal CI.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 23:32:52 -07:00
prosolis
a6f1de4e74 N7/E4: Seasonal events — 1-week holiday skins
Four anchor holidays (Hallowtide, Midwinter Feast, Sweethearts' Revel,
First Bloom) each get a 7-day window (anchor ±3 days) that layers three
things on the world, all reusing existing machinery:

  1. A themed Omen that overrides the weekly rotation. Reuses the B3 omen
     effect fields, so the non-combat rule holds; kept behind activeOmen's
     simOmenDisabled guard (and activeSeason honours it too) so no season
     path can reach the balance sim or move the golden.
  2. A curated curio shelf at Luigi's — existing registry items rotated to
     the front of dailyCuriosStock, so no net-new power enters the economy.
     Off-season output is byte-identical to before.
  3. A themed road visitor via the ambient seam — a season_visitor event
     that leaves a sellable keepsake + coin gift. No combat: the ambient
     seam still never opens a fight.

Pure function of the UTC date + anchor calendar — no schema, ticker, or
persistence, same discipline as the Omen and holiday calendar. Season
banner rides the existing morning DM (no net-new scheduled message).

go test ./internal/plugin ./internal/db green; combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:30:15 -07:00
prosolis
1a47a2fdee N7/B4: Renown achievements wing
Three passive achievements (renown_1/5/10) gated on the derived Renown level
via renownAtLeast, reading player_meta.renown_xp through the achievements
plugin's existing Check(d, userID) seam. No event hook — the passive checker
grants them on the next message once the level is reached.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
aaa45eab14 N7/B3: the Omen — one rotating world modifier per ISO week
activeOmen() is a pure function of the UTC ISO (year, week): omenTable
indexed by (year*53+week)%len, so it advances weekly with no schema, no
ticker state, no persistence. Five non-combat seams read it — harvest yield,
supply freebie, expedition start mood, arena payout (scales gross earnings
before the pot tax), and ingredient drop chance. TwinBee reveals the active
omen in the existing morning DM (no net-new scheduled message).

Launch set is buffs-with-texture on non-combat levers only: Bountiful
Harvest, Quartermaster's Blessing, Golden Purse, Overflowing Satchels, Still
Waters. Nothing touches SimulateCombat or the turn engine — the omen is keyed
on the real clock, so a combat mutator would make the golden and the balance
corpus week-dependent. The plan's "elites +2 ATK" is deliberately dropped for
that reason.

The balance sim drives the real expedition loop and would otherwise traverse
all five seams, making corpus sweeps depend on the wall-clock week. NewSimRunner
sets simOmenDisabled (mirrors simAutoArmEnabled), so activeOmen returns a
no-effect omen under the sim. Still Waters subtracts from the daily threat
*rise* only, floored at hold-steady — it never forces active decay.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 20:09:30 -07:00
prosolis
38a3693832 N7/B2: Renown — prestige past the L20 cap
Overflow XP that grantDnDXP used to drop at L20 now accumulates as Renown
on player_meta.renown_xp (cumulative, atomic +=; renown_level derived as
renown_xp/25000). The reward is prestige-only: a derived rank ladder
(Renowned→…→Eternal), a cosmetic ✦N marker on the sheet and leaderboard,
a games-room shout on rank promotion, and !level progress once capped.

Renown perks are the two combat-neutral economy levers only — +loot / +XP,
capped at a streak-30 grant's economic half (+15% / +20%). combat_stats.go
reads DeathModifier/SuccessBonus/ExceptionalBonus (which map to Defense/
Attack/CritRate) but never LootQuality/XPMultiplier, so renown pays out even
through loadCombatBonuses without moving the golden or the balance corpus.
The plan's "-death penalty" perk is deliberately dropped (it would inflate
Defense).

The overflow→renown conversion and the character save commit in one
transaction (saveDnDCharacterExec now takes an executor), so a crash can
neither drop the overflow nor double-credit it on the next grant.

Schema: renown_xp column, DEFAULT 0 correct for every existing row, no
bootstrap (journal_pages/epilogue_cleared pattern).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:48:49 -07:00
prosolis
401b17c3bb N6/C2: player-initiated duels — staked, no-death PvP
`!duel @user [stake]` (+ `!adventure duel`; accept/decline/status). A staked
arena bout resolved through the auto-resolve combat engine.

The engine is asymmetric — N player-seats vs one monster-shaped enemy, no seat
for a second PC — so a duel builds each fighter as their real player Combatant
and synthesises the opponent as an enemy stat block from their sheet. That is
lopsided (player side = full kit, enemy = flat stat block), so the bout runs
BOTH orientations and decides on aggregate remaining-HP fraction, cancelling the
attacker-seat edge. To-hit stays faithful both ways (d20 + AttackBonus vs AC);
damage folds into one enemy Attack (per-hit × swings + companion-proc
expectation); DamageReduct ports over. Casters fight weapon-only in both
orientations — accepted, PvP class balance is out of scope per the plan.

Economics: both escrow the stake on accept (pool 2×stake); winner 70%, community
pot rakes 30% (a sink); exact-HP-tie draw refunds both. Stake capped at
level×€500. W/L reuses adventure_rival_records; shared 7-day pair cooldown.

Terminal fate is serialised by an atomic claim (DELETE … WHERE id=? gated on
RowsAffected==1, mirroring communityPotDebit): accept/decline/the expiry ticker
all claim first, only the winner moves euros — no stake can be both resolved and
refunded across the 24h boundary. issueDuel runs under advUserLock; accept
re-checks the challenger and builds both fighters before debiting the challenged.
Rides the 1-min eventTicker; combat golden byte-identical; suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:18:45 -07:00
prosolis
3026fe6012 N6/C3 review cleanup: retire the combined-level reducer duplication
The W1 extraction added combinedAdvLevel but left twinBeeMaxTier and
distributeTwinBeeRewards inlining the identical dndLevel+3-skills expression,
so the reducer lived in three places. Rewire both twinbee sites to
combinedAdvLevel — pure refactor, byte-identical arithmetic.

Deliberately did NOT share tierForCombinedLevel's switch into twinbee: its
default arm is worldBossMinTier, a world-boss knob, and coupling twinbee's
tier floor to it would let a world-boss retune silently leak into TwinBee's
activity selection. The reducer is the safe shared piece; the tier thresholds
stay independent.

Suite green, combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:27:32 -07:00
prosolis
e2c0c3359b N6/C3 review fixes: close the killed-boss-resolves-as-survived window
Two findings from an adversarial review of the C3 diff, both fixed:

1. (medium) A killing blow commits the shared pool to 0 inline, but the status
   flip to 'defeated' was deferred until AFTER the multi-second narration stream.
   In that window a crash/redeploy or the ticker's survive path could resolve a
   boss the town KILLED as a survival — debiting the pot and paying no bounties.
   Fix: resolve the defeat BEFORE streaming narration, and add a ticker safety
   net that resolves any active 0-HP boss as defeated (never falls through to the
   survive/pot-debit branch). Both guarded by setWorldBossStatus, so still once.

2. (minor) Pool damage was applied before the best-effort contrib/gate write, so
   a failed upsert let a player refight a pool they had already drained and lose
   the credit. Fix: write the contribution (which sets the once-per-day gate)
   BEFORE draining the pool, as a hard error that aborts the bout with the pool
   untouched.

Also corrected the applyWorldBossDamage comment (two concurrent killers CAN both
see killed=true; the status guard dedupes the payout — the old comment claimed
only one would). New ticker tests cover both resolution paths; suite green,
golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:09:11 -07:00
prosolis
127d252982 N6/C3 W2: world boss — the daily bout, command, operator spawn
Wires the player-facing half of the Siege on top of W1's model + lifecycle.

- !adventure worldboss [status|fight|spawn] (alias !adventure siege). Status
  shows the shared-pool board + your daily bout state + the muster; fight
  takes today's bout; spawn is an admin override (the "both" spawn decision).
- The bout is an arena-style solo fight through runZoneCombat vs a disposable
  per-tier stat block; the damage dealt (EnemyEntryHP-EnemyEndHP) is subtracted
  from the shared pool atomically (MAX(0, …) WHERE status='active') win or lose.
  Real HP cost like the arena, but no death/no hospital: worldBossFloorHP
  raises a 0-HP loser to 1 so a loss reads as "battered", not a corpse.
- One bout per player per UTC day (worldBossBoutUsedToday off the contrib's
  last_fight_date). The per-user advUserLock serialises a player's own repeat
  submits so the gate can't be raced; cross-player pool-crossing is safe
  because only the setWorldBossStatus winner resolves a defeat.
- A killing bout trips resolveWorldBossDefeated (minted bounty by fights +
  cache + treasure roll), after the fighter's own bout DM has streamed.

Bout core (resolveWorldBossBout) + the once/day predicate + the HP floor are
factored out of the DM path so they're unit-tested end to end with a real
fightable character; the DM/games-room emission stays thin (no client stub).
Combat golden byte-identical; full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:59:17 -07:00
prosolis
c3e122694b N6/C3 W1: world boss — model, scaling, spawn + lifecycle
The monthly communal "Siege": a named boss camps outside town for 72h with
a single shared HP pool. This lands the model and the automatic lifecycle;
the player-facing bout command is W2.

- New tables world_boss + world_boss_contrib (own tables, outside the
  saveAdvCharacter fan-out, so a char save can't clobber the shared pool —
  the isolation adventure_shadow earns). Absent active row == no event; no
  bootstrap.
- Boss sized to the town it will fight: tier from the MEDIAN combined level
  of any-chat-active players (feedback_presence_is_any_chat, off
  daily_activity), pool HP = arena per-bout HP × ~2 bouts/active player,
  clamped [4,60] bouts. Floored at T3.
- Lifecycle rides the 1-min eventTicker (no net-new goroutine): auto-spawn
  on the 1st of each UTC month (JobCompleted dedup) + resolve a lapsed
  window as a survival. Operator override + the daily bout are W2.
- Resolution: defeat mints a bounty scaled by fights fought (not damage —
  accessibility) + a consumable cache + one low-rate treasure roll each;
  survival debits 20% of the community pot as a tribute (a pot sink). Both
  close-outs are guarded on status='active' so they fire once.
- Announcements post to the games room (no-op when GAMES_ROOM unset).

Pure logic (median, tier bucket, HP scaling, pool subtract/clamp, payout
split) is unit-tested; euro/DM emission left thin per the repo's no-client
-stub convention. Combat golden byte-identical (never touches SimulateCombat);
full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:50:56 -07:00
prosolis
cc165bed1f N6/D3: the Shadow — a simulated rival adventurer
A per-player NPC rival who "runs" the same zone progression on a midnight
ticker at ~1.3x the player's own clear pace, staying just ahead so it's a
race you can always see and nearly catch. Pure theatre: no combat, no
punishment, only race pressure and two payoffs at each zone clear.

- New adventure_shadow table, deliberately OUTSIDE the player_meta save
  fan-out so a character save can never clobber the ticker's advance (the
  isolation journal_pages earns by being grant-only, made structural). No
  bootstrap: absent row == no Shadow, minted lazily on first advance.
- midnightReset advances every player's Shadow once per UTC day (own
  idempotency guard); lead-capped so it never runs >2.5 zones ahead. When it
  clears a zone the player hasn't, it leaves a journal page waiting (D1 tie-in).
- Morning-briefing race-pressure one-liners (TwinBee voice, deterministic).
- Zone-clear payoff in finalizeExpeditionOnZoneClear: a bonus-XP crow when the
  player got there first, or the Shadow's waiting page when it did.
- !adventure shadow status view.

Review fixes (3 finders + verify) folded in before commit:
- Crow XP is now set-once per zone (crowed_mask), so re-running a zone the
  Shadow hasn't reached can't farm it.
- The waiting page is granted BEFORE the pending bit is retired, so a transient
  grant failure leaves the debt for the next clear instead of swallowing a page.
- The crow line no longer claims "+XP" when the grant errored.

Combat golden byte-identical (Shadow never touches SimulateCombat); go
build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:26:16 -07:00
prosolis
81b2359109 N5 review fixes: protect keys from Robbie, fire epilogue on autopilot, atomic finale reward
Five correctness fixes from a code review of the N5 branch:

- Robbie no longer sweeps/sells cross-zone keys (Type "key"), which
  permanently broke the vault unlock they exist to open.
- Robbie's gift tier now reads the canonical DnD level, not the frozen
  legacy CombatLevel that pegged every gift at tier 1.
- Boss epilogue (D1b) now fires on the compact autopilot boss resolve —
  the primary long-expedition path — not just manual !fight. Deduped the
  two manual sites into a shared writeBossEpilogue helper.
- Finale reward latches epilogue_cleared before granting the Legendary +
  title, so a failed/late write can't make the reward repeatable.
- Misty arc beat's occupied-slot guard moved above the counter increment,
  so a contended pending slot defers the encounter instead of consuming a
  5/15/30 beat forever.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:53:32 -07:00
prosolis
3103b519fb N5/D2: NPC arcs — Misty stages, Robbie's gift, Thom's final letter
Three flavor arcs on existing per-player counters (no schema, golden
byte-identical):

- Misty: 3 deepening dialogue beats at MistyEncounterCount 5/15/30,
  prepended to the encounter opening the one time the counter lands on a
  threshold. Fiction only — no copy ties a donation to the hidden arena
  effects.
- Robbie: every 10th visit he leaves a consumable "for the trouble,"
  drawn from the dungeon pool at a level-matched tier.
- Thom: paying off the Tier-4 mortgage (no next tier) sends a final
  letter instead of the stock payoff line, plus an inert pet-treat
  keepsake if the player keeps a pet.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:12:53 -07:00
prosolis
c37c95a3e3 N5/D4: secret content pass — treasure-cache secret rooms + cross-zone keys
Secret rooms were dead content: every NodeKindSecret node silently
collapsed to a normal exploration fight and its authored LootBias
(1.5-3.0) was never read at runtime. D4 makes them what they read as —
no-combat treasure caches.

resolveRoom now diverts a secret node (keyed off the graph node, since
CurrentRoomType has already lost the kind) to resolveSecretRoom before
the RoomType switch — shared by manual !zone advance, !expedition run
autopilot, and the sim. Each secret pays a guaranteed journal page
(the D1a grant hook built "for secret rooms"), a LootBias-weighted
treasure roll (floored at elite weight), and a guaranteed zone-tier
consumable cache, with bespoke in-world discovery flavor.

Underdark was the only T2+ zone with no secret; added at throne_gallery
on the universal R4 tail (Lost Reliquary, Perception DC 17), merging to
throne_steps so it's length-neutral.

Two cross-zone keys: the Sunken Temple's Coral Reliquary grants a Sunken
Sigil that opens a Sealed Reliquary in Manor Blackspire; the Underforge's
Forge Vault grants an Underforge Seal that opens a Sealed Vault in the
Underdark. Keys are persistent inventory items matched against LockKey
key_id; grants are idempotent.

Graph validator + no-soft-lock pass on both touched graphs; combat golden
byte-identical; go build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:02:15 -07:00
prosolis
9b6c1ff9a4 N5/D1c: the finale — !expedition start epilogue
Closes the Hollow King arc with a solo one-shot boss fight, reached via
`!expedition start epilogue` (intercepted before the zone/loadout
machinery). Unlock: all 24 journal pages found + both Tier-5 zones cleared.

- Encounter: runZoneCombat + renderBossOutcome, the arena's own pattern —
  no supplies, no walk, no party, no new mechanics. The stat block is
  Belaxath's (the abyss boss whose gate "lets one thing come home")
  re-dressed as the King returned in full, HP ×1.25 for a capstone; the
  ability/AC/CR carry over unchanged.
- Reward-once: first clear grants a unique title ("Kingsbane") + one
  Legendary + a games-room notice; later clears are a flavour-only
  rematch. The flag is a dedicated player_meta.epilogue_cleared column,
  latched by an atomic UPDATE (never the bulk save) so the monotonic
  false→true can't be clobbered — same discipline as D1a's journal
  bitmask. A loss costs a death, as any boss fight does.
- Title is written after a fresh character reload so runZoneCombat's
  post-fight HP persist isn't overwritten (the survivalist-milestone
  gotcha).

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:37:53 -07:00
prosolis
aab7a7bad0 N5/D1b: boss epilogues + TwinBee's journal reactions
- Boss epilogues: a 2-3 sentence campaign capstone per zone boss, tying
  each kill to the Hollow King arc. Appended to the boss-down moment in
  both close-out paths (finishCombatSession solo, finishPartyWin party),
  gated on the boss room (!elite) so it fires for any boss kill —
  expedition or legacy !zone — and never for elites or the arena (which
  has no ZoneID entry). Forest of Shadows is the King himself; its
  epilogue frames the fall as a shed shell, leaving the arc for the finale.
- TwinBee digest reactions: a journal page found mid-expedition writes a
  "journal" log beat; the end-of-day digest emits one first-person,
  deterministically-picked TwinBee line reacting to the day's pages. No
  net-new DM — it rides the existing night-camp digest.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:26:20 -07:00
prosolis
fd7803b13c N5/D1a: journal pages — the Hollow King campaign collectible
First slice of the N5 story layer. Adds a 24-page serialized campaign
threaded through the zones as collectible journal pages.

- Storage: one journal_pages INTEGER bitmask on player_meta (bit i ==
  page i+1). DEFAULT 0 is correct for every existing row, so no bootstrap.
  Grants are an atomic bitwise-OR (INSERT ... ON CONFLICT DO UPDATE SET
  journal_pages = journal_pages | excluded.journal_pages) so a page found
  mid-expedition can't be lost to a stale character save. Journal pages
  are therefore grant-only + overlay-read, never in the bulk upsert.
- Drop seam: elite kills roll a page (22%, tunable) in dropZoneLoot,
  gated on isElite — bosses get epilogues (D1b), not pages. Not on
  SimulateCombat's path, so TestCombatCharacterization is byte-identical.
- Viewer: !adventure journal renders found pages in story order with runs
  of missing pages collapsed to a single "…".
- Catalog + bitmask helpers + render live in the new
  adventure_flavor_campaign.go; the pages are in-world found artifacts,
  not TwinBee's voice.

Golden byte-identical; go test ./... green (incl. a real-DB round-trip
that pins the atomic OR + overlay read).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:20:09 -07:00
prosolis
57a0ea90f2 N4/E1: second pet slot for the Tier-4 Estate
An Established (Tier-4) home can draw a second companion. Both pets show up on
the sheet and the town showcase, level via babysitting, and wear their own
barding (!thom pet2buy <tier>).

Combat: both pets contribute their attack/deflect/whiff procs at half weight —
DerivePlayerStats now averages over the active pets, so a pair reads as roughly
one full pet (flavor-forward, not a stat spike; difficulty-neutral). The average
reduces to identity over a single pet (x/1==x, 0.0+x==x, 3+(2L+1)/2==3+L), and
the engines still roll one proc per channel per round, so the single-pet path
and TestCombatCharacterization golden stay byte-identical. Pinned by
TestDerivePlayerStats_OnePetIsByteIdentical + the golden.

Scope kept deliberately flavor-forward: pet 2 carries identity/level/barding and
the three combat procs only. The morning-defense buff, death/ditch-recovery
save, and the level-10 supply-shop unlock stay pet-1 mechanics — no second
defensive multiplier stacks, and the one-pet path is untouched by construction.

Storage: parallel pet2_* columns on player_meta + Pet2* mirror on
AdventureCharacter (absent == no second pet, DEFAULT '' correct for every
pre-existing row, no backfill — the N2-temper / P4-party precedent). Pet-1 code
paths are untouched. Arrival reuses the chase/feed/type/name DM flow, slot-aware,
gated HouseTier>=4 with an established first pet.

Review fixes folded in (high-effort /code-review, 3 angles): pet-2 barding block
no longer hidden when pet-1 is chased away; showcase tie-break restored
(level desc, then name); petGrantXP collapsed onto the shared level-up helper;
dead-param armor-buy wrappers inlined; pet-2 barding tagged with its own euro
ledger reason.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:03:57 -07:00
prosolis
27d6bfd361 N4/E3: town registries — !town, !graveyard, !rivals board
Surface social data the game already stores as three read-only boards:

- !town — civic pride (top tax_ledger contributors), housing street
  (name + tier), pet showcase (name/type/level/barding).
- !graveyard — recent deaths across the guild (death_source/location,
  still-resting vs recovered), St. Guildmore's caretaker voice.
- !rivals board — room-wide duel standings aggregated from the directed
  adventure_rival_records ledger; bare !rivals keeps the per-user view.

All read-only: no schema, no combat, golden byte-identical. Enumerate off
player_meta / tax_ledger / adventure_rival_records with COALESCE'd display
names. Render layer is client-free and unit-tested; a DB-backed loader test
exercises the real schema (caught a MAX(datetime) affinity issue — parsed by
hand via parseSQLiteTime).

Leak-check (engagement plan §E3): the civic board ranks tax_ledger.total_paid,
which is gambling/shop/arena rake only — Misty/Arina donations go through
euro.Debit with their own reason strings and their counters live in separate
player_meta columns, so no board can correlate donations with the hidden buffs.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:31:59 -07:00
prosolis
476384206e N4/E2 review fixes: close a gift-enabled equip dupe; harden vault writes
A high-effort code review of the gifting/vault work turned up three issues:

1. Item duplication (real exploit). The masterwork/arena equip confirmation
   captures an item at prompt time and equips it on "yes" without re-checking
   ownership. Since MasterworkGear/ArenaGear are now giftable, a player could
   !give the item away in the window, then confirm — minting a copy onto
   themselves while the recipient kept theirs. Fixed by taking the user lock
   (making gift-vs-confirm atomic) and re-loading the inventory to refuse an
   item that is no longer ours. Magic items are exempt (not giftable); other
   delete paths load-and-remove within one locked invocation.

2. clearAdvInventory read the vault-filtered list but its DELETE wiped every
   row including vaulted ones — no live caller today, but it would break the
   vault's "safe from !sell all" promise the instant sell-all routed through
   it. Delete now matches the read (in_vault = 0).

3. vault store/take took no per-user lock, so two near-simultaneous stores
   could both pass the capacity check and overfill the 10-slot vault. Now
   serialized on the same user lock the gift path uses.

go test ./... green; golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:05:24 -07:00
prosolis
272f59aa32 N4/E2: item gifting — !give <item> @user
Hands a consumable or non-magic gear item to another adventurer. The sender
pays a 5% handling fee (of item value) to the community pot — the same civic
tax every payout pays — and is capped at 3 gifts/day (a persisted log doubling
as an anti-twink-funnel guard and audit trail). Recipient must have run !setup.
Magic items are deliberately non-giftable: attunement and the BiS economy stay
personal.

Built on the vault's inventory plumbing: a gift is transferInventoryItem
flipping the row's owner (owner-scoped, forces in_vault=0 so it lands in the
recipient's active pack). pickGiftableItem prefers a giftable match so a
non-giftable item can't shadow a giftable one and block the command.

go test ./... green; golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:05:11 -07:00
prosolis
1de2004f26 N4/E1: T4 Estate vault — 10-slot protected item storage
The Tier-4 "Established" home now unlocks a vault: `!adventure vault
[store|take] <item>` moves items in and out of a 10-slot pool that shelters
them from `!sell all`, crafting, and combat consumption. It is also E2
gifting's prerequisite plumbing.

Implemented as a single `in_vault` flag on adventure_inventory rather than a
parallel table: a stowed item keeps its identity (id, temper, everything) and
loadAdvInventory filters it out, so a vaulted item drops out of every play
surface with one flag change and comes back untouched. DEFAULT 0 = "in play",
correct for every pre-existing row, so no bootstrap backfill.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:44:32 -07:00
prosolis
d01d3e277a N4/E1: T3 housing payoffs + a home-rest "well-rested" buff
Turn the dead top housing tiers into something worth buying. All three
land without a schema bump, and TestCombatCharacterization stays
byte-identical (the balance corpus never sets the new fields).

T3 trophy room: treasure cap 3->4 at HouseTier>=3 (maxTreasuresForTier).
Enforced at the save gate only -- HouseTier is never written downward,
so a held 4th treasure below tier 3 is unreachable and a load-time cap in
computeAdvBonuses would just add a query for an impossible state. The
all-irreplaceable manual discard prompt now lists the 4th slot too.

T3 workshop: +5% craft success at HouseTier>=3, lifting the cap 0.95->0.98
so a maxed forager still gains. craftingSuccessRate takes a workshopBonus,
threaded through autoCraftConsumables and renderRecipesKnown.

Well-rested buff (replaces the plan's T4 "inn-quality rest", a verified
no-op -- home rest already equals inn rest). Home-only (the inn and a
tier-1 shack grant nothing), starts at T2 and grows through T4, expires at
the next long rest:
  - Temp HP cushion (8/12/16% of MaxHP). TempHP was a dormant field; wired
    into applyDnDHPScaling as MaxHP headroom -- additive and golden-safe.
  - Bonus spell slots (+1/+2/+3 at the caster's highest slot level), the
    real reward, lifting casters who trail on spell-pool richness.
    applyLongRestSpellSlots resets the pool to base then folds in the
    bonus; expiry is stateless (next rest's reset drops it).

Magnitudes are tunable defaults; revisit against the post-parties
re-baseline.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:36:32 -07:00
prosolis
d1c067452e Review fixes: align party enemy-HP scaling in the !fight entry path
The P8 diff scaled the enemy's max HP ×1.15 for parties at persist and
per-turn rebuild, but the !fight command's own template stayed unscaled:
the entry banner reported the pre-scale HP, and the opening-round settle
resolved the enemy against the wrong MaxHP ceiling (regen clamp, bloodied
threshold). Mirror the scalar for the banner and align the in-memory
template before the settle. Solo scales by 1.0, so it is untouched.

Also extract enemyActionPlan() so both combat engines share the one
load-bearing action-count computation instead of duplicating it.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:02:28 -07:00
prosolis
0d18cea59a N3/P8: scale the enemy's action economy to the party
A party of N used to face one enemy swing a round against a single seat, so
each member absorbed ~1/N² of the solo incoming and cleared 100% of every T5
cell. The enemy now takes enemyActionsThisRound() attack-actions, each
re-targeted at a fresh standing seat, in both combat engines:

  - auto-resolve (simulatePartyRound): enemyRoundSwings loops the actions,
    re-rolling each target's pet procs.
  - turn engine (stepEnemyTurn): enemyAttackAction resolves one full SRD
    multiattack per action; step() no longer blanket-stamps the enemy turn to
    one seat, since a party's enemy now hits several.

The action count is a fractional expectation realised as a per-round coin-flip
(2.4 for a duo, 2N-1 for N>=3), because the integer lever is too coarse at small
N -- against a duo, 2 actions is a ~91% faceroll and 3 a ~45% grinder, with
nothing between. A light party-only enemy HP scalar (x1.15) trims the martial
ceiling that action count alone leaves at 100%.

Solo is exempt on both levers (1 action, no RNG draw; x1.0 HP), so
TestCombatCharacterization is byte-identical and the d8prereq corpus still
compares. Sim band (party of 3, T5, HP scaled): fighter 70->90%, cleric-led
27->72% -- monotonic by party size, no composition worse than soloing.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 12:44:19 -07:00
prosolis
88c5fcdf2f Review follow-ups: harden the extraction guard, fix Misty/concentration ordering
Applying /code-review high findings on the review-follow-up stack:

- expeditionCmdStart: the resumable-extraction guard swallowed a partySize
  error and fell open, starting a new expedition on top of a still-seated
  party — the exact orphaning it exists to prevent. Now checks
  extractionLapsed first (no DB call on the reap path) and treats a
  roster-read error as "assume occupied → refuse".
- Lapsed reap on !expedition start silently unseated members. Extracted a
  shared reapLapsedExtraction helper (reap + notify the roster) and routed
  both the hourly sweeper and the start-path reap through it.
- stepRoundEnd: moved Misty's crowd/heal seat-loop after the concentration
  tick so a caster whose lingering aura would kill the enemy that round wins
  before the end-of-round crowd swing, honoring the concentration block's
  "a lethal pulse settles the fight" intent.
- Promoted the misty_heal event-log scan to a shared hasAction helper.
- Renamed the new sweep test off the dnd_ prefix.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 11:07:21 -07:00
prosolis
d5fecf45d8 Review follow-up: a test seam for outbound messages
SendDM/SendMessage went straight through Base to the live client with no
fake, so every handler whose only observable output is a DM stopped below
the test boundary — which is how the party close-out bug (fix #1) and the
member soft-lock (fix #2) survived a thorough suite: nothing could see what
was, or wasn't, sent.

Add a MessageSink interface and a Base.Sink field. When non-nil it captures
every outbound message — both the DM route (SendDM/SendDMID) and the room
route (SendMessage/SendMessageID, which is what the arena announcement uses)
— and the client is never touched. Nil in production, so behaviour is
unchanged; one seam, no call-site churn, since all 765 send sites are
downstream of these four methods.

Tests (message_sink_test.go): a captureSink double + installSink helper;
TestMessageSink_CapturesDMAndRoom proving both routes divert with the right
target/text and that the *ID variants report an id back; and
TestExpeditionCmdLeave_DMsBothEnds, which drives the real fix-#2 handler end
to end and asserts both DMs land — a path that was a silent no-op in a unit
test before. beginCombatTurn's terminal path and the arena rollover are now
reachable the same way.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 10:44:56 -07:00
prosolis
6be7744e81 Review follow-up K: a fourth event anchor for the grind loop
The three A6 presence anchors (expedition Night digest, !sell, arena
cashout) miss a whole playstyle: a player who only mines/forages/fishes
(now automatic, done by walking) and clears single-day dungeons, but
never sells, never enters the arena, and never runs a multi-day
expedition to a Night camp, would roll for zero mid-day events.

Anchor a fourth roll on a foreground single-day zone clear — the climax
DM that player is reading, and one they cannot spam (a clear costs a
full walk). advanceResult.zoneCleared is set only in the full-clear
branch (a mid-zone region clear continues the run and would fire per
region), and the roll lives in zoneCmdAdvance, the foreground path only:
autopilot goes through runAutopilotWalk and party members are refused
earlier by isPartyMember. The per-day slot guard still caps everyone at
one event/day, so the three prior anchors are unmoved.

advEventChanceZoneClear = 0.08 puts a zone-clear-only player at ~1
event/week, matching the fully-engaged player; it is the tuning knob.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 09:12:34 -07:00
prosolis
1f1fbf0251 Review follow-ups L + I: drop dead openParty, unify the menu-index parser
L: openParty had no production callers (invite path uses joinParty -> seatLeader,
which seats the leader the same way but reads the owner off the expedition row).
Removed it; the tests now seat the leader through a seatLeaderFixture that wraps
seatLeader in a transaction.

I: promoted parseTemperIndex to parseMenuIndex and routed resolveMagicEquipReply
and handleMasterworkEquipReply through it, replacing two hand-inlined copies of
the same digit parser. Behaviour-preserving (the parsed flag was redundant with
the idx<0 guard); the retry-on-bad-parse disagreement is left as-is since this
is a pure dedup.

Full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:55:07 -07:00
prosolis
d7a5333048 Review follow-up D: unify solo/party close-out effects
Both the solo (finishCombatSession) and party (finishPartyWin/Loss) close-outs
carried hand-copied lists of the same terminal effects. Item A drifted exactly
there. Hoist the effects into three shared helpers so the lists can't diverge:

- applyOwnerWinEffects: kill record + room threat + boss-defeat threat, once
  through the owner; returns bossOnExpedition.
- grantSeatWinXP: near-death calc + XP grant, per seat.
- endRunOnLoss: mood event (death only) + run/expedition teardown, shared by
  the Lost and Fled paths.

Player-facing text stays divergent (it legitimately differs) and the
roster-size death-on-win rule (item E) is untouched. Pure extract-and-call;
full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:50:15 -07:00
prosolis
91eeee0826 Review follow-up N: Misty's round-end procs fire in turn-based combat
DerivePlayerStats builds MistyHealProc / CrowdRevengeProc onto every turn-based
combatant, but stepRoundEnd had no counterpart to endOfRoundForSeat, so neither
was ever read. The buff (Misty's heal) was silently lost. The debuff (her
crowd's revenge) was an exploit: a player who declined Misty escaped it entirely
by fighting with !attack instead of letting the room auto-resolve -- no
discovery required, just press the button.

Hoisted both procs out of endOfRoundForSeat into shared helpers
(mistyCrowdRevenge, mistyHeal) and a seatEndOfRound hook that runs the pair in
the auto-resolve order. endOfRoundForSeat now calls the helpers; stepRoundEnd
calls seatEndOfRound per seat, after the poison tick so a heal can answer the
round's damage. A one-sided debuff-only hook would have been a second parallel
sibling of the kind deferred item D warns about, so the heal ships with it -- a
player-favourable discovery mechanic, consistent with the lift-trailers stance.

Both helpers short-circuit before st.randFloat() when their proc is unarmed, so
a character with no Misty history draws no dice: the sim corpus and
combat_characterization.golden do not move.

seatCombatResult now reads MistyHealed back off the misty_heal event (as
combat_pet_save always has), so combat_misty_clutch is reachable turn-based.
combat_sniper_kill stays unreachable -- Arina's proc is a pre-combat one-shot
with no round-end seam.

renderAllySeatEvent renders crowd_revenge as unattributed damage: an ally sees
the hit but not Misty's name, keeping the grudge the owner's own discovery.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:44:25 -07:00
prosolis
c34e740008 Review follow-up M: Grim Harvest fires in turn-based combat
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.

Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.

The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().

grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.

Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:25:56 -07:00
prosolis
a59a544fff Review follow-up C: enforce the extraction resume window
The seven-day window was a promise the code never kept. releaseParty fires
only on a terminal status, and dnd_expedition.go justified skipping it for
'extracting' by asserting the roster gets cleared when the window lapses and
the row flips to 'failed'. Nothing did that: the only extracting -> failed
transition lived inside handleResumeCmd, a lazy expiry that runs only when the
leader personally types !resume.

A leader who quit, forgot, or simply started a different expedition therefore
left the row 'extracting' forever. releaseParty never ran, every member stayed
seated, and assertNotAdventuring kept refusing them a run of their own.

Three holes:

  - No sweeper. sweepLapsedExtractions reaps every row past completed_at + 7d
    through completeExpedition, which releases the roster, and DMs the
    audience. Hourly ticker plus a one-shot at Start() -- a lapse that happened
    while the bot was down is blocking those members now. The audience is read
    before the close-out, since completeExpedition disbands the roster
    expeditionAudience reads. handleResumeCmd's lazy expiry stays, now sharing
    the extractionLapsed predicate.

  - The leader could orphan their own party. !expedition start checked only
    getActiveExpedition, and !resume resolves the newest 'extracting' row, so
    starting fresh on top of one left it unreachable with its roster still
    held. It now refuses when that row has a roster; a solo extraction strands
    nobody, so walking away from one stays normal.

  - The leader had no way out but to pay. !expedition abandon could not see the
    extracted row it owns, so closing it meant buying a !resume first. Both it
    and abandonExpedition now span 'extracting' via ownedLiveExpedition, which
    sorts active rows first so expeditionCmdStart's run-spawn rollback still
    tears down the row it just created. This fixes dnd_setup.go for free: a
    leader who rerolled their character used to strand their whole party.

Note the review item this came from (C) was mis-stated: it claimed members and
leaders disagree during 'extracting' because expeditionForMember filters
status = 'active'. getActiveExpedition filters 'active' too, so they already
agree -- and honestly, since nobody is standing in the dungeon. Widening
expeditionForMember would have made the member the only player who can see a
paused expedition. Not done.

Abandoning now DMs the members, and says the true thing when the party is in
town rather than the dungeon (supplies already spent, loot already banked).

New: dnd_expedition_extract_sweep_test.go, 6 cases.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:14:59 -07:00