18 Commits

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

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

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

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

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

Three holes:

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

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

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

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

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

New: dnd_expedition_extract_sweep_test.go, 6 cases.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:14:59 -07:00
prosolis
d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00
prosolis
1f211564d9 Review fixes: party close-out, member soft-lock, supply race, elite loot, season crown
Five bugs found reviewing n1-restoration end to end.

beginCombatTurn settles any phase the engine owes before reading whose turn it
is. That settle can end the fight — and the old code then answered "you're not
in a fight" and returned. The terminal status was already persisted, so nothing
ever paid the party out: no XP, no loot, no death recorded, no run teardown. The
reaper cannot recover it either, because listExpiredCombatSessions filters on
status='active'. Close the fight out there, the way the !fight start path and
the reaper already do.

A party member was permanently soft-locked when their leader extracted and never
resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember
(what !expedition leave resolved through) saw only 'active'. So the member was
refused any new adventure by the guard and told "No active expedition" by the
command the guard points them at, with nothing sweeping stale rows and only the
leader able to clear one. Resolve the exit through the same lookup as the gate.

updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded
a member's packs onto a snapshot read before the coin debit, unlocked. Handlers
run one goroutine per event, so two invitees accepting genuinely interleave and
one member's packs vanish. advUserLock cannot help — it is keyed by sender, so
racing members take different mutexes. Add advExpeditionLock and re-read the pool
under it. Closes accept-vs-accept; the six other updateSupplies callers still
race and are written up separately.

runHarvestInterrupt picked an elite enemy and elite narration off a local `elite`
flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot
gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the
masterwork roll and took standard treasure weight — while the same elite fought
via !zone paid out correctly.

arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle
failed, and JobCompleted short-circuits every later run for that quarter, so a
transient SQLite BUSY lost the crown forever. Defer completion on failure; the
insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past
season's data is frozen, so the retry is safe.

Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into
fightRoster, route partyCasualtyLine through joinNames, fold four copies of the
expedition column projection into expeditionSelectCols, stop replyDM sending a
blank DM, and correct two doc comments describing a path that no longer exists.

Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most
notably that both turn-based close-outs skip grantCombatAchievements and
persistDnDPostCombatSubclass, which the auto-resolve paths run.
2026-07-10 07:20:14 -07:00
prosolis
3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00
prosolis
1928f75c19 N3/P6b: a party you can actually ask someone to join
!expedition invite / accept / decline / party / leave. The invitee buys
their own loadout and it pools -- a party is a shared burden, not a free
ride.

The plan said invites close "before the first walk". That is not a window,
it is a race: autoRunMinExpeditionAge leaves a fresh expedition alone for
thirty minutes and then the autopilot starts walking it, and the leader's
own !expedition run can beat it there. Thirty minutes is not enough to ask
a friend who is asleep.

So two changes to what the plan specified:

- The window is all of Day 1, not the first step. Supplies burn at the
  night rollover, so a companion who arrives three rooms in pays and
  receives exactly what one who arrived at the gate does.
- An unanswered invite pins the autopilot: loadExpeditionsForAutoRun skips
  any expedition somebody has been asked to join. The leader must not be
  dragged into a boss room while their friend reads the DM. Bounded by
  expeditionInviteTTL (2h) in the query itself, so a forgotten invite
  costs an afternoon, not the expedition.

New table expedition_invite. Absent == nobody was asked, which is true of
every expedition predating N3 -- nothing to backfill, same reading that
let expedition_party and roster_size ship without one.

Details worth keeping:

- Outstanding invites count against expeditionPartyMax. Otherwise a leader
  asks four people and three accept.
- Pooling raises Current *and* Max. supplyDepletion reads the ratio, so
  folding in only Current would read as the party suddenly starving.
- A member's supplies stay in the pool when they !leave. They were spent
  on the expedition, not lent to it; clawing them back would let someone
  starve the party on their way out.
- assertNotAdventuring guards expeditions and rosters but not bare zone
  runs, so accept checks getActiveZoneRun itself -- startExpedition does.
- A party is not a taxi: zoneOpenToLevel gates the invitee on the same
  tier rule !expedition start applies to the leader.
- releaseParty now clears invites too, or someone could accept onto a
  corpse.
- expeditionCmdStatus and the bare `!expedition` switched to
  activeExpeditionFor, and a member typing `!expedition go 2` is told the
  leader picks the path instead of falling through to `start` and being
  told "2" is an unknown zone.

