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
// JobCompleted gate.
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
// over-archived: it treats any active dnd_zone_run row not linked to
// 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: 12},
}},
"boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23
{Name: "Thorned Lash", AttackBonus: 8, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 8, Damage: 11},
"boss_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band)
{Name: "Thorned Lash", AttackBonus: 9, Damage: 13},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 11},
}},
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
{Name: "Bite", AttackBonus: 11, Damage: 19},
@@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
{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.

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)
}
// 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 ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
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 {
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()
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)
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
switch existing.Status {
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:
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:
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)
}
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)
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)
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
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
if err != nil {
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
@@ -118,7 +131,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
}
b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String())
return p.replyDM(ctx, b.String())
}
// ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -138,22 +151,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
sess, err := getActiveCombatSession(ctx.Sender)
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 {
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)
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)
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
@@ -393,35 +406,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
sess, err := getActiveCombatSession(ctx.Sender)
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 {
// 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)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
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) {
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)))
}
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg)
return p.replyDM(ctx, errMsg)
}
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))
}
player, enemy, err := p.combatantsForSession(sess)
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
@@ -433,11 +446,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am)
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))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
return p.replyDM(ctx, msg)
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
@@ -445,7 +458,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 {
_ = 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"
if d.heal > 0 {
@@ -458,11 +471,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
} else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
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))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
return p.replyDM(ctx, msg)
}
eff = &turnActionEffect{
Label: out.Desc,
@@ -478,9 +491,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 {
_ = 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
@@ -551,37 +564,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
sess, err := getActiveCombatSession(ctx.Sender)
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 {
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)
args = strings.TrimSpace(args)
if args == "" {
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))
for i, c := range inv {
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)
if ambig != "" {
return p.SendDM(ctx.Sender, ambig)
return p.replyDM(ctx, ambig)
}
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
player, enemy, err := p.combatantsForSession(sess)
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"}
@@ -600,13 +613,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am)
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))
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
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.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})
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
// 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 {
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.
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).
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
// 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
}
// Phase 5-B player power floor — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult + 0.5)
// Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
if scaled < 1 {
scaled = 1
}
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
switch class {
case ClassFighter, ClassPaladin:
floor = 6 // heavy/medium-armor baseline
case ClassCleric, ClassRanger, ClassDruid:
case ClassCleric, ClassDruid:
floor = 5
case ClassRanger:
floor = 3
case ClassRogue, ClassBard, ClassWarlock:
case ClassBard, ClassWarlock:
floor = 3
case ClassRogue:
floor = 1
case ClassMage, ClassSorcerer:
floor = 2
}
return 10 + dexMod + floor
}

View File

