mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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
This commit is contained in:
@@ -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 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.
|
// enemyAttackProfile returns the attack rolls a creature makes on its turn.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -506,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
|
||||||
@@ -626,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.
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user