Combat still seats one player -- handleFightCmd is P6c. go test ./...
green, golden byte-identical.
2026-07-09 22:30:11 -07:00
prosolis
0f144fa335 N3/P6a: let a member find the run they're standing in
Every ownership lookup in the adventure module keys on a user id, and a
party member owns no row: not the expedition, not the zone run. P4 gave
them activeExpeditionFor; this gives them activeZoneRunFor, and gives the
DM seams the audience they never had.

- activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is
  exactly getActiveZoneRun, side effects and all -- in particular the
  §4.3 idle reap, which force-extracts the wrapping expedition. A member
  must never re-enter it, or glancing at the map would end the leader's
  run. Pinned.

- expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all
  DM'd id.UserID(e.UserID) alone. They now loop the roster, which
  partyMemberIDs collapses to exactly the owner when there is none -- so
  a solo expedition sends the same bytes to the same user it always has.
  The briefing's body is expedition-scoped but its pet prefix is not:
  each member has their own pet and their own sheet, so the roll rides a
  per-reader decorator (the shape P5 settled on for combat narration).
  The digest's A6 event anchor rolls per member for the same reason.

- releaseParty on every terminal transition. A seated member is barred
  from adventuring elsewhere, so a roster outliving its expedition
  strands the party. Deliberately NOT on 'extracting': that is a 7-day
  resumable limbo and !resume must bring everyone back. The roster clears
  when the window lapses to 'failed', which routes through
  completeExpedition like the rest.

Rosters are still empty in production -- nothing seats a member yet -- so
every loop here has exactly one element and the whole change is a no-op
until P6b. Golden byte-identical, go test ./... green.
2026-07-09 22:18:40 -07:00
prosolis
5a6e395805 Long expeditions D4-a: autopilot DM bundling + EoD digest
Suppress per-tick auto-walk DMs in compact mode. tryAutoRun now only
surfaces for fork / death / run-complete / boss-safety camp / Night-camp
pitch. A Night-camp pitch flushes the day as an EoD digest (counts of
walk/harvest/interrupt plus threat/milestone/narrative bullets, built
from dnd_expedition_log) followed by the camp block. Each successful
background walk writes a `walk` log entry so the digest can count rooms
without persisting raw stream narration. maybeAutoCamp and
pitchBossSafetyCamp now return the autoCampDecision so callers can
branch on dec.Night.
2026-05-27 19:07:37 -07:00
prosolis
7115c536ef Long expeditions D2-a: autopilot camp scheduler
New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp
+ dwell-window lifecycle. Wired into tryAutoRun so the background ticker
pitches a rest camp on low HP and a base-camp waypoint on region-boss
clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick
breaks them and walks. CampState.AutoPitched separates auto- vs player-
pitched camp lifetimes so a player !camp stays sticky.

Day-rollover semantics unchanged — still UTC-anchored; D2-b moves
day++/burn/threat-drift onto the camp-pitch event.
2026-05-27 18:23:26 -07:00
prosolis
92b99a0399 Camp: heal on pitch; ambient cooldown 3h → 6h
!camp now applies long-rest effects (HP, spell slots, exhaustion,
threat -5, Underforge heat) immediately instead of waiting for the
06:00 UTC briefing. New CampState.RestApplied flag stops
processOvernightCamp from re-applying at briefing — it just strikes
the camp.

Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits
instead of ~3.
2026-05-27 16:57:57 -07:00
prosolis
41adfce9f1 Expedition autopilot Phase 4 + rogue sneak attack + TwinBee voice sweep
Tedium-removal pass driven by live play feedback. Three big threads:

Expedition autopilot Phase 4 — background auto-run + harvest-until-dry:
- New expeditionAutoRunTicker walks active expeditions every 15min
  (5min tick, per-expedition CAS on new last_autorun_at column). Skips
  combat sessions, briefing/recap quiet windows, expeditions <30min old.
- Walks up to 3 rooms/tick with compact narration; suppresses DMs when
  0 rooms walked or just hitting the per-tick cap (no "stretch complete"
  filler). Player only hears from the bot when a real decision is needed.
- Compact mode: one-line combat narration for trash/elite, auto-resolves
  elite doorways via the forward-sim engine (boss still pauses). Threaded
  via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom.
- Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8
  attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses.
- Ambient ticker: anti-repeat by Kind via new last_ambient_kind column
  (avoids two pack_rat DMs in a row when the pool only has 6 lines).
