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.