diff --git a/internal/plugin/bestiary_srd.go b/internal/plugin/bestiary_srd.go index 055fd33..95e966c 100644 --- a/internal/plugin/bestiary_srd.go +++ b/internal/plugin/bestiary_srd.go @@ -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 97–100% 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. diff --git a/internal/plugin/dnd_bestiary.go b/internal/plugin/dnd_bestiary.go index 3e91739..2bb36de 100644 --- a/internal/plugin/dnd_bestiary.go +++ b/internal/plugin/dnd_bestiary.go @@ -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, diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 2997f1e..505a2b7 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -506,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 @@ -626,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. diff --git a/internal/plugin/zone_graph_feywild_crossing.go b/internal/plugin/zone_graph_feywild_crossing.go index a67491f..fda27f8 100644 --- a/internal/plugin/zone_graph_feywild_crossing.go +++ b/internal/plugin/zone_graph_feywild_crossing.go @@ -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}, diff --git a/internal/plugin/zone_graph_feywild_crossing_test.go b/internal/plugin/zone_graph_feywild_crossing_test.go index 57297a9..623d92c 100644 --- a/internal/plugin/zone_graph_feywild_crossing_test.go +++ b/internal/plugin/zone_graph_feywild_crossing_test.go @@ -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 diff --git a/internal/plugin/zone_graph_test.go b/internal/plugin/zone_graph_test.go index e56c42e..2b676d5 100644 --- a/internal/plugin/zone_graph_test.go +++ b/internal/plugin/zone_graph_test.go @@ -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"