mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user