4 Commits

Author SHA1 Message Date
prosolis
4934383a9a J3 D8-f #2: T4 difficulty lift (leaders-define-band) + feywild fork1 soft-lock fix
T4 monster tuning so martial leaders land in the 60-75% band (underdark
59/80, feywild 65/84 at L10/L12, n=50); casters trail (class-side gap that
monster tuning provably can't compress -- Pass 1 showed casters pinned at 0%
while martials moved). T5 deferred (walls everyone at its L12 floor; needs an
L15-16 corpus).

- dnd_bestiary.go: underdark elite/boss HP+AC up + caster-lethal proc cuts
  (mind_flayer/drow_mage/roper); feywild HP+AC up.
- bestiary_srd.go: feywild multiattack profiles (fomorian/night_hag/green_hag)
  + Thornmother 2->3 lashes -- HP/AC alone didn't move feywild's low-damage
  roster; multiattack is what pulls facerolling martials into band.
- zone_graph_feywild_crossing.go: free fork1's marsh edge. fork1 was the only
  fork in the game with every exit skill-locked (CHA+Perception, no LockNone)
  and deterministic no-retry rolls -- a prod SOFT-LOCK stranding ~60% of
  players (low CHA+WIS). Kept grove's CHA bargain as a bonus route.
- expedition_sim.go: firstUnlockedForkChoice -- sim fork policy picks the
  first unlocked option instead of blind 'go 1' (which looped forever on
  locked forks); halts fork_all_locked if none.
- tests: graph-wide TestZoneGraphs_NoSoftLockedFork + fork1 free-path guard.

Writeup: sim_results/d8f_findings.md
2026-05-28 19:02:58 -07:00
prosolis
a46b773750 J3 D8-f #1: route prod autopilot boss/elite through the turn engine
Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.

- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
  methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
  sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
  handlers route their DMs through it so the background autopilot can drive
  the real !fight/!attack engine without spamming a DM per round (the EoD
  digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
  true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
  rooms, but boss AND elite now face the player's full kit against the
  enemy's full multiattack. Loss surfaces as stopEnded (run already
  force-extracted by finishCombatSession).

Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
2026-05-28 15:30:48 -07:00
prosolis
b80de43db1 J3 D8-e: GOGOBEE_SIM_INLINE_BOSS toggle for engine A/B
Diagnostic env toggle (off by default) routing the sim's boss/elite
doorways through the inline SimulateCombat path instead of the turn
engine, for A/B-ing the martial T4/T5 'regression'. D8-e confirmed the
gap is honest multiattack math (inline swings the enemy once/round; the
turn engine loops the full SRD profile) and that prod autopilot is
secretly easier than manual !fight. Toggle left in for the D8-f parity
work.
2026-05-28 15:08:16 -07:00
prosolis
81dda51907 J3 D8-d-fix: caster AC floor + HP ×1.25
computeAC: lift caster floors (cleric/druid 3→5, bard/warlock 1→3,
mage/sorcerer 0→2). Ranger 3 / rogue 1 / fighter+paladin 6 unchanged.

computeMaxHP: new casterHPMult(class) = 1.25 for the same caster set,
applied multiplicatively on top of phase5BHPMult. Martials unchanged.
New caster L10 HPMax: mage/sorc ~98 (was 78), bard/cleric/druid/warlock
~118 (was 95); martial L10 ~141 unchanged.

bootstrap_caster_hp.go: new bootstrapCasterHPRefresh (job key
caster_hp_refresh_v1) refreshes existing rows once at startup; mirrors
bootstrapPhase5BHPRefresh — only raises HPMax, preserves absolute
wound. Wired in adventure.go.

Measured on n=2500 d8dfix2 corpus (gitignored sim_results) vs d8c:
- T3 manor caster lift confirmed — bard +13 (43→56), druid +8 (88→96),
  warlock +8, sorcerer +5, mage +5, cleric +2. Martials flat.
- Macro deltas: bard +2.6, warlock +2.0, druid +1.4, mage/sorcerer +1.0,
  cleric -0.2. n=500 1σ ≈ 2.2pp — bard/warlock cleanly past noise.
- T4 underdark wall still holds (every caster ~0%). Lift directionally
  correct but undersized for T4 atk-bonus + multiattack — separate
  follow-up.
- T5 dragons_lair universal wall unchanged (martials TPK too).

Diagnostic context: project_d8d_diagnostic. Cleric flat manor likely
the concentration AOE re-tick engine gap (filed for D8-e+).
2026-05-28 09:35:04 -07:00
14 changed files with 453 additions and 124 deletions

View File