- !expedition go <n> now routes to the fork-choice handler when active +
  numeric; fork footer rewritten to suggest !expedition go instead of
  !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action
  hint by next room type and autopilot loop breaks via new nextRoomType
  field so "Room X/Y — Boss" doesn't double-print with contradictory
  hints (!zone advance vs !fight).
- runAutopilotWalk extracted from expeditionCmdRun so foreground and
  background share the loop body.

Rogue Sneak Attack actually exists now:
- Audit found the rogue's "Sneak Attack" passive was AutoCritFirst +
  5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this
  with a small flat buff; the class's defining mechanic never matched
  its tooltip or 5e identity.
- Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in
  combat_primitives.go (same lane as DivineStrikePerHit). Scales with
  level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20.
- AutoCritFirst + 5% rider retained as bonuses on top of working sneak
  attack — preserves the opener-burst feel.
- L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes
  from "16 rounds to kill, die in 8" to winnable.

TwinBee voice sweep (Phase B3):
- ~530 line changes across 24 files replacing third-person "TwinBee
  notes/files/tracks/respects/..." constructions with first-person
  inside flavor string literals. Code identifiers (TwinBeeLine,
  twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names
  (TwinBee's Bell), achievements, and game-title references in
  fun.go (Konami TwinBee ship) left intact.
- Catches a real bug: Valdris fight rendered "TwinBee marks the
  Legendary Resistance" mid-combat in third person, contradicting
  the Phase B2 convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:40:17 -07:00
prosolis
dfa7beeb96 Adv 2.0 D&D polish: GM→DM rename, streamed zone combat, end-to-end scenario test
GM→DM rename across docs and code (GMNarrationType→DMNarrationType,
GMState→DMState, narration constants, comments) so the system reads as
"Dungeon Master" everywhere. Player-visible "GM mood" wording stays
where it appears in flavor.

Streamed zone/expedition combat: zone advance now stages patrol →
patrol play-by-play → patrol resolution → room intro → room play-by-play
→ final outcome through sendZoneCombatMessages with 2–3s pacing
(arena keeps its 5–8s window). Combat narrative lines pick up a compact
d20-vs-AC roll annotation for hit/crit/miss/block events.

Combat outcome polish: dndHPSnapshot lets narration show sheet HP
rather than legacy combat-engine HP, and markAdventureDead clears the
zombie state where hp_current was 0 but the legacy alive flag stayed
true after a D&D-layer KO.

Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite
covering the new layer, and adv2_scenario_test.go — a full
zone-run + expedition + harvest playthrough against a copy of the prod
DB asserting persisted state end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
45863bd5f3 Adv 2.0 D&D Phase R7: post-completion audit hardening
High-priority fixes from the multi-agent audit of Adventure 2.0:

- Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe
  getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a
  placeholder instead of panicking.
- Briefing/recap idempotency: deliverBriefing/deliverRecap now claim
  the rollover via a conditional UPDATE. Double-fires from clock skew
  or restart become no-ops; supply burn and day++ no longer reapply.
- Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all
  11 background tickers now select on stopCh in addition to ticker.C.
- Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation
  so sub-hour gaps decay correctly.
- 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and
  abandons stale runs whose LastActionAt is over 24h old.
- Respec / auto-migrate orphan cleanup: !respec and the auto-migrated
  draft wipe now abandon active zone runs and expeditions before
  deleting the dnd_character row.
- Phase R combat-link: applyBossDefeatThreat now wired from
  resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds
  +5/+8 from non-boss/elite kills (§8.1).
- Starvation → forced extraction: briefing-time check forces extract
  with §10.2 coin tax when supplies hit zero.
- GMNat20/Nat1 narration wired into resolveCombatRoom and
  resolveBossRoom (nat-20 takes precedence over nat-1).
- Treasure-undo race: LoadAndDelete on both timer-fire and `undo`
  paths so only one side wins.
- Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6
  of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike)
  documented inline as needing ally/reaction mechanics the engine
  doesn't model.
- Threat-70 warning: tracked in RegionState["siege_warning_fired"]
  so a drop-and-recross doesn't re-fire the beat.
- region_state JSON decode error now logged via slog.Warn instead
  of silently discarded.

Failing TestProdDB_DnDLayer fixed via option (a): track migrated
characters and only run round-trip / idempotency assertions on those,
skipping pre-existing prod-DB rows accumulated from live bot use.

New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon,
briefing double-fire idempotency, threat-70 warning idempotency,
multi-region extract→resume state preservation, and starvation
forced-extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
83cdf07374 Adv 2.0 D&D Phase R R2: Harvest nodes & per-room registry
Per-zone resource registry (§3, all 10 zones) and full per-room harvest
layer wired into expeditions: !forage / !mine / !scavenge / !essence /
!commune / !resources. Auto-spawns a DungeonRun per region on
!expedition start, swaps it on !region travel, retires on
abandon/extract. Long rest at camp replenishes nodes across every
room and region. Reuses existing flavor.Harvest* / RichYield /
NodeDepleted / per-zone Harvest<Zone> pools.

Also clears two pre-existing test failures introduced by the E6c
flavor expansion (briefing substring list + combat-lift trial count).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
d179ca27a7 Adv 2.0 D&D Phase 12 E5: Extraction & Resume
Voluntary !extract burns 1 day, flips status to 'extracting', keeps
all rewards, resumable for 7 real days. Forced extraction (Abyss
collapse, future HP-0/supply-0 hooks) flips to 'abandoned' and applies
the §10.2 20% coin tax via the cycle layer's euro handle. !resume
within the 7-day window repurchases supplies and restores threat &
temporal stacks as-is; expired rows demote to failed.

getActiveExpedition / briefing / recap loaders narrow to status='active'
so 'extracting' rows don't get phantom day rollovers. Reuses
flavor.ExtractionVoluntary, ExtractionForced, ExpeditionResume from
twinbee_expedition_flavor.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
c2bed4282d Adv 2.0 D&D Phase 12 E4b: region progression hooks + status display
Multi-region zones now default CurrentRegion to the entry region at
startExpedition; that region is also written to regions_visited so
the visited/cleared distinction is meaningful from Day 1.

MarkRegionVisited / MarkRegionBossDefeated expose the combat-link
hook surface. MarkRegionBossDefeated also flips Expedition.BossDefeated
when the cleared region's IsZoneBoss is set, since the zone-boss kill
is the same event from §7's POV (suppresses temporal accumulation,
kills further awareness pulses, etc.). setCurrentRegion is the
companion writer used by E4c's travel command.

