Phase 2 diagnostics named the InterruptElite bracket as the likely
first lever; tier-walking the lethality probe at the matrix cadence
told a different story. Phase 1's uniform-0% baseline isn't an
elite-bracket calibration issue at all — every tier reads 0% because
the engine's TimedOut contract was being ignored by every expedition
caller.
combat_engine.go:451 says: "Timeout = retreat, not lethal blow.
Caller treats a timeout loss as 'fight ended, no character death'".
But runHarvestInterrupt / tryPatrolEncounter / resolveCombatRoom all
called abandonZoneRun + retireAllRegionRuns on any !PlayerWon —
ending the expedition outright on a retreat. The retreat flavor line
("X outlasts you. You retreat from the expedition, wounded but alive")
was already in the code, just stapled to an actual run-abort.
Splits the policy by caller:
• runHarvestInterrupt — autopilot daytime interrupt. TimedOut →
retreat: threat +5, HP carries over, run continues, harvest slot
forfeit (no kill / loot). HP<=0 still ends the run + marks dead.
• tryPatrolEncounter — !advance pre-room patrol roll. Same
retreat policy: patrols don't gate progress, so retreating from
one and walking into the next room is the right shape.
• resolveCombatRoom — !advance room/elite combat. Unchanged —
this path gates room progression; a retreat has nowhere to go, so
any loss still ends the run. (Manual zone runs were always
intended to end here.)
Harness mirrored: daytime interrupt timeout → carry HP + threat bump
+ continue day; night-encounter loss → terminate (mirrors
resolveCombatRoom, since live night encounters defer to !advance).
retreatThreatBump = 5 is the per-retreat threat penalty. Low enough
not to compound brutally with chained retreats, high enough that 3–4
retreats noticeably walks the threat clock toward Stirring. Easy to
dial in Phase 3 if zones go off-band.
Phase 1 matrix after the change still reads 0% completion at every
cell — but the encounter counts and survival shape are dramatically
different (T4 underdark 3.6→7.5 encs; T3 underforge trial saw 18
encounters across 10 days where the pre-change run died on day 2/3).
Adds TestExpeditionBalance_Phase2_TierLethality, a tier-walking
companion to the T1/rolls=1 probe, that traces every fight at the
matrix cadence across one zone per tier — the actual Phase 2b lever
work picks from this data, not the old T1-only probe.
The remaining 0% is now legibly driven by tier-disproportionate elite
rosters (Hobgoblin Warchief at T1, Green Hag at T2, Roper/Helmed
Horror higher up) that one-shot or two-shot tier-appropriate
fighters. Phase 2b's lever shortlist:
1. Roster gate / SpawnWeight tuning to dilute over-tier elites.
2. Surprise-nick floor reduction on chained interrupts (carryover
HP + nick is the death-spiral fingerprint at T1 specifically).
3. Per-day cadence reduction if 1+2 don't carry T1 to band.
Pre-existing test failures (TestAdv2Scenario_ZoneRunGoblinWarrens,
TestMageSpellbookLineInRender) verified to fail identically on HEAD;
no new test regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase 2 diagnostic artifacts. No tuning knob has moved on
production code yet — these tests calibrate the harness and surface
the real first lever for next session.
1) Cadence calibration sweep
TestExpeditionBalance_Phase2_CadenceCalibration sweeps
HarvestRollsPerDay ∈ {1,2,3,4} across the full Phase 1 matrix and
logs per-cell + per-tier completion. Required a new
HarvestRollsPerDay field on expeditionBalanceProfile so cells can
override the package-default constant. Finding: cadence is NOT the
dominant lever — at rolls=1 the T1 cell only reaches 2%, with
bimodal hp_left (100% survivors / 0% deaths). Killed the cadence
hypothesis from Phase 1's commit message.
2) Gear-tier centerline fix
phase1TierCenterline bumped for T3/T4/T5 (8→9, 11→13, 15→17). The
shared gearTier ladder (5/9/13/17 boundaries) was placing T3/T4/T5
centerlines one gear bracket *below* the zone's tier, so those
cells fought with under-spec'd weapons/armor. New centerlines are
the lowest level in each tier's design-doc range where gearTier ==
tier. All centerlines still inside their design-doc ranges. Effect
in the sweep at rolls=1: underforge T3 1.0% → 10.5% comp, underdark
T4 flipped from pure combat-death to 14% starve (i.e. fighter now
survives combat, runs out of food). Real bug, but small — the
structural lethality problem remains.
3) Lethality probe + traceFight hook
TestExpeditionBalance_Phase2_LethalityProbe runs 5 trials at the
cleanest cell (T1 goblin_warrens L3 fighter, rolls=1) with a new
optional traceFight hook on expeditionHarness that logs
monster/AC/atk/HP-pre/HP-post/outcome per fight. Hook is nil in
production runs, zero cost when unused. Finding: at T1, the
InterruptElite branch keeps drawing Hobgoblin Warchief (AC 18,
atk +5) from goblin_warrens' elite roster, and an L3 fighter has
~coin-flip odds against a CR-6-ish elite. One bad draw = dead;
that's the bimodal hp_left fingerprint from the sweep. Non-elite
draws (Worg, AC 13) play out as normal multi-round combats and
are winnable.
Next-session lever choices, in order of suspected impact:
- Roster gate: Hobgoblin Warchief out of (or weighted down in)
the T1 elite pool — it's tier-disproportionate for goblin_warrens.
- InterruptElite threshold: rarer elite-bracket draws at low threat
so a single d20 swing doesn't equal expedition end.
- Tier-floor cap on already-over-tier bestiary entries.
Plan doc: gogobee_expedition_difficulty.md §Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New permanent test TestExpeditionBalance_Phase1_FullMatrix — every
registered zone × its tier-centerline level, 200 trials/cell, Fighter
fixed (class parity is the class-balance pass's job).
Centerlines per plan doc §Method (median of design-doc level range):
T1=L3, T2=L5, T3=L8, T4=L11, T5=L15.
Gates split: wiring pathologies (zero-day loop, days > cap) are fatal;
"0% at T1 / 100% at T5" land as WARN log lines, not t.Errorf. Phase 1's
deliverable is a logged baseline, not a tuning gate — Phase 2 promotes
those to band assertions once the global levers move.
Baseline numbers (seed 0xE0FFEE1, 200 trials, current Phase 0 harness):
all 10 cells: 0.0% completion, 100% death, median day 2,
avg encounters 2.0-3.6, threat at end 5-7.
The uniform 0% says the placeholder harnessHarvestRollsPerDay=4 plus
the tier-floored AC/atk is overwhelming the fighter on day 1 across
all tiers, well before threat drift matters. Per-tier spread is 0.0pp
because every zone is floored — Phase 2 has plenty of headroom and a
clear first lever (encounter cadence).
Plan doc: gogobee_expedition_difficulty.md §Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New file internal/plugin/expedition_balance.go — sibling to
dnd_class_balance.go, no dnd_ prefix per feedback_avoid_dnd_naming.
Plan doc: gogobee_expedition_difficulty.md.
Critical de-risk settled: clock-injection seam. The harness does not
run the wall-clock tickers (expeditionBriefingTicker @ 06:00,
expeditionRecapTicker @ 21:00, expedition_ambient @ 3h) and does not
inject a virtual time.Now. Instead, advanceExpeditionOneDay
reimplements the morning→day→night pipeline by calling the math-pure
helpers directly: applyDailyBurn, dailyThreatDrift,
resolveCombatInterrupt, resolveWanderingCheck, simulateCombatWithRNG.
No DB, no goroutines, no clock.
Phase 0 trade-offs (documented in the file header):
- Boss completion deferred; survive-N-days is the proxy.
- Per-region zones, loot/XP, pardon/Sovereign, babysit, temporal
stack effects all skipped — Phase 1+ scope.
- Encounter cadence (harnessHarvestRollsPerDay=4) is a placeholder
to be calibrated against live traces in Phase 1.
Tests:
- TestExpeditionBalance_Phase0_Spike — T2 Crypt Valdris × L5 Fighter,
100 trials, asserts only degenerate sentinels (no 0%/100%, days>0,
days<=cap). Runs in <3s.
- TestExpeditionBalance_Phase0_SeedSpread — confirms the RNG seam is
actually wired by showing two distinct seeds produce different
trials. Full byte-for-byte reproducibility under same seed is not
asserted at Phase 0; surpriseRoundNick + pickWanderingMonster
draw from package-global rand (same caveat as class balance).
Spike numbers (calibration baseline, NOT a target): 1% completion,
99% deaths, median 4 days, ~6 encounters. The headline death rate is
loud but expected — Phase 1's full matrix will diagnose whether it's
HP-carryover punishment, bestiary-vs-abstract-monster delta, or kit
ladder mismatch. Phase 2 is where the centerline gets tuned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
D1: single block comment above the presence.go best-effort QueryRow
cluster (lines 149-198). Zero-value fallbacks are correct for new users
with no activity rows yet.
D2: contract comment on sendZoneCombatMessages — returns a done channel,
callers chaining post-flush work MUST consume it; in-flow handlers with
nothing to chain MUST explicitly discard. Blocking on flush would stall
command handlers behind the 2-3s/message pacing, defeating streaming.
Two fire-and-forget sites (adventure_arena.go round-advance,
dnd_zone_cmd.go streamFlow) now use `_ =` + comment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C2: wrap the 19-statement purge loop in a single BEGIN IMMEDIATE /
COMMIT so a mid-maintenance crash can't leave half the cache tables
purged. SetMaxOpenConns(1) guarantees the BEGIN, DELETEs, and COMMIT
all share one SQLite connection.
C3: run PRAGMA integrity_check at most once per week, gated on the
mtime of a sentinel file (.integrity_check_last) under dataPath. Any
result other than 'ok' is logged via slog.Error. The sentinel is
touched even on failure so a corrupted DB doesn't re-run the check
every cycle and spam logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit flagged C1 as "Backup() unwired" — actually
adventure_scheduler's dailyReset has been calling it every
midnight. The real gap was that Backup() always ran, always
to dataPath/backups, with no way for dev environments to opt
out.
Now: env var unset → no-op + debug log. Env var set → that
directory is the destination. Existing scheduler call site
unchanged; it cleanly no-ops in dev.
Sweep of the remaining four twinbee_*.go flavor files (gm, resource,
ambient, housing). Every 'TwinBee [verb]' / 'TwinBee says' / 'TwinBee
notes' rewritten into first-person or implicit-subject voice. Jokes,
references, and pacing preserved; a few lines got slightly sharper
punchlines on the way through.
Files touched:
- twinbee_gm_flavor.go (~270 lines, all narration pools)
- twinbee_resource_flavor.go (~100 lines, harvest/fish/loot pools)
- twinbee_ambient_flavor.go (~40 lines, between-day ticker)
- twinbee_housing_flavor.go (7 lines; Thom Krooke 3rd-person and
Pastel 1st-person voices preserved)
twinbee_npc_flavor.go intentionally untouched: those are Pete
announcements referring to TwinBee, which is correctly third-person
(different speaker).
DO-NOT-REWRITE headers replaced with voice-convention notes covering
future additions.
Every 'TwinBee [verb]' / 'TwinBee says' / 'TwinBee notes' rewritten
into first-person or implicit-subject voice. Header DO-NOT-REWRITE
prohibition retired (it predated the realization that the third-person
voice was actively annoying); replaced with a voice-convention note
covering future additions.
Sibling twinbee_*.go files still carry third-person references; sweep
to follow.
The A1 regex banned \b\d+d\d+\b (e.g. '1d20') but missed bare die
notation ('d20', 'd6') with no leading count. SRD's 'blink' Description
slipped through with 'Roll a d20 at the end of each of your turns...'.
- Add \bd(4|6|8|10|12|20|100)\b ban to the prose regex sweep.
- Add hand-authored overlay shim for blink in dnd_spells_data.go.
Classes union with SRD preserves Cleric/Sorcerer/Warlock availability.
Test now passes the wider sweep; no other reachable spells leak.
Strip 'Adv 2.0', dice formulas, DCs, and ability-mod math from the 21
player-facing Description strings in AdventurePlugin.Commands(). Rewrite
harvest verbs (forage/mine/scavenge/essence/commune/fish) into outcome
language. !roll keeps its dice syntax since that's a format spec, not
gameplay jargon.
No runtime changes; build + vet clean.
The audit plan flagged jargon leaks in the SRD spell prose surfaced by
!cast and !spellbook. Verifying against HEAD turned up two corrections
to the plan's framing:
- The overlay in dnd_spells_data.go wins on ID collision, so for the
~76 hand-authored spells the *overlay* Descriptions are what players
actually see — and those were crunchier than the SRD ("Heal 1d8 +
WIS mod HP. Touch.", "8d6 fire in 20 ft radius. DEX save half.",
etc.). Touching only the generated SRD file would have missed the
bigger leak.
- The Upcast field is never displayed at runtime (only the import
generator reads it), so testing it against the regex would test
dead data.
So this commit:
- rewrites every overlay Description into playful, jargon-free flavor
in the charm_person voice ("Sweet-talk a humanoid…") — no dice, no
saves, no AC math, no ability-mod references
- adds 31 overlay shims for SRD-only spells whose generated text was
broken (placeholder gaps from the Open5e classifier — "no larger
than a.", "up to to", "in a in range"), empty ("You touch a
creature."), typo-laden ("magicou", "undeadou", "diseasesou"), or
jargon-heavy (false_life's "1d4+4 temporary hit points")
- adds TestSpellDescriptionsAreJargonFree, a regex sweep over the
merged registry that bans "saving throw", "spell slot of",
"ability modifier", "hit points equal to", and \bNdN\b dice
notation. Currently passing; will fail-loud on regressions or new
SRD imports that need overlay shims.
The generated dnd_spells_srd_data.go is left untouched — overlay is
the regen-safe surface per its own header comment.
Active expeditions now tick on a 3-hour cadence between the 06:00
briefing and 21:00 recap. Each fire DMs the player a short TwinBee
moment — mostly pure flavor (dripping sounds, polite possums,
diplomatic moths) with a 50/50 chance of a small mechanical nudge
(±SU, +threat, 1d4 coins from a found purse, +1 HP if camped).
The dungeon is now louder while you're offline without being
balance-relevant. Multi-day expeditions feel populated by something
other than the player's calendar.
Mechanics:
- new last_ambient_at column with CAS idempotency (mirrors
briefing/recap pattern in dnd_expedition_cycle.go)
- ambientCooldown=3h, skipped if in turn-based combat session
- 60min politeness window around scheduled briefing/recap so we
don't pile DMs on the daily messages
- weighted event pool with per-event eligibility (camp_visitor
requires camped, pack_rat requires SU>=1, faction_whisper
requires threat>=30 and not siege)
- effects: applyThreatDelta, updateSupplies, euro.Credit +
coins_earned bump, SaveDnDCharacter HP nudge (clipped at max)
Files:
- internal/db/db.go: ALTER TABLE + CREATE TABLE update
- internal/flavor/twinbee_ambient_flavor.go: 7 humor-heavy pools
- internal/plugin/expedition_ambient.go: ticker + resolver
- internal/plugin/expedition_ambient_test.go: 8 pure-logic tests
- internal/plugin/adventure.go: wire goroutine in Start()
The rage threshold check sat only at the top of resolvePlayerAttack,
so a player who took a threshold-crossing hit and was then killed by
the very next enemy swing (before getting back to their own attack)
would never see "rage" emitted — even though HP visibly crossed 50%
while alive.
Extracted maybeTriggerOrcRage shared by both sites:
- Top of resolvePlayerAttack (unchanged UX: rage applies same-round
when the player swings after the enemy hit).
- End of runRound as a backstop (catches cross-round two-shots).
st.raged guards against double-emit. Fixes the flaky
TestOrcRageFiresOnLowHP; 10/10 repeats green.
Each walked room gets one auto-harvest pass across every applicable
action (forage, scavenge, mine, fish in water zones, plus the class-
restricted essence/commune). Class/kill/event gates are inherited
from the manual harvest path.
Stop rules:
- Rare+ nodes are NOT auto-attempted; they pause the walk via
stopRareNode so the player spends the attempt deliberately.
- Standard/Elite/Patrol interrupts hard-stop the walk (stopEnded on
death, new stopHarvestCombat on survive).
- Noise interrupts apply threat+2 and continue, matching manual
!harvest behavior.
Display:
- Per-room compact footer ("+2 Scrap Iron, +1 Shadow Herb · 1 fail")
streamed inline as autopilot walks.
- Walk-end cumulative tally appended to whatever final block fires.
No SU surcharge — parity with manual !harvest.
Pure helpers unit-tested (rarity classification, footer rendering,
walk tally sort order, rare-pending dedup). Full autoHarvestRoom
path needs prod DB and follows the existing setupAuditTestDB gating
pattern.
Adds an autopilot loop that walks rooms automatically until a
natural or threshold interrupt fires.
- New !explore (top-level alias) and !expedition run subcommand
driven by expeditionCmdRun in dnd_expedition_cmd.go.
- advanceOnce extracted from zoneCmdAdvance so the autopilot and
the manual !zone advance path share one room-step implementation.
- Stop reasons: fork, elite/boss doorway, death, complete,
active-combat block, HP <= 30%, SU < 1 day's burn, 6-room cap.
- Trash combat continues to auto-resolve inside resolveCombatRoom;
no changes there.
- TestAutopilotFooter_Reasons covers the pure stop-reason logic;
preflight + walk tests skip without the prod DB, matching the
existing setupAuditTestDB pattern.
Phase 2 (auto-harvest) and Phase 3 (real-time ticking) deferred.
Drops D&D-handbook syntax from the player-facing caster UX in favor of
verbs and feelings (per feedback_accessibility_over_dnd_crunch).
R12 — Drop "Spell DC: N / Spell Atk: +N" line from the spellbook view.
The numbers are pure handbook noise; the engine still computes them.
R13 — renderSlotLine swaps "L1 3/4 · L2 1/3" for "Level 1 ●●●○ · Level 2
●○○○". Filled bullets = energy left, hollow = spent.
R14 — Caster passive Description strings rewritten outcome-first: no
"+5%", no "scaled by your Charisma". Verbs and texture instead. All ten
class passives reworded; mechanics in applyClassPassives unchanged.
R18 — Class menu drops the "(d8, INT/WIS)" suffix → "**Mage** — INT &
WIS". The HP-die number was leaking implementation. !setup class confirm
line gets the same treatment.
P5 — Spellbook headers: "Cantrip" → "Cantrips", "L1" → "Level 1".
P6 — Cast queue line: "_(upcast to L2)_" → "_(empowered)_". Queued-line
in the spellbook view follows suit — drops "(L2 slot)" for "(empowered)"
when the slot is above the spell's base level.
P8 — Comment-convention marker added to dnd_passives.go and
dnd_subclass_combat.go file headers: `// internal note (not user-facing)`
flags Phase 2/3 tuning history so future codegen doesn't lift it into
Description / Flavor strings.
P9 — Spellbook line drops the duplicate "(can learn N more)" trailer —
the "%d / %d" already conveys remaining capacity.
Bonus cleanup. The two Arcane-Trickster slot-ceiling errors ("Arcane
Trickster L5 only has up to L1 slots.") and the "No L1 slot available"
out-of-energy messages get the same jargon scrub: "At level 5, your
Arcane Trickster magic only reaches level-1 spells." / "You're out of
level-1 energy."
Tests. Full plugin suite green; no test pinned the old strings.
R22: replace race copy that promised mechanics the engine doesn't deliver.
- Tiefling: wire FireResist as a CombatModifier. Enemy main attack is
halved when monster is FireAttacker-tagged; aoe_fire abilities are
halved unconditionally; fire-tagged traps deal half damage to Tieflings.
DnDMonsterTemplate carries FireAttacker; toCombatStats propagates it.
Hand-authored fire entries tagged in dnd_bestiary.go (flameskull,
magmin, azer, salamander, fire_elemental, emberlord_thyrak,
young_red_dragon, infernax, belaxath).
- Open5e tuned generator derives FireAttacker from the highest-AvgDamage
attack's DamageType (threshold AvgDamage>=5). 19 tuned monsters tag.
Regenerated bestiary_tuned_data.go.
- Elf: drop "immune to sleep" (no sleep mechanic); reframe as keen
senses + trance flavor.
- Half-Elf: drop "two bonus skill proficiencies" (no skill system);
reframe as adaptable cross-cultural know-how.
- Tiefling copy: drop "bonus on CHA checks" (no checks); keep fire
resistance with flavor framing.
R23: DnDRaceInfo grows a BestFit field; renderRaceMenu emits an
"_best with: …_" hint per race so spiky stat spreads (Orc -1/-1/-1)
read as specialist picks rather than a brick of penalties.
R24: dnd.go header comment for the caster classes now reflects the
shipped state (Playable=true, spell lists populated) instead of the
pre-Open5e scaffold language.
Tests: TestApplyRacePassives gains a FireResist column; new
TestTieflingFireResistance asserts ~0.5x ratio over a 300-trial sweep
against a FireAttacker enemy. Full suite green.
Plumbed through the open5e importer so regen stays safe:
- New cmd/open5e-import/desc_overrides.go holds two per-ID override
maps (spellDescOverride, magicItemDescOverride) and a regex-driven
cleanDesc sanitizer. Override wins outright; otherwise the SRD
first-sentence runs through cleanDesc, which strips the phrases
the S3 acceptance criteria forbid (saving throw[s], spell slot,
within range, 5-foot, DC <n>, "(save DC X)" parentheticals,
"constitution score is N", "out to a range of N feet"). A small
post-pass repairs the orphan stubs the strippers leave behind
(" and." trailers, "must make." after the saving-throw object
is gone).
- gen.go (spells) + magicitems.go now call spellDescription /
magicItemDescription instead of raw firstSentence; same hand-
authored override pattern, same cleanDesc fallthrough.
- Override coverage: the 19 SRD-only spells that show up in
defaultKnownSpells (call_lightning, charm_person, vicious_mockery,
…) plus ~35 high-visibility magic items (Amulet of Health, every
Belt of Giant Strength variant, Cloak of Displacement, etc.).
Tone is outcome-first second-person with bite — these surface
in the spellbook and the curio shop, so they get to be funny.
- tuned.go (R21) + magicitems.go strip "(...)" from emitted Names
via stripNameParenthetical. Slug keeps the variant; only the
display text loses the qualifier. Two bestiary entries
(giant_rat_diseased, deep_gnome_svirfneblin) and stone_of_good_luck
affected.
- Regenerated all three data files. Acceptance grep is clean:
zero hits for any banned phrase in Description/Desc fields.
- New cmd/open5e-import/desc_overrides_test.go covers the
sanitizer regressions, orphan-repair, name-strip, and the
override-wins-but-fallthrough-still-sanitizes path.
Conflicts: none. S4 (magic-item UX) wanted this in first so the
new curio renderer consumes clean text — ready for it now.
B2: Removed shield/counterspell from Mage/Sorcerer defaults and hellish_rebuke/counterspell
from Warlock — reactions are EffectReaction and combat has no reaction window yet, so
auto-granting them just litters the spellbook with dead entries. Substituted L1/L3
staples (sleep, fly, burning_hands, chromatic_orb where class-tagged appropriately).
B3: Replaced the stale "Mage, Cleric, Ranger, and Arcane Trickster (L5+)" enumeration in
!cast/!spells learn/!prepare error paths with class-aware route hints via a new
spellRouteHintFor() helper.
R15: Removed the duplicate hand-authored healing_word_spell entry; Cleric default now
references the canonical SRD healing_word (cleric/druid/bard tagged). parseSpell
and lookupSpell now resolve "healing word" deterministically.
R16: !cast --drop now appends "(slot refunded)" when applicable.
R17: Prep-cap message rewritten to "Your prepared list is full (N/N)."
R19: Removed shillelagh from Druid default — the overlay flags it cleric/ranger only and
it has no real attack profile yet (BuffSelf, no DamageDice).
Bonus correctness: dndSpellRegistry now unions Classes on overlay collision instead of
replacing wholesale. The hand-authored buildSpellList() entries were tagged Mage-only,
so every Sorcerer/Warlock/Bard/Druid spell that collided with the SRD silently lost its
class list — meaning auto-granted defaults were rejected at the classOK gate. Union
merge restores SRD coverage without forcing a hand-edit of every overlay entry.
Tests:
- TestDefaultKnownSpellsHaveNoReactions covers B2.
- TestDefaultKnownSpellsAreCastableByClass guards the overlay-narrow regression.
- TestHealingWordResolvesDeterministically covers R15.
- TestCastNonCasterErrorHasNoClassEnumeration covers B3.
- Existing dnd_prepare_test.go updated to reference healing_word.
`!adventure sell all` and `!adventure sell <name>` previously treated
Type=="magic_item" rows as bulk loot, silently deleting bonded curios for
their base coin Value. Mirror the existing MasterworkGear/Arena/card
branch and reroute the player to `!adventure equip-magic`.
Tally kept curios in the sell-all summary; surface a dedicated
empty-rejection message when the only contents are curios; add the same
guard to the single-item path.
Tests cover: mixed bag (magic item preserved, junk sells), curio-only
bag (no-op + reroute), explicit sell <curio name> (refused + reroute).
Design steer from user — "relatively easy but not too easy" — narrowed the
target: lift the embarrassing caster trailers on off-tier cells (a casual
player walking into a slightly too-hard dungeon underleveled) without
pushing the already-saturated in-tier ceiling.
Levers:
- Druid passive: was the only chassis with a purely defensive passive
(5% DR, no offense), and it read it — L1/T1 mean 0.77 (lowest at the
entry tier), L1/T2 0.04. Added a level + WIS-scaled FlatDmgStart burst,
same shape as the Phase-2 Bard/Mage/Warlock pass. Kept the DR; no
DamageBonus rider so high-tier ceilings stay flat.
- Sorcerer passive: burst base 3→5. Sorcerer was second-worst caster
off-tier (L1/T2 0.10 vs Mage 0.27 pre-tune) despite a comparable stat
line; the bump pulls it toward arcane-chassis parity.
Observed lifts:
- Druid L1/T1: 0.77 → 0.86 (+9pp) — chassis now functional at its
intended tier
- L2/T2 cross-class spread: 77pp → 63pp; druid trailer 0.23 → 0.35
- L1/T1 spread: 23pp → 14pp
Off-tier diagnostic: added a focused log to TestClassBalance_Phase1_FullMatrix
that names the trailing class at each off-tier (lvl, tier) cell. Not
asserted — L1 in T2 is *supposed* to be hard, so the diagnostic is for
watching the gap, not the absolute number.
In-tier parity assertion (35pp band on the diagonal) still passes;
TestApplyClassPassives updated for the new druid/sorcerer FlatDmgStart
values; full plugin -short suite clean.
The Phase 1 per-class-mean summary was hiding the truth — most cells are
floor/ceiling-saturated (L10+ pinned at 1.0, L1-4 caster cells at high
tier pinned at 0.0), so means barely budge when you tune passives. Added
a per-(level, tier) cross-class spread diagnostic to the matrix log,
then tuned with the levers from doc §6 in priority order:
1. Class passives (dnd_passives.go) — caster trailers (Bard, Mage,
Warlock, Sorcerer) gained level + casting-stat-scaled FlatDmgStart
bursts so the L1-4 chassis isn't a quarterstaff + one weak spell
against a T2-T3 monster; small DamageBonus riders (Mage/Bard/
Sorcerer/Rogue +5%, Warlock 10→12%) and +1 attack for Bard/Warlock
close the steady-DPS gap. Added clampNonNeg so ability-mod-scaled
additions never go negative on sub-10-stat sheets.
2. Subclass L5 tiers (dnd_subclass_combat.go) — the three Sorcerer L5
picks (Wild/Storm/Draconic) and Warlock Great Old One were defense-
only or near-inert pre-tune; each gained a small bite (DamageBonus
+0.10, or a FlatDmgStart burst for Storm) so the L5 chassis can press
through a T4 monster.
Parity band locked in TestClassBalance_Phase1_FullMatrix: cross-class
spread ≤ 35pp on the in-tier diagonal — (level, tier) cells where the
level is appropriate for the tier (T1: L1-4, T2: L3-7, T3: L5-10, T4:
L7-15, T5: L10-20). Off-tier cells (L1 mage at T3 dungeon etc.) are
still logged but not asserted: those are level-vs-tier mismatches and
casters at L1-4 can't muscle through a T3 monster on a single L1-slot
spell the way martials muscle through with weapon dice. Worst in-tier
cell after tuning: ~26pp at L3/T2. The 35pp band gives ~9pp Monte-Carlo
headroom over the worst signal at 200 trials/cell.
TestApplyClassPassives expectations updated to match the new passives.
Phase 0 spike still green, full plugin suite (-short) clean.
Generalizes the Phase 0 spike harness to the full build matrix the
class-balance doc plans for. No tuning yet — just measurement.
- classBalanceProfile gains Subclass; buildHarnessCharacter sets it on
the synthetic DnDCharacter; buildHarnessPlayer now calls
applySubclassPassives after class+race passives, matching live order
(combat_bridge.go, combat_session_build.go). Subclass="" is a no-op,
so L1–L4 pre-unlock rows are unaffected.
- buildPhase1Profiles yields 190 rows: 10 classes × 4 pre-subclass
levels (L1–L4) + 10 classes × 3 subclasses × 5 post-unlock checkpoints
(L5/7/10/15/20). Order is registry order so output reads like the
design doc / !class help.
- TestClassBalance_Phase1_FullMatrix runs the matrix at 200 trials/cell
(~5.5s) and logs every cell plus a per-class tier-mean summary with
min/max range. Only harness-broken pathologies fail the test (0% at
T1 anywhere, or 100% at T5 for an L1 build); per-tier parity bands
land in Phase 2 once we have data to calibrate the tolerance.
Phase-2 baseline from this run: at T4 the cross-class spread of mean
win rate runs Bard 0.62 → Fighter 0.80 (~18pp); at T5 0.48 → 0.64
(~16pp); casters trail martials at the post-unlock tier (T3) by ~20pp.
Phase 0 test (TestClassBalance_Phase0_FighterVsMage) still green with
identical numbers — the additional applySubclassPassives call is a
no-op for Subclass=="".
Per gogobee_class_balance.md §5 Phase 0: harness skeleton, equipment
and spell-selection policies, Fighter-vs-Mage plausibility run before
Phase 1 generalizes to the full 10 × 30 matrix.
Bypassed in Phase 0 (per doc §2): DB-touching layers (magic items,
armed abilities, pending-cast persistence), subclass passives (none
below L5), and race passives beyond Human +1-all. Everything else
flows through the production combat path.
Initial numbers (400 trials/cell, dungeon T1..T5):
fighter L1 .998 .805 .165 .020 .000
fighter L3 1.000 .998 .795 .235 .035
mage L1 .880 .190 .003 .000 .000
mage L3 1.000 .950 .158 .003 .000
Both classes win at T1 (spell policy is firing — Magic Missile lands
each Mage fight); both collapse by their off-tier — monster scaling
works. The Mage's L1 T2 gap (-60pp vs Fighter) is real data, not a
broken harness. Phase 1 picks up the full matrix from here.
Phased plan for the class-balance pass. Unlike the race pass, classes
are *measured* not modeled: combat is one-shot auto-resolved through a
seedable simulateCombatWithRNG, so the harness runs Monte Carlo over the
real engine and reads win rates directly.
Scopes the full matrix (10 classes × 30 subclasses × level checkpoints ×
monster tiers), the two hard policies to de-risk in Phase 0 (equipment
loadout, spell selection), the per-tier win-rate parity rule, and the
tuning levers. Phase 0 spike in progress.
Flat net ability mods aren't equal *effective* power: a +1 in a stat a
build uses beats a +1 in a dump stat. Add dnd_race_balance.go, which
scores each race's mods against a 60/40 blend of per-class combat stat
priorities and class-independent non-combat utility (zone locks,
expedition harvest, skill checks, haggling).
Retune all races so their mean score across playable classes lands
within ±0.5 of the Standard Human baseline (6.0); best-fit/worst-fit
spread is kept as intentional race identity. TestRaceBalance logs the
report and asserts the rule.
Human was mechanically blank (all-zero mods, "not yet implemented"
passive). Wire the Standard Human flavor: +1 to every ability score,
no setup-wizard choice so !setup stays uniform across races.
That put Human at +6 net with no downside, ahead of every other race.
Retune the rest to +6 net as well, keeping their negatives intact so
each stays a spiky specialist rather than a flat generalist.
Magic items now reach players through three surfaces: zone loot drops,
Luigi's "Curios" shelf, and combat effects. Effects are formulaic
(Rarity scalar x Kind), mirroring the bestiary tuning pass, with an
empty magicItemEffectOverlay as the hand-authored refinement path.
- magic_items_gameplay.go: rarity index, magic_item_equipped persistence
(new table, DnDSlot-keyed), codified effect formula, applyMagicItemEffects
combat hook, potion/scroll -> ConsumableDef bridge, !adventure equip-magic
- dropZoneLoot: 15% magic-item substitution roll by tier rarity
- Luigi's Curios category: daily UTC-seeded 8-item rotation
- combat_bridge / combat_session_build: applyMagicItemEffects after passives
- consumableDefByName falls through so loot/shop potions auto-resolve
- renderDnDSheet: new Magic Items section
Equippable items live entirely in the DnDSlot scheme, separate from the
legacy tier-gear. Attunement items equip inert until attuned (3-slot cap).
The tuned bestiary previously left every generated entry with a nil
Ability. abilityFromTraits now classifies each creature's SRD trait
names against a priority-ordered rule table, mapping the most
combat-defining trait onto a MonsterAbility effect (death_aoe,
regenerate, spell_resist, evade, enrage, ...). Creatures whose traits
are all non-combat stay nil. 165 of 322 entries get an ability.
fetch/gen magicitems subcommands vendor data/open5e/magicitems.json (237
SRD items) and classify them into a generated registry. magic_items.go
holds the MagicItem struct + Kind enum + an init-time overlay merge where
a hand-authored entry wins on ID collision, mirroring the spell and
bestiary imports. Not yet wired into zone loot or the shop — that
integration is a deliberate follow-up.
Adds `gen tuned` to cmd/open5e-import: a deterministic formula that scales
every raw SRD stat block down to an engine-ready DnDMonsterTemplate. HP/AC/
AttackBonus are verbatim SRD (AC clamped to the engine min 10); the Attack
stat is interpolated from attackByCRPoints, a CR→Attack anchor table lifted
from the hand-tuned dndBestiary (CR is the calibration axis the 2026-05-10
rebalance used — raw SRD per-hit damage is ignored). Speed/BlockRate are
coarse baselines from SpeedWalk/AC.
bestiary_tuned.go merges the 322 generated templates into dndBestiary, but
hand-authored roster entries win — the merge only fills IDs the roster does
not already define, so playtested numbers and wired abilities are untouched.
Abilities are deliberately not wired: every generated entry has a nil
Ability, with the SRD multiattack/trait text parked in Notes as raw material
for the follow-up ability-wiring pass.
fetch|gen bestiary subcommands vendor data/open5e/monsters.json (322
SRD monsters) and generate bestiary_srd_data.go — all 322 as raw SRD
stat blocks (HP/AC/ability scores/CR + per-attack damage dice).
This is a balance-baseline reference, not an engine roster: raw SRD
damage one-shots the solo player, so nothing here feeds combat. It's
what the future tuning pass reads against when deriving dndBestiary /
srdProfiles entries. XP is derived from CR (Open5e has no XP field).
Adds signature class passives for Druid/Bard/Sorcerer/Warlock/Paladin in
dnd_passives.go, each riding an existing CombatModifiers channel so a
playable caster is no longer mechanically empty: Druid Wild Resilience
(damage reduction), Bard Bardic Inspiration (initiative), Sorcerer Innate
Sorcery (CHA-scaled pre-combat burst), Warlock Agonizing Blast (+10%
damage), Paladin Divine Smite (level-scaled burst).
Adds 15 subclasses (3 per caster, registry now 30) using canonical 5e
archetype names, with passive-only L5/7/10/15 effects in
applySubclassPassives — consistent with the majority of the original
fifteen, no new resource pools or active abilities. subclassesForClass is
now non-empty for casters, so the L5 !subclass prompt and sheet display
light up through the existing generic plumbing.
Tests: TestApplyClassPassives extended to all ten classes and the new
channels; TestSubclassRegistry_* updated to expect 30 entries across all
ten classes.
Rebuilds the cmd/open5e-import CLI (fetch/gen × spells) to vendor
data/open5e/spells.json (319 SRD spells) and generate
dnd_spells_srd_data.go (237 after the level>5 filter). mapClasses unions
the API's incomplete structured spell_lists field with the complete
free-text dnd_class field so all eight casters get spells.
dndSpellRegistry loads buildSRDSpellList() first; the hand-authored
buildSpellList() overlays it (hand wins on ID collision). Playable=true
flipped for Druid/Bard/Sorcerer/Warlock/Paladin, each with a
defaultKnownSpells case. TestDefaultKnownSpellsExistInRegistry now covers
all eight classes.
.gitignore: vendor data/open5e/ while keeping the rest of data/ ignored,
and anchor the open5e-import binary pattern so it stops swallowing the
cmd/open5e-import source dir. NOTICE adds CC-BY-4.0 / SRD attribution.
Add Druid, Bard, Sorcerer, Warlock and Paladin as structural
scaffolding ahead of the Open5e spell-data import. They are wired
through the mechanical layer but gated out of !setup via the new
DnDClassInfo.Playable flag — they have no spell list yet.
- dnd.go: class constants, dndClasses rows, Playable gate, AC floors
- dnd_combat.go: classAttackStatMod, dndClassWeaponBonus,
classStatPriority arrays for the five
- dnd_spells.go: mageSlots generalized to fullCasterSlots (shared by
the full casters); Paladin shares rangerSlots; new warlockSlots
simplified long-rest pool; spellcastingMod + classIsCaster extended
- dnd_setup.go: renderClassMenu and parseClass filter on Playable
Subclasses and spell lists are deferred to later sessions.
Turn the four placeholder ability effects into working mechanics:
spell_resist halves player spell damage, reveal_action rolls the
player's next swing at disadvantage, fear_immune fizzles control
spells, and ally_buff grants an accumulating enemy attack bonus.
All four are armed by applyAbility, read by the shared resolution
primitives, and round-tripped through CombatStatuses for turn-based
suspend/resume. New branches are guarded by zero-valued state so the
auto-resolve characterization golden is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 3 of the bestiary SRD upgrade: the monster abilities that need
per-fight state (evade, block, advantage, retaliate, regenerate,
survive_at_1, stat_drain, debuff, max_hp_drain). applyAbility arms
combatState flags that the shared resolution primitives read, so both
the auto-resolve and turn-based engines honor them; the turn-based
engine round-trips them through CombatStatuses so a suspended fight
resumes from exact mid-state. New branches are guarded by zero-valued
state so the auto-resolve characterization golden is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires up the ability effects that resolve fully within applyAbility with
no new persistent state: damage riders (bonus_damage, aoe/aoe_fire/
death_aoe, execute) via the shared calcDamage formula, self_heal, and
flavor-only placeholders for effects still pending per-fight state.
Works in both auto-resolve and the turn engine since both call
applyAbility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pet attacks were never resolved in turn-based fights. Roll the proc once
at fight start (a per-round roll would make a proc near-certain over a
long manual fight), persist it on the session so suspend/resume and
reaper auto-play honor the same outcome, and land a single pet hit on
the player's first acting turn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A turn-based elite/boss combat session locks the run for up to 1h and
can straddle any scheduled tick. Add hasActiveCombatSession() and consult
it from the morning DM, midnight idle reaper, expedition briefing/recap,
and mid-day random event paths so none of them fire DMs or mutate run
state into a live fight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the MVP turn-based round renderer with RenderTurnRound, which
reuses the full auto-resolve narrative pools for shared combat events so
TwinBee's voice is identical across both engines. Only the four
turn-specific actions (flee, spell_held, spell_cast, use_consumable) get
new pools.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice 1 shipped raw SRD damage, which is calibrated for a 4-PC party
and one-shots gogobee's single player at every tier. Rescale each
profile's per-attack damage so the round-total lands at ~1.3x the
creature's tuned auto-resolve Attack stat, keeping the SRD structure
(attack count, to-hit, damage split) intact.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Elites/bosses in turn-based fights now swing a full SRD multiattack
profile and fire their special ability — abilities never fired in the
turn-based path before, and every enemy made a single attack.
bestiary_srd.go adds SRDAttack/SRDProfile and a hand-authored registry
for the named bosses and multiattack elites; auto-resolve keeps its
tuned single-Attack blocks untouched. turnAbilityFires remaps the
auto-resolve phase clock onto fight progress for the phase-less duel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CombatStatuses now mirrors every persistent combatState one-shot —
depleting resources (ward/spore/reflect/autocrit/arcane-ward/heal-
charges), once-per-fight class/race/subclass flags, and accumulated
buff stat deltas. resumeTurnEngine restores them; commit writes them
back in place. Fixes turn-based bugs where Orc rage, Halfling Lucky
reroll, and the Assassin first-attack bonus re-fired every round and
Abjuration Arcane Ward did nothing.
Buff spells and buff-type consumables (ward/atk/def/crit/spore/reflect/
auto-crit) are now usable mid-fight: a flattened-delta model diffs the
reused applySpellBuff/ApplyConsumableMods math against a throwaway
combatant, folds the marginal effect into the session, and re-applies
the persistent stat deltas onto the rebuilt player each round. Pure-
utility spells diff to nothing and are refused before a slot is spent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Turn-based Elite/Boss fights gain !cast and !consume as player-turn
actions, so casters and item-users make decisions per round instead of
pre-queuing a single effect. The command handler validates and resolves
the spell/item into a pre-rolled turnActionEffect; the engine just
applies the HP deltas and flows on into the enemy turn.
Scoped to effects that resolve within the casting round: damage, heal,
and control spells, plus heal/flat-damage consumables. Buff and utility
spells and buff-type consumables are refused without spending the
resource — they need cross-round stat persistence, a later sub-phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A combat session abandoned past its 1h TTL is now resumed from persisted
mid-state and auto-played through the shared resolver to a real win or
loss, rather than flatly marked as a retreat. The bulk-UPDATE sweep is
replaced by listExpiredCombatSessions plus a reaper that locks the user,
auto-plays the fight, runs the normal close-out, and DMs the outcome.
markCombatSessionExpired remains the terminal fallback for sessions that
can't be reconstructed. Wired into eventTicker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes Elite/Boss rooms off the auto-resolve SimulateCombat path and onto
the persisted turn-based engine. !zone advance now stops at an Elite/Boss
doorway; the player engages with !fight, then resolves one full round per
!attack / !flee. A won CombatSession is the record that the room's combat
is done, so a fresh !zone advance clears the room and advances the graph.
- buildZoneCombatants: shared player/enemy Combatant builder extracted from
runZoneCombat; combatantsForSession rebuilds the pair from a session row.
- runCombatRound loops the phase state machine through a whole round;
finishCombatSession runs HP/XP/loot/kill/threat/mood close-out.
- getCombatSessionForEncounter lets the room resolver tell "already won"
apart from "not yet fought".
- !zone advance/enter/go blocked while a session is active.
- resolveBossRoom deleted (dead after the reroute).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
combat_session.go: CombatSession persistence layer mirroring the
dnd_expedition pattern — CRUD accessors, one-active-per-user enforcement,
and the timeout reaper (sweeps stale sessions to 'expired').
combat_turn_engine.go: the player_turn -> enemy_turn -> round_end -> over
state machine. advanceCombatSession seeds a deterministic per-(round,phase)
RNG, resolves one phase via the shared attack primitives, commits, and
persists. The deferred poison/status tick lands in round_end now that the
round-loop shape exists.
CombatStatuses persists only between-round monster-ability effects; the
reaper marks sessions 'expired' rather than auto-playing them — both gaps
depend on Combatant reconstruction, which lands with the command-wiring PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema-only: persistent per-fight session table so manual elite/boss
combat can resume or be auto-finished by the timeout reaper from exact
mid-state. State machine and accessors land in a later PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull effectiveAttackBonus, attackCritFloor, attackConnects, and the
post-hit player damage stack out of combat_engine.go into
combat_primitives.go so the upcoming turn-based engine resolves an
attack identically to auto-resolve. Behavior-preserving — the combat
characterization golden file is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits SimulateCombat into a thin wrapper over simulateCombatWithRNG, a
deterministic core that accepts an optional *rand.Rand. Production passes
nil (package-global rand, behaviorally identical); the new test seeds it
per scenario.
Adds TestCombatCharacterization: 23 curated scenarios x 5 seeds, with the
full event stream + result summary serialized to a stable text form and
diffed against a golden file. This locks current auto-resolve behavior
before the shared-primitives extraction for the turn-based engine — any
perturbation fails loudly and forces a deliberate -update + diff review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Split advDMResponseWindow into a 12h low-priority tier for passive
overnight-tolerant prompts (pet arrival/type/name); bump pet arrival
chance 15% -> 30%.
- Skip zone_run activity lines for runs that belong to an active
expedition — the expedition rollup is the source of truth.
- Loosen currentRoomCleared: Entry/Exploration/Trap allow harvest
unconditionally (risk is the combat-interrupt roll); Elite/Boss stay
gated on RoomsCleared.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional *rand.Rand to combatState so a fight can be driven by a
deterministic source. Auto-resolve leaves it nil and falls through to the
package global in the same call order — behaviorally identical. Threads
the source through calcDamage and rollWeaponDamage.
Groundwork for the turn-based elite/boss engine and its timeout reaper,
which seed the rng per combat_session to make fights resumable and
replayable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle of uncommitted working-tree edits across combat engine, expedition
cycle, flavor pools, and TwinBee/zone narration. Includes new files:
combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus
CHANGES_24H.md and REBALANCE_NOTES.md scratch notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The midnight idle reaper inspected only the overworld CombatActionsUsed/
HarvestActionsUsed counters, so a player on an active expedition was
flagged idle — streak halved and a nonsense overworld shame DM sent. The
08:00 morning DM had the same gap and stomped on the 06:00 expedition
briefing. Both paths now early-out when getActiveExpedition is non-nil
(advancing the streak in the midnight branch).
Includes a one-shot bootstrap that doubles CurrentStreak (capped at
BestStreak, +1 when room allows to recover the integer-divide odd half)
for characters still flagged StreakDecayed with an active expedition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wound carry-over went through three integer truncations across the
legacy combat scale (123 max) and the dndChar scale (78 max). 101/123
persisted as int(64.04)=64, then restored as int(100.92)=100 — losing
0.92 HP every fight.
Switching both sides to math.Round means most HPs round-trip exactly;
the residual error is bounded by ±1 HP from the inherent scale mismatch
(1 dndChar HP ≈ 1.58 legacy HP, so some pairs collide on the smaller
scale). Acceptable; the prior always-truncate behavior was directionally
biased against the player.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs reported back-to-back from a Rogue's run:
1. HP display reset between battles. End of fight 1: 101/123 → start of
fight 2: 100/100. applyDnDHPScaling was overwriting Stats.MaxHP with
the scaled wound value, so the display denominator dropped along with
the numerator. Wounded carry-over became invisible.
Fix: introduce CombatStats.StartHP. Combat engine reads it as the
entry-HP when set; MaxHP stays put. Display now reads "100/123" as
intended.
2. Auto-crit fired on a roll of 11 with no narrative tell. Rogue passive
AutoCritFirst was triggering correctly, but the renderer used the
generic crit pool, so the player saw "🎲 11 vs AC 10 → CRIT" with no
indication their *class* caused it.
Fix: tag the crit event with Desc="auto_crit" when the passive (not
the dice) caused it; new narrativePlayerAutoCrit pool calls out the
training/instinct/exploit theme.
Test bound for T5 dungeon death rate loosened from 0.02 to 0.01 — the
new Sudden Death phase from the previous commit shifted geared-T5
fights slightly toward player wins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Players reported the prior 6-round exhaustion timeout felt arbitrary
when both sides still had plenty of HP. Two fixes:
- Add a "Sudden Death" 4th phase (3-4 extra rounds) so most fights
resolve naturally before any timeout. Total combat length now caps
at ~10 rounds.
- When the timeout does fire, tiebreak by absolute HP instead of HP%.
The %-based logic was unintuitive — a player at 88/123 (71%) would
lose to a boss at 83/97 (86%) despite having more HP remaining.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Credits an arbitrary amount to any user, logged with the granting admin's
ID (and optional free-form note) for audit. Reuses Credit so balance
bootstrap and transaction history stay consistent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single "a lot" descriptor with a pool of 8 variants picked
via advPickFlavor (per-user dedup, last 5 indices avoided).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New optional plugin (FEATURE_SPACE_INVITER) that detects local homeserver
users who aren't in any Matrix Space and prompts the configured admin via
DM with yes/no/ignore. Persistent prompt log in space_inviter_prompts so
restarts and upgrades don't re-ping for users who already replied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the after-insurance revival cost from level × 50k to level × 5k
(before-insurance still 5×). On check-in, Nurse Joy now lets the player
attempt a CHA save vs DC 15:
- nat 1 → auto-fail, full price
- < 15 → fail, full price
- >= 15 → pass, bill halved
- nat 20 → bill fully waived (no debit, no community-pot cut)
Outcome is pinned on the pending interaction (Discounted/Waived) so the
TOCTOU recompute at confirm preserves the discount/waiver and can't be
re-rolled. Bill DM reordered: itemized → after-insurance (pre-haggle) →
🎲 roll line + Nurse Joy reaction → final amount with strikethrough on
the pre-haggle figure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final G9 step. The schema no longer creates current_room or
room_seq_json on fresh installs, and existing prod schemas drop them
via purgeLegacyZoneRunColumns() once the operator flips
GOGOBEE_BRANCHING_PURGE=1.
- CREATE TABLE dnd_zone_run loses both columns; comment updated to
point at G1's columnMigrations for the graph-mode columns.
- cleanup_g9.go mirrors the L5 idempotent-purge shape: ALTER TABLE
DROP COLUMN per legacy column, "no such column" treated as already
done, default-off.
- Wired into adventure Init alongside purgeLegacyAdvCharacterTable.
- Plan doc marks G9 complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two legacy columns are no longer needed at runtime. CurrentRoom is
derived from len(VisitedNodes)-1 on read (lockstep with what the
dual-write era stored), and RoomSeq is a transient in-memory field
populated only when a fresh run is started.
- INSERT, SELECT, and UPDATE statements drop the two columns. The
schema still carries them — they retire in G9c.
- scanZoneRun derives CurrentRoom and falls back to a linear-derived
CurrentNode only if a row predates the G4 dual-write deploy.
- markRoomCleared rewritten to walk the registered graph (first
outgoing edge / dead-end completion). Tests that use it as an
end-to-end run driver continue to pass.
- advanceZoneRunNode drops its current_room dual-write.
- adventure_activity_unified reads visited_nodes for the daily
"X/N rooms" progress fragment.
- Camp boss-room test pivots to setting current_node directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 9 zones have hand-authored graphs (G8a–G8i), so the POC gate has
served its purpose. Make graph mode the only runtime path:
- Drop branchingZonesEnabled() and the os import in zone_graph_nav.go.
- Inline the gate-on branches in zoneCmdMap, zoneCmdAdvance,
CurrentRoomType, startZoneRun, and the expedition map renderer.
- Keep the legacy linear functions (markRoomCleared, renderZoneMap,
generateRoomSequence) standing — their tests still exercise them and
they'll retire alongside current_room / room_seq_json in G9b.
- Strip gate test (TestBranchingZonesGate, TestCurrentRoomType_GateOff)
and t.Setenv calls from the surviving graph tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T4 zone, ninth (final) authored graph. Shape: 3-way regional fork
where each arm crosses a different region boundary, all converging at
R4 (deep_throne). First and only authored zone where every node
carries a non-empty RegionID matching dnd_expedition_region.go.
Three arms from fork1:
- R1→R2 drow_patrol → drow_captain (elite)
- R1→R3 psionic_corridor → mind_flayer (elite)
- R1→R1 deep_chasm (CON-gated harvest, stays in surface_tunnels)
…all converging at R4 throne_approach → boss.
Exercises G6 fireGraphRegionTransition end-to-end: edges crossing
region boundaries fire the transition hook (updates Expedition
.CurrentRegion, marks region visited, writes a transit log entry)
without burning supplies or advancing the day, since the graph is
the run state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T5 zone, eighth authored graph. Shape: three fork nodes in series —
binary, binary, ternary capstone. Player makes three separate fork
decisions in this zone, more than any other shipping zone, matching
the "things keep getting worse" thematic descent.
First zone with LockStatCheck CON (mind_corridor, DC 16, psychic
pressure flavor) — completes the full ability roster (STR/DEX/CON/
INT/WIS/CHA) across shipping zones by G8h. reality_seam secret
LootBias 3.0, the highest authored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T5 zone, seventh authored graph. Shape: long approach (kobold_warrens
→ drake_pens) → binary mid-fork that converges at the elite
(wyrmlings_nest) → 3-way capstone fork into the boss
(direct / CHA bargain / Perception-found hoard_pillar secret).
First zone combining two distinct fork stages where the first converges
diamond-style and the second spreads triple-wide. Secret carries
LootBias 2.5 — highest of any shipping zone, reserved for T5 — locked
in by TestDragonsLairGraph_LootBiasEscalation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T4 zone, sixth authored graph. Shape: woven forks — two first-stage
forks each lead to a second-stage fork; hag_circle is shared between
both first-stage paths while time_eddy (grove-only) and
illusion_garden (marsh-only, secret) are exclusive to one side.
Captures the Feywild theme of "the path you walked is not the path
you arrived on."
First authored zone with LockStatCheck CHA, completing the stat
roster (CHA joins STR/DEX/INT/WIS in shipping zones). Also the first
zone where fork1 has no LockNone option — both first-stage edges are
locked (CHA DC 14 vs. Perception DC 14), forcing the player to commit
to whichever check they're stronger at.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T3 zone, fifth authored graph. Shape: five-node linear preamble
(sealed_gate → forge_descent → cooling_river → magma_chamber elite)
followed by a single 3-way antechamber fork before the boss.
Bends the plan §G8 "two forks for T2+" guideline to a single late
fork-with-three-options because the gauntlet shape is the design
intent — Kharak Dûn is a one-way descent. Triple-option antechamber
(direct / DEX-checked catwalks / Perception-found forge_vault secret)
preserves agency at the commitment moment.
Map renders as a long horizontal stem with a 3-leaf fan at the right
edge — visually distinct from any earlier zone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T3 zone, fourth authored graph. Shape: two stacked 3-way forks
(great_hall, upper_hall). The player picks twice in a row from a
wide menu — distinct from the binary forks in G8a/b and the parallel
Y-trees in G8c.
Completes lock-kind coverage by G8d: this is the first authored use of
LockLevelMin (tower_observatory gated at L7). With Perception (DC 14
study, DC 15 hidden_oratory), StatCheck INT (library), and LockNone,
all four non-trivial lock kinds now appear in shipping zones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T2 zone, third authored graph. Shape: two-tier sequential forks where
each path is unique up to the boss room. Four committed leaves
(kuo_toa_pen / glyph_chamber / aboleth_thralls / coral_reliquary) all
terminate at the boss; no mid-path node has >1 incoming edge.
Differentiates from Crypt's diamond and Forest's asymmetric-diamond by
producing two parallel "Y" subtrees on the !zone map instead of a
converging branch. Exercises Perception + StatCheck (STR) + LockNone
in a single zone (covered by TestSunkenTempleGraph_LockKindCoverage).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T2 zone, second authored graph. Deliberately diverges from the diamond
shape so the !zone map renders distinctly:
- Asymmetric main fork: long branch (grove_descent → dryad_circle elite,
2 mid-nodes) vs. short branch (thorn_tunnel, 1 mid-node, Perception
DC 13 hint). Tests lock in the asymmetry.
- Pre-boss fork to a sapling_shrine secret behind LockStatCheck WIS
DC 14 (LootBias 2.0). First authored zone exercising stat_check —
Crypt's secret was Perception, so this validates the second lock kind
end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T1 single-fork shape with no secret — deliberately the simplest
authored topology, mirroring the Crypt of Valdris pattern minus the
secret tail. Serves as the baseline reference shape; G8b onward will
vary topology (stub branch, sequential forks, hub-and-spoke, gauntlet,
convergent triangle) so zones feel structurally distinct on the
!zone map.
Validator-checked at init via BuildGraph. Both branches reach boss;
the Perception (DC 12) side carries a player-facing hint per plan §G8
guideline ("locked paths should always have a hint").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The graph-mode map renderer iterated 0..maxX, emitting a blank slot for
columns whose only inhabitant was a hidden secret node. In Matrix code
blocks the dark background painted that gap as a phantom cell with no
status mark — read in playtest as "a box missing a dot." Collapse
columns with no visible nodes before laying out rows; secret columns
reappear once the player visits them.
Hand-authored zoneCryptValdrisGraph() (8 nodes incl. fork +
Perception DC 15 secret reliquary) self-registers at init. Two
surgical bridges so new runs traverse the authored graph when
GOGOBEE_BRANCHING_ZONES=1: startZoneRun seeds current_node from
g.Entry, and DungeonRun.CurrentRoomType resolves via the live
node's kind so divergent paths (secret_chamber) dispatch through
the right resolver. Gate off is bit-identical to pre-G7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Harvest tables (expedition + standalone) now keyed by node_id; legacy
room-idx entries auto-migrate via read-fallback + drop-on-save.
- Narration salts swapped from run.CurrentRoom to narrationCadence(run)
(= len(visited_nodes)-1) so flavor pickers survive G9 column drop.
- !zone map renders the graph (BFS by PosX/PosY) when the gate is on:
✓/▶/· status, ╳ for locked-only edges, secrets hidden until visited.
- Region-boundary hook in graph advance/!zone go updates expedition
CurrentRegion + visited list when from/to nodes differ — without
burning supplies or retiring runs (the graph IS the run state).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the graph types from G2/G3/G4 into the !zone advance flow.
Default off; with the gate on, room-clear branches on outgoing-edge
count instead of the linear current_room+1 walk.
zone_graph_nav.go — env gate, edge unlock evaluation
(LockNone/Perception/Key/LevelMin/RegionClear/StatCheck), pendingFork
JSON shape with encode/decode helpers, perception roll seeded
deterministically from (runID, from, to) so reload doesn't grant
retries, plus fork prompt rendering and the
recordRoomCleared/advanceZoneRunNode/completeRunAtNode persistence
trio.
dnd_zone_cmd_graph.go — advanceTransitionGraph drives the graph-mode
branch in zoneCmdAdvance: 0 outs → run completes (boss_defeated set
when current node IsBoss); 1 unlocked out → auto-advance and emit
the same teaser the legacy path uses; 2+ outs (or a single locked
out) → write pending fork to node_choices and render the menu. New
!zone go <n> / !zone choose <n> consumes the prompt, validates the
chosen edge is unlocked, advances, and emits the arrival teaser.
Old "go" alias for "enter" was unused in tests — repurposed.
dnd_zone_cmd.go — extracts formatNextRoomMessage so the legacy and
graph-mode paths share the post-advance teaser. Adds the new
subcommand to the dispatch switch and help text.
Tests: pure-logic coverage for unlock evaluation, perception
determinism, pendingFork roundtrip + decode-edge-cases, choice
resolution, fork prompt rendering with hint vs no-hint locked
options, edge ordering by (Weight, To), and the env gate. Existing
zone tests pass with and without the gate set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
G1 — schema. Adds zone_node + zone_edge tables and three columns to
dnd_zone_run (current_node, visited_nodes, node_choices). Linear
columns stay during the migration; G9 retires them.
G2 — types + validator. New internal/plugin/zone_graph.go defines
ZoneNode/ZoneEdge/ZoneGraph + ZoneNodeKind/ZoneEdgeLockKind. BuildGraph
enforces: exactly one entry, exactly one boss, boss reachable via BFS,
no orphan nodes, no self-loops without explicit opt-in. BuildLinearGraph
synthesizes a chain for legacy zones.
G3 — legacy compiler + dual-mode loader. compileLegacyZoneGraph turns
a ZoneDefinition into a representative linear graph (MaxRooms shape).
loadZoneGraph returns the registered graph if hand-authored (G7+),
else the legacy fallback. compileRunGraph mirrors a per-run RoomSeq
exactly for hot-swap derivations.
G4 — run-state dual-write. DungeonRun gains CurrentNode / VisitedNodes
/ NodeChoices. scanZoneRun reads them and hot-swaps current_node from
current_room when a row predates the migration (deriveLegacyNodeID
matches BuildLinearGraph's "<zone>.r<n>" scheme). startZoneRun and
markRoomCleared write both columns.
No behavior change yet — navigation surface (forks, locked edges,
!zone go) lands in G5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full implementation plan for replacing the linear room_seq_json model
with a directed-graph zone topology. Enables Slay-the-Spire-style
forks, perception-gated secret rooms, locked shortcuts, multiple
paths converging on a shared boss.
Plan covers:
- Schema (new zone_node + zone_edge tables; current_node /
visited_nodes / node_choices columns on dnd_zone_run)
- Go-side types (ZoneGraph, ZoneNode, ZoneEdge, lock kinds)
- Authoring model with worked example (Crypt of Valdris POC)
- Nine-phase rollout (G1 schema → G2 types → G3 legacy compiler →
G4 dual-write run state → G5 navigation behind env gate → G6
dependent surfaces → G7 POC zone → G8 mass migration → G9
retire linear model)
- Risk inventory + mitigations
- Surface inventory of files likely to change
- Open authoring questions with proposed defaults
Not yet implemented — handed off to a fresh session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two longstanding warts surfaced when the user inspected today's TwinBee
daily report:
1. Active players were rendering "Acted today — no log recorded." The
adventure_activity_log path only captures !rest now; harvest/zone/
expedition action logging was lost during Phase R/L migration. The
stale HasActedToday() check fired on fossil harvest_actions_used
counters that nothing modern increments.
2. Per-player headline still rendered legacy four-skill line ("Combat
Lv.X | Mining Lv.Y | Forage Lv.Z | Fishing Lv.W"). No D&D level,
race, class, or HP — discoverability gap for the layer that's now
canonical.
Fix both:
- adventure_activity_unified.go: new loadAdvDailyActivity(date) unions
dnd_zone_run (zone runs touched today) + dnd_expedition_log
(expedition activity rolled up per expedition) + legacy
adventure_activity_log (mostly !rest). One source of truth.
- adventure_render.go: AdvPlayerDaySummary gains HasDnDChar/DnDLevel/
DnDRace/DnDClass/HPCurrent/HPMax. New advPlayerHeadline helper
renders "Lv.N Race Class — HP cur/max" when a dnd_character row
exists, falls back to the legacy four-skill line plus an inline
"(no D&D sheet — run !setup)" nudge for accounts that haven't
completed setup yet. titleizeDnDToken("half_elf") → "Half-Elf".
- adventure_scheduler.go: postDailySummary swaps loadAdvLogsForDate
for loadAdvDailyActivity. IsResting now derives from "len(acts)==0"
rather than the fossil HasActedToday() counter check, so dead
harvest_actions_used bytes can't trip the active-player branch.
- proddb_daily_report_test.go: renders the report against a copy of
data/gogobee.db and prints it for visual confirmation. Verified
output for 2026-05-09 shows real zone-run outcomes (Withdrew from
Sunken Temple at room 1/6, etc.) instead of the previous "no log
recorded" placeholder.
go vet + go test ./... clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The L5 close-out (596b2b7) rewired loadAdvCharacter to read from
player_meta and simultaneously deleted the 13 backfill one-shots from
Init. That's only safe if every account already has a player_meta row
from prior dual-writes — fresh restores from old backups, dev
environments cloned from older snapshots, or a prod upgrade that
skipped the soak window all silently strand every character (loader
returns empty).
Adds bootstrapPlayerMetaFromLegacy: a single consolidated migrator that
walks adventure_characters, finds rows missing from player_meta, and
fans them out via the audited upsertAllPlayerMetaFromAdvChar helper.
Idempotent (re-runs touch zero rows once migrated; no-ops post-purge).
Wired into AdventurePlugin.Init before loadAllAdvCharacters.
proddb_simulation_test.go exercises the full bootstrap + fan-out +
overlay round-trip against a copy of data/gogobee.db with 11 subtests
covering every migrated subsystem (skills, arena, babysit, streak,
death/revive, NPC counters, pet incl. JSON-packed flags, house, save
idempotency, last_active advance, fresh createAdvCharacter).
dnd_proddb_integration_test.go also calls the bootstrap explicitly to
mirror prod startup ordering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
!expedition list was routing to zoneCmdList, which rendered "Zones
available at L%d" with `!zone enter <id>` CTAs and referenced an active
zone run for the footer — wrong system for an expedition caller. New
expeditionCmdList mirrors the zone-list shape but uses expedition CTAs
(`!expedition start <id>`) and surfaces the active expedition (day
count, `!expedition status` / `!expedition abandon`) via
getActiveExpedition. zoneCmdList stays for !zone list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit confirmed saveAdvCharacter's fan-out (upsertAllPlayerMetaFromAdvChar)
covers all 13 migrated subsystems, so the per-call-site upsertPlayerMetaXxx
calls retained "as defense" through the L4-L5 migration are redundant
post-L5h. Removed 16 such call sites across arena (2), scheduler (4),
hospital (1), consumables (2), rival (1), housing (11), and reordered
masterwork to drop a now-pointless explicit upsert before the load+save.
Refreshed nine stale doc comments referencing the legacy
adventure_characters table or "soak window / fallback" language that no
longer applies after the L5 close-out (commit 596b2b7). No behavior
change; go vet + go test ./... pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewires loadAdvCharacter / createAdvCharacter to source from player_meta,
drops every legacy-table fallback in the loadXxxState helpers, and adds a
GOGOBEE_LEGACY_PURGE=1 gate to drop the now-cold adventure_characters
table. Schema CREATE removed and column migrations tolerate the missing
table so the purge stays sticky across reboots.
§7.4 of the migration plan is reconciled to document that
player_meta.combat_level stays — combat_stats.go consumes it via the
overlay and is shared infrastructure, not legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reconcile the migration plan with what actually shipped, and clear the
three remaining CombatLevel reader sites so the §7 grep gates hold.
Plan reconciliation (gogobee_legacy_migration.md §7.4):
The original deletion list was wrong on two counts. (1) AdventureCharacter
struct survives as the loaded-view shape post-L5h overlay; only the
backing table drops at purge. (2) combat_engine.go / combat_bridge.go /
combat_stats.go are not legacy — they're the live combat engine the D&D
system layers on top of (applyDnDPlayerLayer/EquipmentLayer/etc. mutate
CombatStats before SimulateCombat). Verified via grep: 21+ files reference
these symbols across arena/dungeon/zone/expedition. §7.4 rewritten to
reflect this; no L6 combat-engine migration needed.
Reader fixes:
- babysitDailyCost reframed for D&D-level scale. Formula 100 + level*100
preserves the curve at every old-CL boundary given the 5:1 compression
in dndLevelFromCombatLevel (Level 4 ≈ old CL 20 = €500/day, Level 10 ≈
old CL 50 = €1100/day). Caller switched to dndLevelForUser.
- dnd_sheet.go drops the vestigial "Combat (legacy) %d" stat-block line.
- D&D onboarding retired. Post-L5g the welcome DM never fires (every
legacy player already has a D&D row), so dnd_onboarding.go +
dnd_onboarding_test.go are deleted, the 3 production caller invocations
(dnd_zone_combat, combat_bridge, dnd_combat::ensureCharForDnDCmd) and
the dnd_setup.go stub-creation branch removed. OnboardingSent column
kept for a separate cleanup pass. dnd_audit_fixes_test.go ported.
go vet ./... && go test ./... clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
saveAdvCharacter no longer writes the legacy adventure_characters row.
It's now a thin fan-out: bumps LastActiveAt and routes through
upsertAllPlayerMetaFromAdvChar, which calls the per-subsystem upserts
for display_name, hospital, arena, masterwork, rival, skills, babysit,
NPC, lifecycle, death, misc, pet, and house.
loadAdvCharacter calls applyPlayerMetaOverlay at the tail to re-source
every migrated subsystem from player_meta. The adventure_characters
SELECT still happens (for the user_id / equipment-table linkage and as
a fallback substrate) but its values are overwritten by the overlay.
This achieves the L4e in-place housing reader flip automatically —
adventure_housing.go's char.HouseFoo reads now see player_meta values
without per-site changes.
npcMidnightReset extended to write player_meta alongside the frozen
adventure_characters.
Per-call-site upsertPlayerMetaXxx duals are now redundant with the
saveAdvCharacter fan-out but kept as defense; cleanup deferred.
player_meta.combat_level column drop deferred — still read by babysit
cost / combat_stats / activities via the overlay; sequenced with the
final combat_engine teardown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
backfillDnDCharactersFromAdv walks adventure_characters rows that have no
dnd_character row and inserts an auto-migrated character (race/class
inferred from archetypes, Level seeded from dndLevelFromCombatLevel).
Idempotent via LEFT JOIN — pending-setup drafts and existing rows are
skipped. Wired into Init after the L5f backfill.
With every legacy player guaranteed a D&D row, the soak-window fallbacks
in dndLevelForUser, rivalLevelForUser, and hospitalCostsForUser are
retired (they now floor at level 1). dndLevelFromCombatLevel itself
stays — still used by autoBuildCharacter and the !setup seed paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three player_meta columns (title TEXT, treasures_locked INT,
crafts_succeeded INT). MiscState struct + load/upsert/backfill/
projection helpers. Dual-write rides saveAdvCharacter alongside the L5e
death-state hook — Title is dormant, the other two already mutate at
sites that save.
Tests: TestPlayerMetaMiscStateBackfill_Idempotent,
TestLoadMiscState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaMiscState_RoundTrip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight player_meta columns: alive (default 1), dead_until,
death_reprieve_last, last_pardon_used (DATETIME), last_death_date,
grudge_location, death_source, death_location (TEXT).
DeathState struct + load/upsert/backfill/projection helpers. Dual-write
strategy switches: instead of per-site upserts, the dual-write rides
saveAdvCharacter itself (after the existing display_name dual-write).
Death-state mutation surface spans ~50 save sites; the saveAdvCharacter-
internal hook catches every one without scatter.
Backfill idempotent (only fills rows where every death field is still
default). Tests cover backfill, fallback, round-trip, and the
saveAdvCharacter dual-write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ten player_meta columns: current_streak, best_streak, last_action_date,
streak_decayed, action_taken_today, holiday_action_taken,
combat_actions_used, harvest_actions_used, created_at, last_active_at.
LifecycleState struct with HasLifecycle() marker + load/upsert/backfill/
projection helpers. New resetAllPlayerMetaDailyActions parallels the
existing resetAllAdvDailyActions for the bulk midnight reset.
createAdvCharacter now seeds created_at/last_active_at (CURRENT_TIMESTAMP)
so player_meta rows are fully formed at creation.
Mutation surface turned out to be small: only `rest` writes
ActionTakenToday (combat/harvest counters are dormant in current code);
plus streak halve + streak update in scheduler. Dual-writes wired at all
four sites + the bulk reset.
Tests: TestPlayerMetaLifecycleStateBackfill_Idempotent,
TestLoadLifecycleState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaLifecycleState_RoundTrip,
TestResetAllPlayerMetaDailyActions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five player_meta columns (babysit_active, babysit_expires_at,
babysit_skill_focus, auto_babysit, auto_babysit_focus). BabysitState
struct with IsActive() marker mirrors SkillState/HouseState shape.
loadBabysitState / upsertPlayerMetaBabysitState /
backfillPlayerMetaBabysitState / babysitStateFromAdvChar helpers.
Dual-writes wired at handleBabysitStart, handleBabysitCancel,
checkBabysitExpiry. Backfill is idempotent — only fills inactive rows
whose legacy counterpart is active.
AutoBabysit / AutoBabysitFocus / BabysitSkillFocus are dormant in
current code but preserved for the schema migration.
Tests: TestPlayerMetaBabysitStateBackfill_Idempotent,
TestLoadBabysitState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaBabysitState_RoundTrip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight player_meta columns (combat_level, combat_xp, mining_skill,
mining_xp, foraging_skill, foraging_xp, fishing_skill, fishing_xp).
SkillState struct with HasSkills() marker mirrors PetState/HouseState
shape. loadSkillState / upsertPlayerMetaSkillState /
backfillPlayerMetaSkillState / skillStateFromAdvChar helpers.
Dual-writes wired at every mutation site: consumables craft XP (both
success and failure branches), events.go XP grant across all skills,
arena death + session-complete combat XP. Backfill is idempotent (only
fills rows where every skill column is still zero AND the legacy row
has any non-zero value).
CombatLevel/CombatXP are transitional — dropped at L5g after the DnD
mass-backfill retires the legacy CombatLevel-derived fallback.
Tests: TestPlayerMetaSkillStateBackfill_Idempotent,
TestLoadSkillState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaSkillState_RoundTrip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>