@@ -220,6 +220,10 @@ func (p *AdventurePlugin) Init() error {
// migration walks dnd_character once at startup; idempotent via // migration walks dnd_character once at startup; idempotent via
// JobCompleted gate. // JobCompleted gate.
bootstrapPhase5BHPRefresh() bootstrapPhase5BHPRefresh()
// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
// existing caster rows once at startup so the lift reaches live
// players without waiting for level-up.
bootstrapCasterHPRefresh()
// Phase R1 orphan-archive used to run here on every Init, but it // Phase R1 orphan-archive used to run here on every Init, but it
// over-archived: it treats any active dnd_zone_run row not linked to // over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which // an active expedition as a legacy `!adventure dungeon` orphan, which

View File

@@ -83,9 +83,10 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Scourge", AttackBonus: 9, Damage: 13}, {Name: "Scourge", AttackBonus: 9, Damage: 13},
{Name: "Scourge", AttackBonus: 9, Damage: 12}, {Name: "Scourge", AttackBonus: 9, Damage: 12},
}}, }},
"boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23 "boss_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band)
{Name: "Thorned Lash", AttackBonus: 8, Damage: 12}, {Name: "Thorned Lash", AttackBonus: 9, Damage: 13},
{Name: "Thorned Lash", AttackBonus: 8, Damage: 11}, {Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 11},
}}, }},
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49 "boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
{Name: "Bite", AttackBonus: 11, Damage: 19}, {Name: "Bite", AttackBonus: 11, Damage: 19},
@@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6}, {Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
{Name: "Bite", AttackBonus: 6, Damage: 4}, {Name: "Bite", AttackBonus: 6, Damage: 4},
}}, }},
// ── Feywild elites (D8-f #2) ─────────────────────────────────────────
// Feywild martials facerolled at 97100% even after an HP/AC raise: the
// roster was single-attack and low-damage, so tankier monsters just took
// longer to kill. Multiattack is the lever that actually pulls leaders
// into band (mirrors underdark's drow_elite). Casters trail (by design).
"fomorian": {Attacks: []SRDAttack{ // Attack 13 → ~21 (2 big fists)
{Name: "Greatclub", AttackBonus: 9, Damage: 11},
{Name: "Greatclub", AttackBonus: 9, Damage: 10},
}},
"night_hag": {Attacks: []SRDAttack{ // Attack 8 → ~12 (claw flurry)
{Name: "Claws", AttackBonus: 7, Damage: 6},
{Name: "Claws", AttackBonus: 7, Damage: 6},
}},
"green_hag": {Attacks: []SRDAttack{ // Attack 6 → ~10 (claw flurry)
{Name: "Claws", AttackBonus: 6, Damage: 5},
{Name: "Claws", AttackBonus: 6, Damage: 5},
}},
} }
// enemyAttackProfile returns the attack rolls a creature makes on its turn. // enemyAttackProfile returns the attack rolls a creature makes on its turn.

View File

@@ -0,0 +1,84 @@
package plugin
import (
"log/slog"
"gogobee/internal/db"
)
// bootstrapCasterHPRefresh recomputes hp_max for caster characters after
// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped.
// Without this, existing caster rows keep their pre-lift hp_max until
// the next computeMaxHP recall (level-up / reset). Mirrors
// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the
// absolute wound (delta added to hp_current, capped at new max). Run
// once per startup, idempotent via db.JobCompleted.
func bootstrapCasterHPRefresh() {
const jobName = "caster_hp_refresh_v1"
if db.JobCompleted(jobName, "once") {
return
}
d := db.Get()
rows, err := d.Query(`
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
FROM dnd_character WHERE dnd_level > 0`)
if err != nil {
slog.Error("caster hp refresh: enumerate failed", "err", err)
return
}
defer rows.Close()
type row struct {
userID string
class DnDClass
conScore int
level int
hpMax int
hpCurrent int
}
var batch []row
for rows.Next() {
var r row
var classStr string
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
slog.Warn("caster hp refresh: scan failed", "err", err)
continue
}
r.class = DnDClass(classStr)
batch = append(batch, r)
}
refreshed := 0
for _, r := range batch {
if casterHPMult(r.class) == 1.0 {
continue
}
conMod := abilityModifier(r.conScore)
newMax := computeMaxHP(r.class, conMod, r.level)
if newMax <= r.hpMax {
continue
}
delta := newMax - r.hpMax
newCurrent := r.hpCurrent + delta
if newCurrent > newMax {
newCurrent = newMax
}
if newCurrent < 1 {
newCurrent = 1
}
if _, err := d.Exec(`UPDATE dnd_character
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
newMax, newCurrent, r.userID); err != nil {
slog.Warn("caster hp refresh: update failed", "user", r.userID, "err", err)
continue
}
refreshed++
}
db.MarkJobCompleted(jobName, "once")
if refreshed > 0 {
slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed)
}
}

View File

@@ -27,6 +27,19 @@ func encounterIDForRoom(roomIdx int) string {
return fmt.Sprintf("room%d", roomIdx) return fmt.Sprintf("room%d", roomIdx)
} }
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
// turn-engine combat commands route all their DMs through here so the
// background autopilot can drive a boss/elite fight on the real engine
// (long-expedition D8-f) without spamming the player a DM per round — the
// state mutations (HP, XP, threat, run-clear) still happen; only the
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
if ctx.Silent {
return nil
}
return p.SendDM(ctx.Sender, text)
}
// ── !fight ────────────────────────────────────────────────────────────────── // ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
} }
if run == nil { if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.") return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` first.")
} }
roomType := run.CurrentRoomType() roomType := run.CurrentRoomType()
if roomType != RoomElite && roomType != RoomBoss { if roomType != RoomElite && roomType != RoomBoss {
return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.") return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
} }
encID := encounterIDForRoom(run.CurrentRoom) encID := encounterIDForRoom(run.CurrentRoom)
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil { if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
switch existing.Status { switch existing.Status {
case CombatStatusActive: case CombatStatusActive:
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.") return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.")
case CombatStatusWon: case CombatStatusWon:
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender)) return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
default: default:
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.") return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
} }
} }
@@ -68,26 +81,26 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true) monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
} }
if !ok { if !ok {
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_") return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
} }
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood) player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error())
} }
playerHP, playerMax := dndHPSnapshot(ctx.Sender) playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 { if playerHP <= 0 {
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.") return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
} }
enemyHP := enemy.Stats.MaxHP enemyHP := enemy.Stats.MaxHP
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP) sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
if err != nil { if err != nil {
if err == ErrCombatSessionAlreadyActive { if err == ErrCombatSessionAlreadyActive {
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.") return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
} }
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
} }
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto // Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
@@ -118,7 +131,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess)) b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String()) return p.replyDM(ctx, b.String())
} }
// ── !attack / !flee ───────────────────────────────────────────────────────── // ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -138,22 +151,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if sess == nil {
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.") return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
} }
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
events, err := runCombatRound(sess, &player, &enemy, action) events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }
// renderRoundResult turns a resolved round into the player-facing block: the // renderRoundResult turns a resolved round into the player-facing block: the
@@ -393,35 +406,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if sess == nil {
// Race: the fight closed between the route check and the lock. // Race: the fight closed between the route check and the lock.
return p.SendDM(ctx.Sender, "You're not in a fight anymore.") return p.replyDM(ctx, "You're not in a fight anymore.")
} }
advChar, _ := loadAdvCharacter(ctx.Sender) advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar) c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil { if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.") return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
} }
if !isSpellcaster(c) { if !isSpellcaster(c) {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))) "%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
} }
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args)) spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" { if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg) return p.replyDM(ctx, errMsg)
} }
if spell.Effect == EffectReaction { if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)) "%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
} }
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
var eff *turnActionEffect var eff *turnActionEffect
@@ -433,11 +446,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
applySpellBuff(spell, c, &as, &am, slotLevel) applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name)) "%s has no effect the turn-based engine can apply yet.", spell.Name))
} }
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg) return p.replyDM(ctx, msg)
} }
sess.Statuses.applyBuffDelta(d) sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) player, enemy, err = p.combatantsForSession(sess)
@@ -445,7 +458,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 { if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel) _ = refundSpellSlot(ctx.Sender, slotLevel)
} }
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
label := spell.Name + " — active" label := spell.Name + " — active"
if d.heal > 0 { if d.heal > 0 {
@@ -458,11 +471,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
} else { } else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats) out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported { if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)) "%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
} }
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg) return p.replyDM(ctx, msg)
} }
eff = &turnActionEffect{ eff = &turnActionEffect{
Label: out.Desc, Label: out.Desc,
@@ -478,9 +491,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 { if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel) _ = refundSpellSlot(ctx.Sender, slotLevel)
} }
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }
// chargeSpellCost debits a spell's material component and leveled slot for a // chargeSpellCost debits a spell's material component and leveled slot for a
@@ -551,37 +564,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if sess == nil {
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.") return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
} }
inv := p.loadConsumableInventory(ctx.Sender) inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args) args = strings.TrimSpace(args)
if args == "" { if args == "" {
if len(inv) == 0 { if len(inv) == 0 {
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.") return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
} }
names := make([]string, len(inv)) names := make([]string, len(inv))
for i, c := range inv { for i, c := range inv {
names[i] = c.Def.Name names[i] = c.Def.Name
} }
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".") return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
} }
item, ambig := matchConsumable(inv, args) item, ambig := matchConsumable(inv, args)
if ambig != "" { if ambig != "" {
return p.SendDM(ctx.Sender, ambig) return p.replyDM(ctx, ambig)
} }
if item == nil { if item == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args)) return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args))
} }
def := item.Def def := item.Def
player, enemy, err := p.combatantsForSession(sess) player, enemy, err := p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
eff := &turnActionEffect{Action: "use_consumable"} eff := &turnActionEffect{Action: "use_consumable"}
@@ -600,13 +613,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}}) ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.replyDM(ctx, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name)) "**%s** has no effect the turn-based engine can apply yet.", def.Name))
} }
sess.Statuses.applyBuffDelta(d) sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) player, enemy, err = p.combatantsForSession(sess)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
eff.Label = def.Name + " — active" eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal eff.PlayerHeal = d.heal
@@ -615,7 +628,7 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}) events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
// Round resolved and persisted — now burn the item. A removal failure here // Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them // leaves the player a free use (logged, rare); better than charging them
@@ -623,5 +636,5 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil { if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr) slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
} }
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }

