mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
2482433896b9285ef2f4ce70a443347f7dfe8fca
478
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2482433896 |
appservice: stop sending plaintext into encrypted rooms
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. |
||
|
|
da398cf674 |
Merge: combat engine N-body debt (§1–§6) + Pete's retreat bulletin
Casters could not cast in ordinary rooms — the auto-resolve engine has no action picker, so a cleric on autopilot fought every room of every expedition with a mace while its spellbook sat unused. It read as a balance problem (cleric 46% vs fighter 81%) and was not one: cleric DIED less than mage, it just fled 167 of 500 runs, and one room loss ends the whole expedition. §6 lets them cast, with a slot reserve so the trash rooms cannot eat the boss's kit. Trailers came up, leaders did not move, and the class spread narrowed from 35pp to 26pp. Pete can now report the runs that simply fell apart, which he was structurally incapable of doing before. Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
f4a4c0d30b | Merge adventure-news: Pete Adventure News seam + code-review fixes | ||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
0c4c4757d3 |
Merge fix-automigration-player-meta-seed: seed player_meta on auto-migration
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa |
||
|
|
fedd357a29 |
Seed player_meta on auto-migration to prevent player_meta-less stragglers
The !setup confirm path seeds the canonical player_meta row (commit
|
||
|
|
59319ede9d |
Merge sim-real-char-harness: headless real-char sim + feature exercise harness
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|