From 04aa887d18e4e4544ea3667b0b6caf92b6e84700 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 15 May 2026 10:01:07 -0700 Subject: [PATCH] Phase 2a (lever): expedition retreat semantics split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/dnd_expedition_combat.go | 64 +++++++++++++++------ internal/plugin/dnd_zone_cmd.go | 6 ++ internal/plugin/expedition_balance.go | 22 ++++++- internal/plugin/expedition_balance_test.go | 67 ++++++++++++++++++++++ 4 files changed, 141 insertions(+), 18 deletions(-) diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index 28d5bb0..831969f 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -30,6 +30,23 @@ import ( "maunium.net/go/mautrix/id" ) +// retreatThreatBump is the threat penalty applied when the player times +// out of a combat (PlayerEndHP > 0 but PlayerWon == false). Retreats +// represent the player breaking off wounded — the run continues, but the +// zone's awareness ratchets up. Tuned alongside the expedition-difficulty +// pass (see gogobee_expedition_difficulty.md): low enough not to compound +// brutally with chained retreats, high enough that 3–4 retreats walks the +// threat clock toward Stirring. +// +// History: pre-Phase-2 the engine's timeout-loss path called +// abandonZoneRun + retireAllRegionRuns, ending the expedition outright +// despite the engine's contract ("Timeout = retreat, not lethal blow"). +// That made any single fight loss across a 14-day expedition an +// auto-fail, which the sim harness exposed as uniform-0% completion +// across every tier. Splitting the retreat path here was Phase 2's +// first lever. +const retreatThreatBump = 5 + // ── Combat Interrupt (§4.2) ───────────────────────────────────────────────── // CombatInterruptKind is the bucket the d20+tier roll lands in. @@ -146,21 +163,27 @@ func (p *AdventurePlugin) runHarvestInterrupt( } if !result.PlayerWon { + if result.TimedOut { + // Retreat: fighter broke off wounded but alive. The engine's + // contract (combat_engine.go: "Timeout = retreat, not lethal + // blow") means HP stays where the engine left it and we keep + // the run going. The harvest slot is forfeit (no kill, no + // loot) and threat ticks up. + _ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat") + b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)", + monster.Name, retreatThreatBump)) + return b.String(), false + } + // True death. _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) - if !result.TimedOut { - markAdventureDead(userID, "expedition", zone.Display) - } + markAdventureDead(userID, "expedition", zone.Display) if line := flavor.Pick(flavor.PlayerDeath); line != "" { b.WriteString(line) b.WriteString("\n") } - if result.TimedOut { - b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You retreat from the expedition, wounded but alive.", monster.Name)) - } else { - b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) - } + b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) return b.String(), true } @@ -390,21 +413,30 @@ func (p *AdventurePlugin) tryPatrolEncounter( var ob strings.Builder if !result.PlayerWon { + if result.TimedOut { + // Retreat — see retreatThreatBump doc. Run continues; threat + // ticks; the patrol's awareness lingers as a soft penalty + // instead of an auto-fail. + _ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat") + ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)", + retreatThreatBump)) + if rollLine := dndRollSummaryLine(result); rollLine != "" { + ob.WriteString("\n") + ob.WriteString(rollLine) + } + outcome = ob.String() + ended = false + return + } _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) - if !result.TimedOut { - markAdventureDead(userID, "patrol", zone.Display) - } + markAdventureDead(userID, "patrol", zone.Display) if line := flavor.Pick(flavor.PlayerDeath); line != "" { ob.WriteString(line) ob.WriteString("\n") } - if result.TimedOut { - ob.WriteString("⏳ The patrol drags on. You break off and retreat, wounded but alive.") - } else { - ob.WriteString("💀 The patrol takes you down. Run ended.") - } + ob.WriteString("💀 The patrol takes you down. Run ended.") if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 8898e00..575ca0c 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -755,6 +755,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z } } if !result.PlayerWon { + // resolveCombatRoom gates room progression — a retreat here has + // nowhere to go (the elite/room is still blocking the way), so + // any loss ends the run. The retreat-continues semantic only + // fits the non-gating expedition paths (runHarvestInterrupt, + // tryPatrolEncounter); see retreatThreatBump in + // dnd_expedition_combat.go. _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) // Timeout loss = retreat; player took wounds but isn't actually diff --git a/internal/plugin/expedition_balance.go b/internal/plugin/expedition_balance.go index 4930c96..1073d60 100644 --- a/internal/plugin/expedition_balance.go +++ b/internal/plugin/expedition_balance.go @@ -269,6 +269,22 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { res := h.runHarnessFight(zone, kind == InterruptElite) h.encounters++ if !res.PlayerWon { + if res.TimedOut { + // Retreat — mirrors the live retreat semantics in + // dnd_expedition_combat.go (Phase 2). Run continues + // with carryover HP and a threat bump; the harvest + // slot's loot is just forfeit. + h.exp.ThreatLevel += retreatThreatBump + if h.exp.ThreatLevel > 100 { + h.exp.ThreatLevel = 100 + } + h.char.HPCurrent = res.PlayerEndHP + if h.traceFight != nil { + h.traceFight(fmt.Sprintf("retreat day=%d hp=%d threat=%d", + h.exp.CurrentDay, h.char.HPCurrent, h.exp.ThreatLevel)) + } + continue + } return h.terminate("died_combat", false, true, false) } h.char.HPCurrent = res.PlayerEndHP @@ -286,8 +302,10 @@ func (h *expeditionHarness) advanceExpeditionOneDay() expeditionTrialResult { } switch nc.Outcome { case NightOutcomeStandard, NightOutcomeElite, NightOutcomeAmbush: - // Live path defers these to !advance; the harness resolves - // them inline so the night threat actually pressures the run. + // Live path defers these to !advance, where they resolve through + // resolveCombatRoom — which ends the run on any loss because + // the next-room path is gated on a win. Harness mirrors: a + // failed night encounter terminates the trial. res := h.runHarnessFight(zone, nc.Outcome == NightOutcomeElite || nc.Outcome == NightOutcomeAmbush) h.encounters++ diff --git a/internal/plugin/expedition_balance_test.go b/internal/plugin/expedition_balance_test.go index f144da6..9beb789 100644 --- a/internal/plugin/expedition_balance_test.go +++ b/internal/plugin/expedition_balance_test.go @@ -428,6 +428,73 @@ func TestExpeditionBalance_Phase2_LethalityProbe(t *testing.T) { } } +// TestExpeditionBalance_Phase2_TierLethality is the tier-walking +// companion to the T1-only lethality probe. Phase 1's matrix shows +// uniform 0% across every tier at the default rolls=4 cadence, which +// the T1/rolls=1 probe alone can't explain (its bimodal-warchief +// finding wouldn't show at T5 vs an L17 fighter). This probe traces +// every fight at the matrix cadence across one zone per tier so we +// can read whether the deaths are elite-driven, standard-fight +// chained, or something the harness combat fold itself is doing. +// +// Diagnostic-only — no assertions. +func TestExpeditionBalance_Phase2_TierLethality(t *testing.T) { + if testing.Short() { + t.Skip("phase 2 tier-lethality probe writes verbose log; -short skips it") + } + + const trialsPerTier = 3 + const baseSeed uint64 = 0x7E11A1 + cells := []struct { + zone ZoneID + tier ZoneTier + }{ + {ZoneGoblinWarrens, ZoneTierBeginner}, + {ZoneForestShadows, ZoneTierApprentice}, + {ZoneUnderforge, ZoneTierJourneyman}, + {ZoneUnderdark, ZoneTierVeteran}, + {ZoneDragonsLair, ZoneTierLegendary}, + } + for _, cell := range cells { + level := phase1TierCenterline[cell.tier] + profile := expeditionBalanceProfile{ + ZoneID: cell.zone, + Class: ClassFighter, + Level: level, + Supplies: makeSupplies(cell.tier, SupplyPurchase{StandardPacks: 3}), + CampType: CampTypeStandard, + HarvestRollsPerDay: 4, // matrix default, not the sparse probe + } + t.Logf("═══ %s T%d L%d Fighter rolls=4 ═══", cell.zone, cell.tier, level) + for trial := 0; trial < trialsPerTier; trial++ { + exp := newHarnessExpedition(profile) + char := buildHarnessCharacter(classBalanceProfile{ + Class: profile.Class, + Level: profile.Level, + }) + t.Logf("─── %s trial %d: hp_max=%d ───", cell.zone, trial, char.HPMax) + h := &expeditionHarness{ + exp: exp, + char: char, + rng: newHarnessRNG(baseSeed + uint64(trial) + uint64(cell.tier)*101), + rollsPerDay: profile.HarvestRollsPerDay, + traceFight: func(line string) { + t.Logf(" %s", line) + }, + } + for { + res := h.advanceExpeditionOneDay() + if res.EndedReason != "" { + t.Logf("END %s trial %d: reason=%s days=%d threat=%d encs=%d hp_left_pct=%.1f%%", + cell.zone, trial, res.EndedReason, res.DaysElapsed, res.ThreatAtEnd, + res.CombatEncounters, res.HPRemainingPct*100) + break + } + } + } + } +} + // joinZones is a tiny helper kept local to the test file so the // per-tier log line reads in one logical chunk without pulling in // strings.Join's import for production code.