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