mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
Four things the review found, all of them the code telling a player something that isn't true. A run that walks into a dead end filed its ending as "cleared" whatever the caller said, so both the liveblog and the summary reported a clear for a party that merely ran out of map — and the end beat is first-writer-wins, so nothing later could take it back. It says "cleared" only for a boss now. The re-offer branches of babysit and resume returned a zero cost, so a player who was in fact charged read "0 coins" in the verdict. Both re-quote the price they actually took. And the realm-firsts reseed retired its one-shot job even when the read under it had failed, which on a transient fault at Init would have left the ledger mis-dated permanently. The read now says whether it worked, and the job stays open when it didn't. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
518 lines
16 KiB
Go
518 lines
16 KiB
Go
package plugin
|
||
|
||
// Phase G5 — branching-zone navigation surface.
|
||
//
|
||
// Wires the graph types from G2/G3/G4 into the !zone advance flow:
|
||
// when the player clears a room with 2+ outgoing edges, write a pending
|
||
// fork prompt to dnd_zone_run.node_choices and DM the menu. !zone go <n>
|
||
// consumes the choice, validates the chosen edge is unlocked, and
|
||
// advances the run state to the chosen node.
|
||
//
|
||
// G9a retired the GOGOBEE_BRANCHING_ZONES POC gate: graph mode is the
|
||
// only runtime path now that all 9 zones have hand-authored graphs.
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"gogobee/internal/db"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// pendingFork is the typed shape of dnd_zone_run.node_choices when the
|
||
// player is paused at a fork. Persisted as JSON inside the
|
||
// map[string]any NodeChoices column; helpers below round-trip via JSON
|
||
// so the column stays tolerant of hand-authored / future shapes.
|
||
type pendingFork struct {
|
||
PendingAt string `json:"pending_at"`
|
||
Options []pendingChoice `json:"options"`
|
||
}
|
||
|
||
type pendingChoice struct {
|
||
Index int `json:"index"`
|
||
To string `json:"to"`
|
||
Label string `json:"label"`
|
||
Unlocked bool `json:"unlocked"`
|
||
Hint string `json:"hint"`
|
||
Lock string `json:"lock"`
|
||
Reason string `json:"reason,omitempty"`
|
||
}
|
||
|
||
// encodePendingFork turns a pendingFork into the map[string]any shape
|
||
// that DungeonRun.NodeChoices uses, so persisting just goes through
|
||
// the existing json marshaling in markRoomCleared / advanceZoneRunNode.
|
||
func encodePendingFork(pf pendingFork) (map[string]any, error) {
|
||
raw, err := json.Marshal(pf)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := map[string]any{}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// decodePendingFork reads a pendingFork out of NodeChoices. Returns
|
||
// (nil, nil) when the column is empty or doesn't carry a fork prompt.
|
||
func decodePendingFork(m map[string]any) (*pendingFork, error) {
|
||
if len(m) == 0 {
|
||
return nil, nil
|
||
}
|
||
if _, ok := m["pending_at"]; !ok {
|
||
return nil, nil
|
||
}
|
||
raw, err := json.Marshal(m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var pf pendingFork
|
||
if err := json.Unmarshal(raw, &pf); err != nil {
|
||
return nil, err
|
||
}
|
||
return &pf, nil
|
||
}
|
||
|
||
// edgeUnlockCtx bundles everything the unlock evaluators need so we can
|
||
// 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 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
|
||
// is committed for the lifetime of the prompt. Re-renders show the
|
||
// same outcome so the player can't reload to retry.
|
||
func evaluateEdgeLock(e ZoneEdge, ctx edgeUnlockCtx) (unlocked bool, reason string) {
|
||
switch e.Lock {
|
||
case "", LockNone:
|
||
return true, ""
|
||
case LockPerception:
|
||
dc := lockDataInt(e.LockData, "dc", 12)
|
||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||
total := roll + ctx.AbilityMods[4]
|
||
if total >= dc {
|
||
return true, ctx.creditFor(4)
|
||
}
|
||
return false, fmt.Sprintf("Perception %d vs DC %d", total, dc)
|
||
case LockKey:
|
||
key := strings.ToLower(strings.TrimSpace(lockDataString(e.LockData, "key_id")))
|
||
if key == "" {
|
||
return false, "missing key (no key_id authored)"
|
||
}
|
||
if ctx.InventoryNames[key] {
|
||
return true, ""
|
||
}
|
||
return false, "you don't have the key"
|
||
case LockLevelMin:
|
||
min := lockDataInt(e.LockData, "min_level", 1)
|
||
if ctx.CharLevel >= min {
|
||
return true, ""
|
||
}
|
||
return false, fmt.Sprintf("requires level %d (you are %d)", min, ctx.CharLevel)
|
||
case LockRegionClear:
|
||
region := lockDataString(e.LockData, "region_id")
|
||
if region == "" {
|
||
return false, "no region_id authored"
|
||
}
|
||
if ctx.Expedition != nil && IsRegionCleared(ctx.Expedition, region) {
|
||
return true, ""
|
||
}
|
||
return false, "another region must be cleared first"
|
||
case LockStatCheck:
|
||
dc := lockDataInt(e.LockData, "dc", 12)
|
||
stat := strings.ToUpper(lockDataString(e.LockData, "stat"))
|
||
idx := abilityIndex(stat)
|
||
if idx < 0 {
|
||
return false, "invalid stat_check authoring"
|
||
}
|
||
roll := perceptionRollForEdge(ctx.RunID, ctx.FromNode, e.To)
|
||
total := roll + ctx.AbilityMods[idx]
|
||
if total >= dc {
|
||
return true, ctx.creditFor(idx)
|
||
}
|
||
return false, fmt.Sprintf("%s %d vs DC %d", stat, total, dc)
|
||
}
|
||
return false, "unknown lock type"
|
||
}
|
||
|
||
// abilityIndex maps a stat short-name to the Modifiers() slot.
|
||
func abilityIndex(s string) int {
|
||
switch strings.ToUpper(s) {
|
||
case "STR":
|
||
return 0
|
||
case "DEX":
|
||
return 1
|
||
case "CON":
|
||
return 2
|
||
case "INT":
|
||
return 3
|
||
case "WIS":
|
||
return 4
|
||
case "CHA":
|
||
return 5
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func lockDataInt(m map[string]any, key string, def int) int {
|
||
v, ok := m[key]
|
||
if !ok {
|
||
return def
|
||
}
|
||
switch n := v.(type) {
|
||
case int:
|
||
return n
|
||
case int64:
|
||
return int(n)
|
||
case float64:
|
||
return int(n)
|
||
}
|
||
return def
|
||
}
|
||
|
||
func lockDataString(m map[string]any, key string) string {
|
||
v, ok := m[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// perceptionRollForEdge synthesizes a stable 1d20 result for a given
|
||
// (run, from-node, to-node). SHA1 keeps the distribution clean and
|
||
// avoids math/rand state contention. Re-arrival at the same fork in
|
||
// the same run reproduces the same roll, so the player can't reload
|
||
// to retry a failed Perception (plan §G5).
|
||
func perceptionRollForEdge(runID, fromNode, toNode string) int {
|
||
h := sha1.Sum([]byte(runID + "|" + fromNode + "|" + toNode))
|
||
return int(binary.BigEndian.Uint16(h[:2])%20) + 1
|
||
}
|
||
|
||
// buildUnlockCtx assembles an edgeUnlockCtx from the live character +
|
||
// expedition state. Inventory item names are lower-cased for matching
|
||
// against lock_data.key_id.
|
||
func buildUnlockCtx(c *DnDCharacter, runID, fromNode string) edgeUnlockCtx {
|
||
ctx := edgeUnlockCtx{
|
||
RunID: runID,
|
||
FromNode: fromNode,
|
||
CharLevel: c.Level,
|
||
AbilityMods: c.Modifiers(),
|
||
InventoryNames: map[string]bool{},
|
||
}
|
||
if items, err := loadAdvInventory(c.UserID); err == nil {
|
||
for _, it := range items {
|
||
ctx.InventoryNames[strings.ToLower(it.Name)] = true
|
||
}
|
||
}
|
||
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
|
||
// teaser); locked edges without any hint at all are still listed but
|
||
// reasoned-out.
|
||
func evaluateForkEdges(g ZoneGraph, fromNode string, ctx edgeUnlockCtx) []pendingChoice {
|
||
outs := g.outgoingEdges(fromNode)
|
||
if len(outs) == 0 {
|
||
return nil
|
||
}
|
||
choices := make([]pendingChoice, 0, len(outs))
|
||
for i, e := range outs {
|
||
unlocked, reason := evaluateEdgeLock(e, ctx)
|
||
toNode := g.Nodes[e.To]
|
||
label := toNode.Label
|
||
if label == "" {
|
||
label = prettyNodeKind(toNode.Kind)
|
||
}
|
||
choices = append(choices, pendingChoice{
|
||
Index: i + 1,
|
||
To: e.To,
|
||
Label: label,
|
||
Unlocked: unlocked,
|
||
Hint: e.Hint,
|
||
Lock: string(e.Lock),
|
||
Reason: reason,
|
||
})
|
||
}
|
||
return choices
|
||
}
|
||
|
||
func prettyNodeKind(k ZoneNodeKind) string {
|
||
switch k {
|
||
case NodeKindEntry:
|
||
return "Entry"
|
||
case NodeKindExploration:
|
||
return "Exploration"
|
||
case NodeKindTrap:
|
||
return "Trap"
|
||
case NodeKindElite:
|
||
return "Elite"
|
||
case NodeKindBoss:
|
||
return "Boss"
|
||
case NodeKindHarvest:
|
||
return "Harvest"
|
||
case NodeKindRestCamp:
|
||
return "Rest Camp"
|
||
case NodeKindSecret:
|
||
return "Secret"
|
||
case NodeKindFork:
|
||
return "Fork"
|
||
case NodeKindMerge:
|
||
return "Merge"
|
||
}
|
||
return "Room"
|
||
}
|
||
|
||
// renderForkPrompt is the player-facing menu rendered from a pendingFork.
|
||
// Locked edges with a Hint show the hint as a teaser; locked edges
|
||
// without a hint just show "(locked)".
|
||
func renderForkPrompt(zone ZoneDefinition, pf pendingFork) string {
|
||
var b strings.Builder
|
||
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 != "":
|
||
b.WriteString(fmt.Sprintf("**%d.** %s _(locked — %s)_\n", c.Index, c.Label, c.Hint))
|
||
default:
|
||
b.WriteString(fmt.Sprintf("**%d.** %s _(locked)_\n", c.Index, c.Label))
|
||
}
|
||
}
|
||
return strings.TrimRight(b.String(), "\n")
|
||
}
|
||
|
||
// recordRoomCleared appends the current room to rooms_cleared and
|
||
// bumps last_action_at, without advancing current_node.
|
||
// Used by the graph-mode fork path: clearing the room is a separate
|
||
// step from choosing where to go next. Returns the updated DungeonRun
|
||
// snapshot reloaded post-write so callers see fresh fields.
|
||
func recordRoomCleared(runID string) (*DungeonRun, error) {
|
||
r, err := getZoneRun(runID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if r == nil {
|
||
return nil, ErrNoActiveRun
|
||
}
|
||
if !r.IsActive() {
|
||
return nil, ErrNoActiveRun
|
||
}
|
||
cleared := appendClearedRoom(r.RoomsCleared, r.CurrentRoom)
|
||
clearedJSON, _ := json.Marshal(cleared)
|
||
if _, err := db.Get().Exec(`
|
||
UPDATE dnd_zone_run
|
||
SET rooms_cleared = ?,
|
||
last_action_at = CURRENT_TIMESTAMP
|
||
WHERE run_id = ?`, string(clearedJSON), runID); err != nil {
|
||
return nil, err
|
||
}
|
||
r.RoomsCleared = cleared
|
||
return r, nil
|
||
}
|
||
|
||
// writePendingFork persists a pendingFork into node_choices for the
|
||
// given run. Replaces any prior fork — there is only ever one pending
|
||
// at a time.
|
||
func writePendingFork(runID string, pf pendingFork) error {
|
||
m, err := encodePendingFork(pf)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
raw, err := json.Marshal(m)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = db.Get().Exec(`
|
||
UPDATE dnd_zone_run
|
||
SET node_choices = ?,
|
||
last_action_at = CURRENT_TIMESTAMP
|
||
WHERE run_id = ?`, string(raw), runID)
|
||
return err
|
||
}
|
||
|
||
// clearPendingFork wipes node_choices. Called when the player commits a
|
||
// choice via !zone go and we transition to the chosen node.
|
||
func clearPendingFork(runID string) error {
|
||
_, err := db.Get().Exec(`
|
||
UPDATE dnd_zone_run
|
||
SET node_choices = '{}',
|
||
last_action_at = CURRENT_TIMESTAMP
|
||
WHERE run_id = ?`, runID)
|
||
return err
|
||
}
|
||
|
||
// completeRunAtNode marks the run finished at the current node. Used
|
||
// when graph-mode advance hits a 0-outgoing-edge boss or dead-end.
|
||
// boss=true sets boss_defeated; dead-ends leave it false.
|
||
func completeRunAtNode(runID string, boss bool) error {
|
||
bossI := 0
|
||
if boss {
|
||
bossI = 1
|
||
}
|
||
// Only a boss kill is a clear. This function also closes a non-boss
|
||
// dead-end — the party simply ran out of map — and beatRunEnd is
|
||
// first-writer-wins, so calling that "cleared" would put a lie in the
|
||
// liveblog and the run summary that nothing downstream could correct.
|
||
// "ended" is deliberately outside the {cleared,died,retreated,abandoned}
|
||
// set: the prompt renderer already degrades an unknown outcome to a
|
||
// neutral "the run ended", which is exactly what happened.
|
||
if run, _ := getZoneRun(runID); run != nil {
|
||
outcome := "ended"
|
||
if boss {
|
||
outcome = "cleared"
|
||
}
|
||
beatRunEnd(run, outcome)
|
||
}
|
||
_, err := db.Get().Exec(`
|
||
UPDATE dnd_zone_run
|
||
SET boss_defeated = ?,
|
||
completed_at = CURRENT_TIMESTAMP,
|
||
last_action_at = CURRENT_TIMESTAMP
|
||
WHERE run_id = ?`, bossI, runID)
|
||
return err
|
||
}
|
||
|
||
// advanceZoneRunNode moves a run to nextNode: records the node entry in
|
||
// visited_nodes, sets current_node, bumps the traversal counter, and clears
|
||
// any pending fork prompt. Caller is expected to have already called
|
||
// recordRoomCleared for the prior node.
|
||
//
|
||
// Returns the moved-to room's path index so callers can label it without
|
||
// assuming CurrentRoom+1 — an assumption that only holds while the player
|
||
// is walking the frontier.
|
||
func advanceZoneRunNode(runID, nextNode string) (int, error) {
|
||
r, err := getZoneRun(runID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if r == nil {
|
||
return 0, ErrNoActiveRun
|
||
}
|
||
visited := appendVisited(r.VisitedNodes, nextNode)
|
||
visitedJSON, _ := json.Marshal(visited)
|
||
if _, err := db.Get().Exec(`
|
||
UPDATE dnd_zone_run
|
||
SET current_node = ?,
|
||
visited_nodes = ?,
|
||
node_choices = '{}',
|
||
rooms_traversed = rooms_traversed + 1,
|
||
last_action_at = CURRENT_TIMESTAMP
|
||
WHERE run_id = ?`,
|
||
nextNode, string(visitedJSON), runID); err != nil {
|
||
return 0, err
|
||
}
|
||
idx := pathIndexOf(visited, nextNode)
|
||
// Every forward move in the game funnels through here — auto-advance, a
|
||
// player's `!zone go`, and the autopilot's stale-fork pick alike — which is
|
||
// what makes this the one honest place to say "the party is now in room N".
|
||
beatRoom(r, nextNode, idx, "entered")
|
||
return idx, nil
|
||
}
|
||
|
||
// resolveForkChoice takes a 1-based choice index against a pending
|
||
// fork and returns the chosen pendingChoice if it's both present and
|
||
// unlocked. Errors are formatted for direct DM display.
|
||
func resolveForkChoice(pf *pendingFork, choice int) (pendingChoice, error) {
|
||
if pf == nil || len(pf.Options) == 0 {
|
||
return pendingChoice{}, fmt.Errorf("no fork pending")
|
||
}
|
||
if choice < 1 || choice > len(pf.Options) {
|
||
return pendingChoice{}, fmt.Errorf("choice %d out of range (1–%d)", choice, len(pf.Options))
|
||
}
|
||
c := pf.Options[choice-1]
|
||
if !c.Unlocked {
|
||
if c.Reason != "" {
|
||
return c, fmt.Errorf("path locked: %s", c.Reason)
|
||
}
|
||
return c, fmt.Errorf("path locked")
|
||
}
|
||
return c, nil
|
||
}
|