@@ -256,7 +256,7 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
XPValue: 700,
@@ -463,7 +463,7 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
XPValue: 1800,
@@ -471,23 +471,23 @@ var _ = func() bool {
},
"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,
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,
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
},
"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,
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,
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
},
"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,
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
XPValue: 700,
@@ -495,15 +495,15 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"},
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"},
XPValue: 1800,
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
},
"boss_ilvaras_xunyl": {
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,
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
XPValue: 8400,
@@ -512,7 +512,7 @@ var _ = func() bool {
// ── Feywild Crossing ─────────────────────────────────────────────
"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,
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
XPValue: 700,
@@ -520,7 +520,7 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
XPValue: 450,
@@ -528,7 +528,7 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
XPValue: 200,
@@ -536,7 +536,7 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
XPValue: 1800,
@@ -544,7 +544,7 @@ var _ = func() bool {
},
"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,
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
XPValue: 3900,
@@ -552,7 +552,7 @@ var _ = func() bool {
},
"boss_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,
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
XPValue: 7200,

View File

@@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
// Mage d6, CON +1
// L1: 6 + 1 = 7
// 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)
if got != 41 {
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got)
if got != 51 {
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, 2, 18}, // 10 + 2 + 6
{ClassRogue, 3, 14}, // 10 + 3 + 1
{ClassMage, 0, 10}, // 10 + 0 + 0
{ClassCleric, 1, 14}, // 10 + 1 + 3
{ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor)
{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
}
for _, c := range cases {

View File

@@ -380,16 +380,16 @@ func roomGlyph(rt RoomType) string {
type stopReason int
const (
stopOK stopReason = iota // walked to next room; loop may continue
stopFork // advanceTransitionGraph returned a forkMsg
stopElite // standing at an Elite doorway; needs !fight
stopBoss // standing at a Boss doorway; needs !fight
stopEnded // patrol or room resolution killed the player
stopComplete // run cleared (boss down, no outgoing edges)
stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
stopBossSafety // compact autopilot bailed before boss (HP/SU/exhaustion gate) — caller pitches a rest camp
stopOK stopReason = iota // walked to next room; loop may continue
stopFork // advanceTransitionGraph returned a forkMsg
stopElite // standing at an Elite doorway; needs !fight
stopBoss // standing at a Boss doorway; needs !fight
stopEnded // patrol or room resolution killed the player
stopComplete // run cleared (boss down, no outgoing edges)
stopBlocked // an active CombatSession blocks the advance
stopHarvestCombat // auto-harvest pulled into combat that resolved short of death
stopPreflight // pre-iteration preflight tripped (low HP / low SU)
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
@@ -477,11 +477,13 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
// inlineBossCombat (only consulted when compact==true) selects between the
// two background combat paths at a boss/elite doorway. true keeps the
// long-expedition D3 inline auto-resolve (production autorun). false
// returns stopBoss/stopElite after the safety gate so a turn-based driver
// — currently only the headless sim's autoResolveCombat / simPickCombatAction
// — handles the fight via the regular !fight / !attack engine. The sim
// uses false so simPickSpell actually fires; D8-prereq re-wired this seam.
// legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
// multiattack). false returns stopBoss/stopElite after the safety gate so
// a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
// the fight via the regular !fight / !attack engine. Both the headless sim
// 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) {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
@@ -568,10 +570,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
}
}
if !inlineBossCombat {
// Background caller wants to drive the fight itself (sim's
// autoResolveCombat / simPickCombatAction). Surface the
// doorway like the foreground path does, after the safety
// gate has had a chance to defer the engagement.
// Background caller wants to drive the fight itself via the
// turn engine (autoDriveCombat / pickAutoCombatAction).
// Surface the doorway like the foreground path does, after
// the safety gate has had a chance to defer the engagement.
kind := "Elite"
r := stopElite
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),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the staged narration. Returns:
//
// intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
@@ -1139,7 +1142,7 @@ type BossOutcomeInputs struct {
Result CombatResult
PreHP, PostHP, MaxHP int
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.
// 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.",
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()
}
// 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
// walk, and DMs the result if the suppression rules say to. The CAS-
// 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)
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 != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent.
return nil

View File

@@ -14,6 +14,7 @@ package plugin
import (
"encoding/json"
"fmt"
"os"
"sort"
"time"
@@ -22,6 +23,12 @@ import (
"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
// just enough surface to drive synthetic players end-to-end.
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
// + a couple offensive items into the synthetic player's inventory so
// 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
// mirror that band rather than max-stocking, which would mask class
// power gaps.
@@ -274,8 +281,8 @@ type SimResult struct {
// resource). YieldsByName breaks that total down by resource name
// for tier/per-resource calibration. Both are read from
// adventure_inventory at end-of-run.
YieldCount int
YieldsByName map[string]int
YieldCount int
YieldsByName map[string]int
// Combats holds a per-combat trace for every fight the synthetic
// player entered during the expedition (boss + elites + patrols).
// 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++ {
simNow = simNow.Add(simWalkInterval)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, false)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, simInlineBossCombat())
if walk.initErr != "" {
res.Outcome = "halted"
res.StopCode = "init:" + walk.initErr
@@ -473,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
case stopBoss, stopElite:
// Auto-resolve the encounter: !fight to open, then !attack
// per round until the session resolves (won / lost / fled).
killed, err := s.autoResolveCombat(ctx)
killed, err := s.P.autoDriveCombat(ctx)
if err != nil {
res.Outcome = "halted"
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
// the loop so the next walk picks up the cleared-run state.
case stopFork:
// Deterministic sim policy: always take path 1. Real players
// pick based on intent; the sim just needs to make progress.
if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil {
// Deterministic sim policy: take the first UNLOCKED path. The
// old blind "go 1" stalled forever on all-skill-check forks
// (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.StopCode = "fork:" + err.Error()
i = walkCap
@@ -619,6 +639,26 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
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.
// HP is read from the live character row; SU/threat/day from the live
// expedition. Rooms is the running res.Rooms counter.
@@ -735,16 +775,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
return total, out
}
// autoResolveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true
// when the player won (enemy dead, room cleared), false when the
// player lost or fled. autoCombatRoundCap is a safety cap against
// autoDriveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack/!cast/!consume until the combat session resolves.
// Returns true when the player won (enemy dead, room cleared), false when
// the player lost or fled. autoCombatRoundCap is a safety cap against
// pathological stalemates (shouldn't trigger in practice — combat is
// 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
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
if err := s.P.handleFightCmd(ctx); err != nil {
func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
if err := p.handleFightCmd(ctx); err != nil {
return false, fmt.Errorf("fight: %w", err)
}
sess, err := getActiveCombatSession(ctx.Sender)
@@ -772,15 +820,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
case CombatStatusLost, CombatStatusFled:
return false, nil
}
kind, arg := s.simPickCombatAction(ctx.Sender, cur)
kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
var dispatchErr error
switch kind {
case "consume":
dispatchErr = s.P.handleConsumeCmd(ctx, arg)
dispatchErr = p.handleConsumeCmd(ctx, arg)
case "cast":
dispatchErr = s.P.handleCombatCastCmd(ctx, arg)
dispatchErr = p.handleCombatCastCmd(ctx, arg)
default:
dispatchErr = s.P.handleAttackCmd(ctx)
dispatchErr = p.handleAttackCmd(ctx)
}
if dispatchErr != nil {
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.
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:
//
// 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
// 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)
if c == nil || sess == nil {
return "attack", ""
}
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
if lowHP {
inv := s.P.loadConsumableInventory(uid)
inv := p.loadConsumableInventory(uid)
for _, it := range inv {
if it.Def.Effect == EffectHeal {
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
// replies should go here instead of the rewritten 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.

View File

@@ -23,9 +23,9 @@ package plugin
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
// 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
// → prismatic_arbor → moonpetal_clearing → dawnglow_walk →
// 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.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",
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},
{From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
Lock: LockPerception, LockData: map[string]any{"dc": 14},
Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1},
Lock: LockNone,
Hint: "wisp-light flickering between two trees — pick your way through", Weight: 1},
// Grove approach.
{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
// intent: both fork1 outgoing edges are locked. The player must succeed
// at CHA or Perception to enter; no LockNone fallback.
func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) {
// TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock
// invariant: fork1 must offer at least one LockNone exit. The original
// design locked BOTH edges (CHA + Perception) with no fallback — fork
// 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()
free := 0
for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
if e.Lock == LockNone {
t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To)
if e.Lock == LockNone || e.Lock == "" {
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

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) {
got := deriveLegacyNodeID("crypt_valdris", 0)
want := "crypt_valdris.r1"