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: 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user