View File

@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
// startup to refresh stale rows. // startup to refresh stale rows.
const phase5BHPMult = 1.5 const phase5BHPMult = 1.5
// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed
// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor
// lift alone wasn't enough to crack the wall. Applied multiplicatively in
// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor
// stays intact for martials. Refreshed for existing rows by
// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1).
func casterHPMult(class DnDClass) float64 {
switch class {
case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer:
return 1.25
}
return 1.0
}
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1). // computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
// Per level after: average HP die (roundup of die/2 + 1) + CON mod. // Per level after: average HP die (roundup of die/2 + 1) + CON mod.
// The result is scaled by phase5BHPMult — see that constant for the // The result is scaled by phase5BHPMult — see that constant for the
@@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
} }
hp += gain hp += gain
} }
// Phase 5-B player power floor — round to nearest int. // Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult + 0.5) scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
if scaled < 1 { if scaled < 1 {
scaled = 1 scaled = 1
} }
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
switch class { switch class {
case ClassFighter, ClassPaladin: case ClassFighter, ClassPaladin:
floor = 6 // heavy/medium-armor baseline floor = 6 // heavy/medium-armor baseline
case ClassCleric, ClassRanger, ClassDruid: case ClassCleric, ClassDruid:
floor = 5
case ClassRanger:
floor = 3 floor = 3
case ClassRogue, ClassBard, ClassWarlock: case ClassBard, ClassWarlock:
floor = 3
case ClassRogue:
floor = 1 floor = 1
case ClassMage, ClassSorcerer:
floor = 2
} }
return 10 + dexMod + floor return 10 + dexMod + floor
} }

View File

