Phase 2a (lever): expedition retreat semantics split

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>
This commit is contained in:
prosolis
2026-05-15 10:01:07 -07:00
parent 0f09a421bc
commit 04aa887d18
4 changed files with 141 additions and 18 deletions

View File

@@ -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 34 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)
}
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))
}
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)
}
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.")
}
if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n")
ob.WriteString(rollLine)

View File

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

View File

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

View File

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