!expedition status now shows the region line ("🗺 Region: Drow
Outpost (2/4)") with a ✓ marker when the region boss is down.
2026-05-09 14:25:22 -07:00
prosolis
044baddcce Adv 2.0 D&D Phase 12 E3c: Underforge heat accumulation
§7.3 heat stacks: +1/day in the Underforge (cap 10), persisted via
new updateTemporalStack helper. Band thresholds:
  • 1–3 ambient (no narration)
  • 4–6 warning band (combat fire damage applies, narration logged)
  • 7–9 supply band (forces harsh-conditions burn = +50%, narration)
  • 10 critical (exhaustion narration, log entry)

Fortified/base camp long rest in the Underforge drops heat by 2
(updates briefing's "Fortified rest" summary line), per §7.3.

Replaces the placeholder `TemporalStack > 0 → harsh` predicate in
deliverBriefing with zoneTemporalHarsh(), which is now per-zone aware
— Heat 1–3 in the Underforge is correctly flavor-only and no longer
prematurely doubles supply burn from Day 1.

Combat-side knobs (+1d4 fire round-start damage at heat 4–6, dis CON
saves at 7–9, -2 to all rolls at 10) are exposed via heat band but
not yet applied — combat-link phase reads heat band off the active
expedition.

Reuses flavor.UnderforgHeapWarning / UnderforgHeapCritical per
feedback_reuse_existing_flavor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00
prosolis
9608ce554a Adv 2.0 D&D Phase 12 E1a: Expedition data model + persistence
Lays the multi-day expedition substrate per gogobee_expedition_system.md
§4.4/§5.3/§8.4/§9. Schema dnd_expedition + dnd_expedition_log added in
db.go. Expedition struct, ExpeditionSupplies, CampState, ThreatEvent
shipped in dnd_expedition.go with start/get/scan/abandon/complete,
updateSupplies, updateCamp, applyThreatDelta (clamped + sticky siege),
advanceExpeditionDay, append/recent log helpers. Round-trip + concurrent
rejection + threat clamp/siege + log ordering covered.

E1b (supply procurement) and E1c (commands) wire on top in subsequent
commits; day cycle (E1d) and camp commands (E1e) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:22 -07:00