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>
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>
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>