@@ -256,7 +256,7 @@ var _ = func() bool {
}, },
"green_hag": { "green_hag": {
ID: "green_hag", Name: "Green Hag", ID: "green_hag", Name: "Green Hag",
CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, CR: 3, HP: 105, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"}, Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
XPValue: 700, XPValue: 700,
@@ -463,7 +463,7 @@ var _ = func() bool {
}, },
"drow_elite_warrior": { "drow_elite_warrior": {
ID: "drow_elite_warrior", Name: "Drow Elite Warrior", ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12, CR: 5, HP: 95, AC: 19, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.20, BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"}, Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
XPValue: 1800, XPValue: 1800,
@@ -471,23 +471,23 @@ var _ = func() bool {
}, },
"drow_mage": { "drow_mage": {
ID: "drow_mage", Name: "Drow Mage", ID: "drow_mage", Name: "Drow Mage",
CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12, CR: 7, HP: 65, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
BlockRate: 0.05, BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"}, Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"},
XPValue: 2900, XPValue: 2900,
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.", Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
}, },
"mind_flayer": { "mind_flayer": {
ID: "mind_flayer", Name: "Mind Flayer", ID: "mind_flayer", Name: "Mind Flayer",
CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12, CR: 7, HP: 90, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"}, Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.30, Effect: "stun"},
XPValue: 2900, XPValue: 2900,
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.", Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
}, },
"hook_horror": { "hook_horror": {
ID: "hook_horror", Name: "Hook Horror", ID: "hook_horror", Name: "Hook Horror",
CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10, CR: 3, HP: 95, AC: 16, Attack: 6, AttackBonus: 6, Speed: 10,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"}, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
XPValue: 700, XPValue: 700,
@@ -495,15 +495,15 @@ var _ = func() bool {
}, },
"roper": { "roper": {
ID: "roper", Name: "Roper", ID: "roper", Name: "Roper",
CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4, CR: 5, HP: 115, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
BlockRate: 0.20, BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"}, Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"},
XPValue: 1800, XPValue: 1800,
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.", Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
}, },
"boss_ilvaras_xunyl": { "boss_ilvaras_xunyl": {
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess", ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12, CR: 12, HP: 210, AC: 18, Attack: 19, AttackBonus: 9, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"}, Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
XPValue: 8400, XPValue: 8400,
@@ -512,7 +512,7 @@ var _ = func() bool {
// ── Feywild Crossing ───────────────────────────────────────────── // ── Feywild Crossing ─────────────────────────────────────────────
"redcap": { "redcap": {
ID: "redcap", Name: "Redcap", ID: "redcap", Name: "Redcap",
CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14, CR: 3, HP: 60, AC: 15, Attack: 6, AttackBonus: 6, Speed: 14,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"}, Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
XPValue: 700, XPValue: 700,
@@ -520,7 +520,7 @@ var _ = func() bool {
}, },
"will_o_wisp": { "will_o_wisp": {
ID: "will_o_wisp", Name: "Will-o'-Wisp", ID: "will_o_wisp", Name: "Will-o'-Wisp",
CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14, CR: 2, HP: 30, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
BlockRate: 0.0, BlockRate: 0.0,
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"}, Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
XPValue: 450, XPValue: 450,
@@ -528,7 +528,7 @@ var _ = func() bool {
}, },
"quickling": { "quickling": {
ID: "quickling", Name: "Quickling", ID: "quickling", Name: "Quickling",
CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18, CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
BlockRate: 0.05, BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"}, Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
XPValue: 200, XPValue: 200,
@@ -536,7 +536,7 @@ var _ = func() bool {
}, },
"night_hag": { "night_hag": {
ID: "night_hag", Name: "Night Hag", ID: "night_hag", Name: "Night Hag",
CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, CR: 5, HP: 135, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"}, Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
XPValue: 1800, XPValue: 1800,
@@ -544,7 +544,7 @@ var _ = func() bool {
}, },
"fomorian": { "fomorian": {
ID: "fomorian", Name: "Fomorian", ID: "fomorian", Name: "Fomorian",
CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12, CR: 8, HP: 180, AC: 16, Attack: 13, AttackBonus: 9, Speed: 12,
BlockRate: 0.10, BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"}, Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
XPValue: 3900, XPValue: 3900,
@@ -552,7 +552,7 @@ var _ = func() bool {
}, },
"boss_thornmother": { "boss_thornmother": {
ID: "boss_thornmother", Name: "The Thornmother", ID: "boss_thornmother", Name: "The Thornmother",
CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12, CR: 11, HP: 235, AC: 18, Attack: 18, AttackBonus: 8, Speed: 12,
BlockRate: 0.15, BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"}, Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
XPValue: 7200, XPValue: 7200,

View File

@@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
// Mage d6, CON +1 // Mage d6, CON +1
// L1: 6 + 1 = 7 // L1: 6 + 1 = 7
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20 // L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
// Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up). // Raw total: 27; Phase 5-B × casterHPMult: 27 × 1.5 × 1.25 = 50.625 → 51.
got := computeMaxHP(ClassMage, 1, 5) got := computeMaxHP(ClassMage, 1, 5)
if got != 41 { if got != 51 {
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got) t.Errorf("Mage L5 (CON+1) = %d, want 51 (27 raw × phase5BHPMult × casterHPMult)", got)
} }
} }
@@ -94,8 +94,12 @@ func TestComputeAC(t *testing.T) {
{ClassFighter, 0, 16}, // 10 + 0 + 6 {ClassFighter, 0, 16}, // 10 + 0 + 6
{ClassFighter, 2, 18}, // 10 + 2 + 6 {ClassFighter, 2, 18}, // 10 + 2 + 6
{ClassRogue, 3, 14}, // 10 + 3 + 1 {ClassRogue, 3, 14}, // 10 + 3 + 1
{ClassMage, 0, 10}, // 10 + 0 + 0 {ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor)
{ClassCleric, 1, 14}, // 10 + 1 + 3 {ClassSorcerer, 1, 13}, // 10 + 1 + 2
{ClassCleric, 1, 16}, // 10 + 1 + 5 (D8-d-fix caster floor)
{ClassDruid, 0, 15}, // 10 + 0 + 5
{ClassBard, 2, 15}, // 10 + 2 + 3 (D8-d-fix caster floor)
{ClassWarlock, 0, 13}, // 10 + 0 + 3
{ClassRanger, 2, 15}, // 10 + 2 + 3 {ClassRanger, 2, 15}, // 10 + 2 + 3
} }
for _, c := range cases { for _, c := range cases {

View File

@@ -380,16 +380,16 @@ func roomGlyph(rt RoomType) string {
type stopReason int type stopReason int
const ( const (
stopOK stopReason = iota // walked to next room; loop may continue stopOK stopReason = iota // walked to next room; loop may continue
stopFork // advanceTransitionGraph returned a forkMsg stopFork // advanceTransitionGraph returned a forkMsg
stopElite // standing at an Elite doorway; needs !fight stopElite // standing at an Elite doorway; needs !fight
stopBoss // standing at a Boss doorway; needs !fight stopBoss // standing at a Boss doorway; needs !fight
stopEnded // patrol or room resolution killed the player stopEnded // patrol or room resolution killed the player
stopComplete // run cleared (boss down, no outgoing edges) stopComplete // run cleared (boss down, no outgoing edges)
stopBlocked // an active CombatSession blocks the advance stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU) stopPreflight // pre-iteration preflight tripped (low HP / low SU)
stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp
) )
// bossSafetyHPPct — compact-autopilot won't engage a boss while current HP // bossSafetyHPPct — compact-autopilot won't engage a boss while current HP
@@ -477,11 +477,13 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
// inlineBossCombat (only consulted when compact==true) selects between the // inlineBossCombat (only consulted when compact==true) selects between the
// two background combat paths at a boss/elite doorway. true keeps the // two background combat paths at a boss/elite doorway. true keeps the
// long-expedition D3 inline auto-resolve (production autorun). false // legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
// returns stopBoss/stopElite after the safety gate so a turn-based driver // multiattack). false returns stopBoss/stopElite after the safety gate so
// — currently only the headless sim's autoResolveCombat / simPickCombatAction // a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
// — handles the fight via the regular !fight / !attack engine. The sim // the fight via the regular !fight / !attack engine. Both the headless sim
// uses false so simPickSpell actually fires; D8-prereq re-wired this seam. // and the production autorun (long-expedition D8-f) now pass false so the
// real engine (with multiattack) resolves the encounter and simPickSpell
// actually fires; D8-prereq re-wired this seam, D8-f flipped prod onto it.
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) { func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender) run, err := getActiveZoneRun(ctx.Sender)
if err != nil { if err != nil {
@@ -568,10 +570,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
} }
} }
if !inlineBossCombat { if !inlineBossCombat {
// Background caller wants to drive the fight itself (sim's // Background caller wants to drive the fight itself via the
// autoResolveCombat / simPickCombatAction). Surface the // turn engine (autoDriveCombat / pickAutoCombatAction).
// doorway like the foreground path does, after the safety // Surface the doorway like the foreground path does, after
// gate has had a chance to defer the engagement. // the safety gate has had a chance to defer the engagement.
kind := "Elite" kind := "Elite"
r := stopElite r := stopElite
if prev == RoomBoss { if prev == RoomBoss {
@@ -953,6 +955,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
// resolveCombatRoom spawns one roster enemy (elite filter optional), // resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas, // runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the staged narration. Returns: // and renders the staged narration. Returns:
//
// intro — pre-combat block (TwinBee combat-start + monster stat block) // intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller // phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary // outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
@@ -1139,7 +1142,7 @@ type BossOutcomeInputs struct {
Result CombatResult Result CombatResult
PreHP, PostHP, MaxHP int PreHP, PostHP, MaxHP int
PhaseTwoAt float64 // fraction of MaxHP; 0 disables phase-two narration PhaseTwoAt float64 // fraction of MaxHP; 0 disables phase-two narration
Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat Nat20s, Nat1s int // pre-counted by scanMoodEventsFromCombat
// Caller-supplied headlines so arena and zone read in their own voice. // Caller-supplied headlines so arena and zone read in their own voice.
// DefeatHeadline is the full sentence shown after the PlayerDeath // DefeatHeadline is the full sentence shown after the PlayerDeath
@@ -1287,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
"🚪 Abandoned **%s** at room %d/%d. No rewards.", "🚪 Abandoned **%s** at room %d/%d. No rewards.",
zone.Display, run.CurrentRoom+1, run.TotalRooms)) zone.Display, run.CurrentRoom+1, run.TotalRooms))
} }

View File

@@ -138,6 +138,84 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
return ids, rows.Err() return ids, rows.Err()
} }
// autoRunCombatSegmentCap bounds the walk→fight→walk ping-pong inside a
// single background tick. With autoRunRoomCap == 3 rooms/tick the loop
// can realistically only hit a couple doorways; the cap is a backstop
// against a pathological state where a fight wins but the next walk
// re-presents the same doorway.
const autoRunCombatSegmentCap = 8
// runAutopilotWalkDriven runs the compact background walk and, when it
// halts at a boss/elite doorway, drives that fight through the real turn
// engine (manual `!fight` parity — long-expedition D8-f) before resuming
// the walk. It loops walk→fight→walk so one tick still covers up to
// maxRooms rooms, exactly as the old inline-boss path did, but bosses now
// face the player's full kit against the enemy's full multiattack profile
// instead of the rosier inline SimulateCombat path.
//
// Combat narration is suppressed via a silent ctx — the day digest
// summarizes the outcome, matching the rest of the compact autopilot
// surface. The returned result carries the cumulative room count and the
// reason of whichever non-combat stop ended the loop.
func (p *AdventurePlugin) runAutopilotWalkDriven(ctx MessageContext, maxRooms int) autopilotWalkResult {
silent := ctx
silent.Silent = true
total := 0
for seg := 0; seg < autoRunCombatSegmentCap; seg++ {
budget := maxRooms - total
if budget <= 0 {
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
r := p.runAutopilotWalk(ctx, budget, true, false)
if r.initErr != "" {
return r
}
total += r.rooms
r.rooms = total
if r.reason != stopBoss && r.reason != stopElite {
return r
}
// Standing at an elite/boss doorway — drive the fight on the turn
// engine. handleFightCmd opens the session at the current doorway;
// autoDriveCombat loops until it resolves.
won, err := p.autoDriveCombat(silent)
if err != nil {
slog.Warn("expedition: autopilot turn-engine combat", "user", ctx.Sender, "err", err)
// Leave the doorway stop in place; the next tick retries the
// engagement after the cooldown.
return r
}
if won {
// The won session is recorded; the next walk advances the now-
// cleared room (a boss win surfaces as stopComplete, an elite as
// a normal continue). Loop.
continue
}
// Lost: the turn engine never voluntarily flees, so a non-win means
// the party fell. finishCombatSession (CombatStatusLost) already
// abandoned the run and force-extracted the expedition; surface it
// as a death so the digest + pet-emergence seam fire.
if c, _ := LoadDnDCharacter(ctx.Sender); c == nil || c.HPCurrent <= 0 {
r.reason = stopEnded
r.finalMsg = fmt.Sprintf("💀 The party fell in battle after %s. The expedition is over.", roomsWalkedPhrase(total))
return r
}
// Alive but the session didn't open / resolve to a win (rare —
// bestiary miss or stall). Leave the doorway stop; retry next tick.
return r
}
// Segment cap hit — stop cleanly rather than spin.
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
// roomsWalkedPhrase renders "N room(s)" for autopilot footers.
func roomsWalkedPhrase(rooms int) string {
if rooms == 1 {
return "1 room"
}
return fmt.Sprintf("%d rooms", rooms)
}
// tryAutoRun claims the slot for this expedition, runs one background // tryAutoRun claims the slot for this expedition, runs one background
// walk, and DMs the result if the suppression rules say to. The CAS- // walk, and DMs the result if the suppression rules say to. The CAS-
// update is the only persistent side effect on the autorun column — // update is the only persistent side effect on the autorun column —
@@ -170,7 +248,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
} }
uid := id.UserID(e.UserID) uid := id.UserID(e.UserID)
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true) // D8-f — boss/elite encounters route through the real turn engine for
// manual `!fight` parity (enemy multiattack included). Trash mobs still
// auto-resolve on the fast inline path inside the walk.
r := p.runAutopilotWalkDriven(MessageContext{Sender: uid}, autoRunRoomCap)
if r.initErr != "" { if r.initErr != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent. // "no expedition" / "no run" — race with abandon/extract. Silent.
return nil return nil

View File

@@ -14,6 +14,7 @@ package plugin
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"sort" "sort"
"time" "time"
@@ -22,6 +23,12 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// simInlineBossCombat is a D8-e DIAGNOSTIC toggle. When GOGOBEE_SIM_INLINE_BOSS=1
// the sim routes boss/elite doorways through the inline SimulateCombat path
// (production autopilot behavior) instead of the turn-based !fight engine.
// Used to A/B the martial T4/T5 regression. NOT for production.
func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == "1" }
// SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes // SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes
// just enough surface to drive synthetic players end-to-end. // just enough surface to drive synthetic players end-to-end.
type SimRunner struct { type SimRunner struct {
@@ -116,7 +123,7 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
// stockSimConsumables drops a small tier-appropriate bundle of potions // stockSimConsumables drops a small tier-appropriate bundle of potions
// + a couple offensive items into the synthetic player's inventory so // + a couple offensive items into the synthetic player's inventory so
// SelectConsumables / setupAutoHealFromInventory have something to fire // SelectConsumables / setupAutoHealFromInventory have something to fire
// during autoResolveCombat. Counts are deliberately modest — a real // during autoDriveCombat. Counts are deliberately modest — a real
// L7+ player typically carries 3-6 heals plus a couple of buffs; we // L7+ player typically carries 3-6 heals plus a couple of buffs; we
// mirror that band rather than max-stocking, which would mask class // mirror that band rather than max-stocking, which would mask class
// power gaps. // power gaps.
@@ -274,8 +281,8 @@ type SimResult struct {
// resource). YieldsByName breaks that total down by resource name // resource). YieldsByName breaks that total down by resource name
// for tier/per-resource calibration. Both are read from // for tier/per-resource calibration. Both are read from
// adventure_inventory at end-of-run. // adventure_inventory at end-of-run.
YieldCount int YieldCount int
YieldsByName map[string]int YieldsByName map[string]int
// Combats holds a per-combat trace for every fight the synthetic // Combats holds a per-combat trace for every fight the synthetic
// player entered during the expedition (boss + elites + patrols). // player entered during the expedition (boss + elites + patrols).
// Used by post-hoc analysis to dig into class-survival walls // Used by post-hoc analysis to dig into class-survival walls
@@ -429,7 +436,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
for i := 0; i < walkCap; i++ { for i := 0; i < walkCap; i++ {
simNow = simNow.Add(simWalkInterval) simNow = simNow.Add(simWalkInterval)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, false) walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, simInlineBossCombat())
if walk.initErr != "" { if walk.initErr != "" {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "init:" + walk.initErr res.StopCode = "init:" + walk.initErr
@@ -473,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
case stopBoss, stopElite: case stopBoss, stopElite:
// Auto-resolve the encounter: !fight to open, then !attack // Auto-resolve the encounter: !fight to open, then !attack
// per round until the session resolves (won / lost / fled). // per round until the session resolves (won / lost / fled).
killed, err := s.autoResolveCombat(ctx) killed, err := s.P.autoDriveCombat(ctx)
if err != nil { if err != nil {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "combat:" + err.Error() res.StopCode = "combat:" + err.Error()
@@ -499,9 +506,22 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
// Boss kill closes the run via combat resolution → continue // Boss kill closes the run via combat resolution → continue
// the loop so the next walk picks up the cleared-run state. // the loop so the next walk picks up the cleared-run state.
case stopFork: case stopFork:
// Deterministic sim policy: always take path 1. Real players // Deterministic sim policy: take the first UNLOCKED path. The
// pick based on intent; the sim just needs to make progress. // old blind "go 1" stalled forever on all-skill-check forks
if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil { // (feywild fork1) — resolveForkChoice rejects a locked edge but
// zoneCmdGo swallows it as a sent-DM with a nil return, so the
// run never advanced and burned every walk at the same node. A
// real player reads the menu and picks a passable path; mirror
// that. choice==0 means every edge is locked (a graph soft-lock
// the author must fix) — halt loudly rather than spin.
choice := s.firstUnlockedForkChoice(ctx.Sender)
if choice == 0 {
res.Outcome = "halted"
res.StopCode = "fork_all_locked"
i = walkCap
break
}
if err := s.P.handleDnDExpeditionCmd(ctx, fmt.Sprintf("go %d", choice)); err != nil {
res.Outcome = "halted" res.Outcome = "halted"
res.StopCode = "fork:" + err.Error() res.StopCode = "fork:" + err.Error()
i = walkCap i = walkCap
@@ -619,6 +639,26 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
return res, nil return res, nil
} }
// firstUnlockedForkChoice returns the 1-based index of the first
// traversable option at the pending fork, or 0 if every edge is locked
// (a graph soft-lock — see feywild fork1, which had no LockNone exit).
func (s *SimRunner) firstUnlockedForkChoice(uid id.UserID) int {
run, err := getActiveZoneRun(uid)
if err != nil || run == nil {
return 1
}
pf, err := decodePendingFork(run.NodeChoices)
if err != nil || pf == nil {
return 1
}
for _, o := range pf.Options {
if o.Unlocked {
return o.Index
}
}
return 0
}
// captureDaySnapshot appends a SimDaySnapshot reflecting current state. // captureDaySnapshot appends a SimDaySnapshot reflecting current state.
// HP is read from the live character row; SU/threat/day from the live // HP is read from the live character row; SU/threat/day from the live
// expedition. Rooms is the running res.Rooms counter. // expedition. Rooms is the running res.Rooms counter.
@@ -735,16 +775,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
return total, out return total, out
} }
// autoResolveCombat dispatches !fight at the current elite/boss gate, // autoDriveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true // then loops !attack/!cast/!consume until the combat session resolves.
// when the player won (enemy dead, room cleared), false when the // Returns true when the player won (enemy dead, room cleared), false when
// player lost or fled. autoCombatRoundCap is a safety cap against // the player lost or fled. autoCombatRoundCap is a safety cap against
// pathological stalemates (shouldn't trigger in practice — combat is // pathological stalemates (shouldn't trigger in practice — combat is
// strictly monotone in HP). // strictly monotone in HP).
//
// Shared by the headless sim and the production background autopilot
// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the
// per-round DM narration so the autorun digest can summarize the fight
// without spamming the player a message per round. Driving the real
// !fight/!attack engine here is what gives autopilot bosses true parity
// with manual `!fight` — including enemy multiattack, which the old
// inline SimulateCombat path ignored.
const autoCombatRoundCap = 200 const autoCombatRoundCap = 200
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) { func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
if err := s.P.handleFightCmd(ctx); err != nil { if err := p.handleFightCmd(ctx); err != nil {
return false, fmt.Errorf("fight: %w", err) return false, fmt.Errorf("fight: %w", err)
} }
sess, err := getActiveCombatSession(ctx.Sender) sess, err := getActiveCombatSession(ctx.Sender)
@@ -772,15 +820,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
case CombatStatusLost, CombatStatusFled: case CombatStatusLost, CombatStatusFled:
return false, nil return false, nil
} }
kind, arg := s.simPickCombatAction(ctx.Sender, cur) kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
var dispatchErr error var dispatchErr error
switch kind { switch kind {
case "consume": case "consume":
dispatchErr = s.P.handleConsumeCmd(ctx, arg) dispatchErr = p.handleConsumeCmd(ctx, arg)
case "cast": case "cast":
dispatchErr = s.P.handleCombatCastCmd(ctx, arg) dispatchErr = p.handleCombatCastCmd(ctx, arg)
default: default:
dispatchErr = s.P.handleAttackCmd(ctx) dispatchErr = p.handleAttackCmd(ctx)
} }
if dispatchErr != nil { if dispatchErr != nil {
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr) return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
@@ -828,7 +876,7 @@ func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) {
// one more big hit, not so early that a 1-HP scratch burns a potion. // one more big hit, not so early that a 1-HP scratch burns a potion.
const simHealHPThresholdPct = 40 const simHealHPThresholdPct = 40
// simPickCombatAction is the sim's per-turn decision tree, mirroring // pickAutoCombatAction is the per-turn decision tree, mirroring
// what a competent prod player would type: // what a competent prod player would type:
// //
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal // 1. If HP is below simHealHPThresholdPct and the inventory has a heal
@@ -847,14 +895,14 @@ const simHealHPThresholdPct = 40
// //
// Pre-J2a the sim looped !attack only, which underweighted every caster // Pre-J2a the sim looped !attack only, which underweighted every caster
// class — see sim_results/j2_findings.md for the trace evidence. // class — see sim_results/j2_findings.md for the trace evidence.
func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) { func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
c, _ := LoadDnDCharacter(uid) c, _ := LoadDnDCharacter(uid)
if c == nil || sess == nil { if c == nil || sess == nil {
return "attack", "" return "attack", ""
} }
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
if lowHP { if lowHP {
inv := s.P.loadConsumableInventory(uid) inv := p.loadConsumableInventory(uid)
for _, it := range inv { for _, it := range inv {
if it.Def.Effect == EffectHeal { if it.Def.Effect == EffectHeal {
return "consume", it.Def.Name return "consume", it.Def.Name

View File

@@ -41,6 +41,13 @@ type MessageContext struct {
// routes DM commands to the player's game room). When set, sender-private // routes DM commands to the player's game room). When set, sender-private
// replies should go here instead of the rewritten RoomID. // replies should go here instead of the rewritten RoomID.
OriginRoomID id.RoomID OriginRoomID id.RoomID
// Silent suppresses player-facing replies for handlers that honor it
// (currently the turn-engine combat commands via replyDM). Set by the
// background autopilot when it drives a boss/elite fight through the
// real !fight/!attack engine for manual parity (long-expedition D8-f) —
// the day digest summarizes the outcome, so the per-round narration is
// dropped rather than DM'd round-by-round.
Silent bool
} }
// ReactionContext holds the context for a reaction event. // ReactionContext holds the context for a reaction event.

View File

@@ -23,9 +23,9 @@ package plugin
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge → // cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
// bargain_walk → fey_market → fork1 // bargain_walk → fey_market → fork1
// //
// Fork1 → both options locked (CHA vs Perception, no free choice): // Fork1 → marsh (free default) | grove (CHA DC 14 bonus — the bargain):
// //
// Grove approach (8 nodes, CHA DC 14): // Grove approach (8 nodes, CHA DC 14 bonus):
// grove_threshold → starlight_path → singing_orchard → mirror_pond // grove_threshold → starlight_path → singing_orchard → mirror_pond
// → prismatic_arbor → moonpetal_clearing → dawnglow_walk → // → prismatic_arbor → moonpetal_clearing → dawnglow_walk →
// glamoured_grove (fork2a) // glamoured_grove (fork2a)
@@ -203,13 +203,19 @@ func zoneFeywildCrossingGraph() ZoneGraph {
{From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone}, {From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone},
{From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone}, {From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone},
// Fork1 — both options locked (CHA vs. Perception, no LockNone). // Fork1 — marsh is the free default path; grove is a CHA-gated
// bonus route (the fey bargain). (Both were skill-locked originally,
// with no LockNone exit — a soft-lock: any character failing both
// the CHA and Perception checks was permanently stranded here, since
// fork rolls are deterministic with no retry. Every other zone fork
// has a free path; freeing marsh restores that invariant while
// keeping the signature CHA fey-bargain route as a bonus.)
{From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold", {From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold",
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14}, Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14},
Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1}, Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1},
{From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold", {From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
Lock: LockPerception, LockData: map[string]any{"dc": 14}, Lock: LockNone,
Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1}, Hint: "wisp-light flickering between two trees — pick your way through", Weight: 1},
// Grove approach. // Grove approach.
{From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone}, {From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone},

View File

@@ -55,16 +55,23 @@ func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) {
} }
} }
// TestFeywildCrossingGraph_NoFreeChoiceAtFork1 captures the design // TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock
// intent: both fork1 outgoing edges are locked. The player must succeed // invariant: fork1 must offer at least one LockNone exit. The original
// at CHA or Perception to enter; no LockNone fallback. // design locked BOTH edges (CHA + Perception) with no fallback — fork
func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) { // rolls are deterministic with no retry, so a character failing both was
// permanently stranded (D8-f part 2 found this stranded ~60% of runs).
// Every other zone fork has a free path; fork1 must too.
func TestFeywildCrossingGraph_Fork1HasFreePath(t *testing.T) {
g := zoneFeywildCrossingGraph() g := zoneFeywildCrossingGraph()
free := 0
for _, e := range g.outgoingEdges("feywild_crossing.fork1") { for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
if e.Lock == LockNone { if e.Lock == LockNone || e.Lock == "" {
t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To) free++
} }
} }
if free == 0 {
t.Error("fork1 has no free (LockNone) exit — soft-lock: a player failing every skill check is permanently stranded")
}
} }
// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses // TestFeywildCrossingGraph_FirstCHALock confirms this zone uses

View File

@@ -190,6 +190,40 @@ func TestCompileLegacyZoneGraph_AllRegistered(t *testing.T) {
} }
} }
// TestZoneGraphs_NoSoftLockedFork guards the invariant that every fork
// node offers at least one traversable (LockNone) exit. A fork whose
// every edge is skill-locked strands any player who fails all the checks
// — fork rolls are deterministic with no retry. D8-f part 2 found
// feywild fork1 violating this (it stranded ~60% of runs). Catch any
// future author who locks every exit of a fork.
func TestZoneGraphs_NoSoftLockedFork(t *testing.T) {
for _, z := range allZones() {
g, ok := loadZoneGraph(z.ID)
if !ok {
continue
}
for nodeID, node := range g.Nodes {
if node.Kind != NodeKindFork {
continue
}
outs := g.outgoingEdges(nodeID)
if len(outs) == 0 {
continue
}
free := false
for _, e := range outs {
if e.Lock == LockNone || e.Lock == "" {
free = true
break
}
}
if !free {
t.Errorf("zone %q fork %q: every exit is locked — soft-lock (a player failing all checks is stranded; add a LockNone fallback)", z.ID, nodeID)
}
}
}
}
func TestDeriveLegacyNodeID_StableShape(t *testing.T) { func TestDeriveLegacyNodeID_StableShape(t *testing.T) {
got := deriveLegacyNodeID("crypt_valdris", 0) got := deriveLegacyNodeID("crypt_valdris", 0)
want := "crypt_valdris.r1" want := "crypt_valdris.r1"