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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user