mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
zones: make locked doors openable and stop autopilot stalling on them
Locks were fully implemented as pass/fail gates and nothing else. A Perception or stat check rolls once per (run, edge), seeded so it can't be reload-scummed — that half shipped in G5, the counterweight never did. A bad roll simply deleted a branch of the graph for the rest of the run, worst for a solo low-WIS character who quietly loses routes they never learn existed. Three changes, one theme: a die roll should not be able to permanently wall a player. Party's best stat answers the check. evaluateEdgeLock read only the acting character's mods, which made a party's rogue and its hired scout decorative at every lock. Fold the whole roster — Pete included, since excluding him would make hiring a scout worth less than the coins it costs — and credit whoever got it open in the fork menu. Thieves' tools as the escape hatch. A utility item (not a ConsumableDef, or the fight engine would spend them for you) sold on Luigi's supplies shelf, consumed by `!zone unlock <n>`. Deliberately not a skeleton key: tools answer the two dice-driven locks only. A key lock is a quest token, a level-min lock is progression, a region-clear lock is structure — none of those are "you rolled badly", so none of them are pickable. Autopilot picks a route instead of parking. The fork timeout was 8h, which reads as "the player gets first say" and behaves as "the expedition stops for a third of a day, at every fork" — a multi-day expedition crosses a lot of forks. 30m keeps a genuine first say for anyone at the keyboard. It now ranks by unvisited-then-edge-weight rather than taking whatever the graph author happened to list first, spends tools when every route is locked, and backtracks a room when it can't do even that, rather than idling into the 24h reaper and losing the player days of progress to a roll they never saw. Sim A/B, same seeds, 90 runs/arm: 43.3% -> 42.2% clear, a single run flipping and well inside the documented noise floor. A party+companion+pet arm runs 31/32 clean through the new roster-folding path.
This commit is contained in:
@@ -19,6 +19,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
|
||||
@@ -79,14 +81,42 @@ func decodePendingFork(m map[string]any) (*pendingFork, error) {
|
||||
// test them without going through the live DB. Filled in by
|
||||
// evaluateForkEdges from the live run + character.
|
||||
type edgeUnlockCtx struct {
|
||||
RunID string
|
||||
FromNode string
|
||||
CharLevel int
|
||||
AbilityMods [6]int // STR, DEX, CON, INT, WIS, CHA — matches DnDCharacter.Modifiers()
|
||||
RunID string
|
||||
FromNode string
|
||||
CharLevel int
|
||||
// AbilityMods is the *party's best* modifier per ability — STR, DEX, CON,
|
||||
// INT, WIS, CHA, matching DnDCharacter.Modifiers(). A door doesn't care
|
||||
// which set of eyes spotted the seam, and reading only the leader's sheet
|
||||
// meant a party's rogue and its hired scout were decorative at every lock.
|
||||
// AbilityWho names whoever supplied each best, empty when it's the leader,
|
||||
// so the fork prompt can say who got it open.
|
||||
AbilityMods [6]int
|
||||
AbilityWho [6]string
|
||||
InventoryNames map[string]bool
|
||||
Expedition *Expedition
|
||||
}
|
||||
|
||||
// creditFor names the party member whose ability carried a check, phrased for
|
||||
// the fork prompt. Empty when the acting character managed it alone — there is
|
||||
// nobody to credit and the menu stays quiet.
|
||||
func (c edgeUnlockCtx) creditFor(ability int) string {
|
||||
if who := c.AbilityWho[ability]; who != "" {
|
||||
return who + " got it open"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// bestAbility folds one body's modifiers into the running party-best, recording
|
||||
// the contributor's name for any ability it improves on.
|
||||
func (c *edgeUnlockCtx) bestAbility(mods [6]int, who string) {
|
||||
for i, m := range mods {
|
||||
if m > c.AbilityMods[i] {
|
||||
c.AbilityMods[i] = m
|
||||
c.AbilityWho[i] = who
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateEdgeLock returns whether the player can take this edge right
|
||||
// now, with a player-facing reason on failure. Per plan §G5: Perception
|
||||
// rolls fire once at fork-arrival (deterministic seed) and the result
|
||||
@@ -101,7 +131,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
|
||||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||||
total := roll + ctx.AbilityMods[4]
|
||||
if total >= dc {
|
||||
return true, ""
|
||||
return true, ctx.creditFor(4)
|
||||
}
|
||||
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
|
||||
case LockKey:
|
||||
@@ -138,7 +168,7 @@ func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason stri
|
||||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||||
total := roll + ctx.AbilityMods[idx]
|
||||
if total >= dc {
|
||||
return true, ""
|
||||
return true, ctx.creditFor(idx)
|
||||
}
|
||||
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
|
||||
}
|
||||
@@ -219,10 +249,45 @@ func buildUnlockCtx(c *DnDCharacter, runID, fromNode string) edgeUnlockCtx {
|
||||
}
|
||||
if exp, err := getActiveExpedition(c.UserID); err == nil && exp != nil {
|
||||
ctx.Expedition = exp
|
||||
foldPartyAbilities(&ctx, exp, c.UserID)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// foldPartyAbilities raises ctx.AbilityMods to the best any body on the
|
||||
// expedition can offer. The companion counts: he is a seat that walks the same
|
||||
// corridor, and excluding him would make hiring a scout worth less than the
|
||||
// coins it costs.
|
||||
//
|
||||
// Errors are swallowed rather than propagated — a roster read that fails leaves
|
||||
// the leader's own mods standing, which is exactly the pre-party behaviour and
|
||||
// never harder than it was.
|
||||
func foldPartyAbilities(ctx *edgeUnlockCtx, exp *Expedition, acting id.UserID) {
|
||||
seats, err := expeditionParty(exp.ID, string(exp.UserID))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, s := range seats {
|
||||
if s.Kind == SeatCompanion {
|
||||
class, level := companionLoadout(exp.ID)
|
||||
ctx.bestAbility(companionSheet(class, level).Modifiers(), companionDisplayName)
|
||||
continue
|
||||
}
|
||||
if s.UserID == acting {
|
||||
continue // whoever we built the ctx from is already the baseline
|
||||
}
|
||||
mate, err := LoadDnDCharacter(s.UserID)
|
||||
if err != nil || mate == nil {
|
||||
continue
|
||||
}
|
||||
name, _ := loadDisplayName(s.UserID)
|
||||
if name == "" {
|
||||
name = string(s.UserID)
|
||||
}
|
||||
ctx.bestAbility(mate.Modifiers(), name)
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateForkEdges walks all outgoing edges of fromNode in the graph
|
||||
// and produces a pending-choice list ready to be persisted. Locked
|
||||
// edges that have a Hint stay in the menu (the player needs the
|
||||
@@ -288,6 +353,10 @@ func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
|
||||
b.WriteString(fmt.Sprintf("**%s — Path divides.** Choose with `!zone go <n>`.\n\n", zone.Display))
|
||||
for _, c := range pf.Options {
|
||||
switch {
|
||||
case c.Unlocked && c.Reason != "":
|
||||
// A party-mate's ability beat the check — say so, so the player can
|
||||
// see what the roster bought them.
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s _(%s)_\n", c.Index, c.Label, c.Reason))
|
||||
case c.Unlocked:
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s\n", c.Index, c.Label))
|
||||
case c.Hint != "":
|
||||
|
||||
Reference in New Issue
Block a user