mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Every ownership lookup in the adventure module keys on a user id, and a party member owns neither the expedition row nor the zone run. So each player-facing read quietly told them they were not playing. Rewire them. Reads a member should see resolve through activeExpeditionFor / activeZoneRunFor. Leader-only actions answer with copy that names the leader instead of denying the expedition. Three busy-guards had to start refusing a member outright: !zone enter, !expedition start and !sell all keyed on the sender's own row, so a seated member could open a private dungeon, outfit a rival expedition, or run a shop from the boss room. Four things the rewire itself exposed: !resources looks like a read but seed-persists harvest nodes, and saveHarvestNodes rewrites the entire region_state blob — kills, event gates, temporal stack — last-write-wins. Reaching it as a member would revert the leader's walk from a stale snapshot. Persist only for the owner; seedRoomNodes is pure, so a member re-derives the same nodes. !zone taunt moves the party's shared mood, which is intended and safe: applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale writes an absolute gm_mood from the caller's snapshot, and every command takes the *sender's* lock — a member running it against the leader's run holds the wrong mutex. The owner check now lives on that function. A seat outlives status='active'. releaseParty deliberately skips the seven-day 'extracting' limbo, so the roster persists while activeExpeditionFor goes blind — long enough for a member to open a run that wins every lookup once the leader !resumes. seatedExpeditionFor spans both statuses; it is what the busy-guards ask. !expedition run was still member-blind. It is the same walk as !zone advance, reached by its other name. isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports isLeader=false for a player with no run anywhere, so the bare test sends a solo player to go ask their leader. Golden byte-identical; solo T1 expedition clears end-to-end.
229 lines
7.8 KiB
Go
229 lines
7.8 KiB
Go
package plugin
|
|
|
|
// Phase G5 — graph-mode dispatch glue. Bridges the navigation helpers
|
|
// in zone_graph_nav.go into the !zone advance / !zone go command flows.
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// advanceTransitionGraph is the graph-mode replacement for
|
|
// markRoomCleared inside zoneCmdAdvance. It records that the current
|
|
// room is cleared, then decides what comes next based on the player's
|
|
// outgoing edges in the registered (or legacy-compiled) zone graph:
|
|
//
|
|
// - 0 outgoing edges → run completes. complete=true. Caller emits
|
|
// the run-complete narration. boss_defeated is set when the
|
|
// current node is the graph's boss.
|
|
// - 1 unlocked outgoing edge → auto-advance to that node. next is
|
|
// the resolved next-room type for the teaser block.
|
|
// - 2+ outgoing edges OR 1 locked edge → fork prompt is persisted
|
|
// and forkMessage is the rendered menu. Caller emits it instead
|
|
// of the next-room teaser. The player commits via !zone go <n>.
|
|
//
|
|
// A single locked edge still surfaces as a fork prompt: the player
|
|
// would otherwise be stuck with no signal that progression is gated.
|
|
func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefinition, c *DnDCharacter) (next RoomType, forkMessage string, complete bool, err error) {
|
|
g, ok := loadZoneGraph(zone.ID)
|
|
if !ok {
|
|
err = fmt.Errorf("no graph for zone %q", zone.ID)
|
|
return
|
|
}
|
|
currentNode := run.CurrentNode
|
|
if currentNode == "" {
|
|
// Should not happen post-G4 dual-write; fall back to derived id
|
|
// so we never crash on an old row that snuck through.
|
|
currentNode = deriveLegacyNodeID(run.ZoneID, run.CurrentRoom)
|
|
}
|
|
|
|
if _, rerr := recordRoomCleared(run.RunID); rerr != nil {
|
|
err = rerr
|
|
return
|
|
}
|
|
|
|
ctx := buildUnlockCtx(c, run.RunID, currentNode)
|
|
choices := evaluateForkEdges(g, currentNode, ctx)
|
|
if len(choices) == 0 {
|
|
// Dead-end or boss node. Boss completes with boss_defeated=1;
|
|
// non-boss dead-ends just terminate the run quietly.
|
|
isBoss := g.Nodes[currentNode].IsBoss
|
|
if cerr := completeRunAtNode(run.RunID, isBoss); cerr != nil {
|
|
err = cerr
|
|
return
|
|
}
|
|
complete = true
|
|
return
|
|
}
|
|
|
|
unlocked := unlockedChoices(choices)
|
|
if len(choices) == 1 && len(unlocked) == 1 {
|
|
// Plain auto-advance — no fork to prompt for.
|
|
chosen := choices[0]
|
|
if _, aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
|
|
err = aerr
|
|
return
|
|
}
|
|
// G6d — region boundary hook. Multi-region branching graphs
|
|
// can cross a region edge mid-run; emit the existing transit
|
|
// flavor + log so the player gets the same "you've crossed
|
|
// into X" beat the !region travel command produces. Doesn't
|
|
// retire the run (the graph is the authoritative state) — it
|
|
// just narrates the crossing.
|
|
fireGraphRegionTransition(run.UserID, g.Nodes[currentNode], g.Nodes[chosen.To])
|
|
next = nodeKindToRoomType(g.Nodes[chosen.To].Kind)
|
|
return
|
|
}
|
|
|
|
pf := pendingFork{
|
|
PendingAt: currentNode,
|
|
Options: choices,
|
|
}
|
|
if werr := writePendingFork(run.RunID, pf); werr != nil {
|
|
err = werr
|
|
return
|
|
}
|
|
forkMessage = renderForkPrompt(zone, pf)
|
|
return
|
|
}
|
|
|
|
// fireGraphRegionTransition compares the from/to graph node region ids
|
|
// and, when they differ, mirrors the existing !region travel surface:
|
|
// updates Expedition.CurrentRegion + visited list and writes a transit
|
|
// log entry. Unlike full !region travel, it does NOT burn supplies,
|
|
// advance the day, or retire/spawn DungeonRuns — the graph is the
|
|
// authoritative run state, and the player crossed via the graph edge,
|
|
// not a separate transit day. Standalone (no expedition) is a no-op.
|
|
func fireGraphRegionTransition(userID string, from, to ZoneNode) {
|
|
if from.RegionID == to.RegionID {
|
|
return
|
|
}
|
|
if to.RegionID == "" {
|
|
return
|
|
}
|
|
exp, err := getActiveExpedition(id.UserID(userID))
|
|
if err != nil || exp == nil {
|
|
return
|
|
}
|
|
if exp.CurrentRegion == to.RegionID {
|
|
return
|
|
}
|
|
if err := setCurrentRegion(exp, to.RegionID); err != nil {
|
|
return
|
|
}
|
|
_, _ = MarkRegionVisited(exp, to.RegionID)
|
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
|
fmt.Sprintf("graph region transit: %s → %s (node %s → %s)",
|
|
from.RegionID, to.RegionID, from.NodeID, to.NodeID), "")
|
|
}
|
|
|
|
func unlockedChoices(cs []pendingChoice) []pendingChoice {
|
|
out := make([]pendingChoice, 0, len(cs))
|
|
for _, c := range cs {
|
|
if c.Unlocked {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nodeKindToRoomType bridges the graph node kinds back to the legacy
|
|
// RoomType enum used by !zone advance's teaser. Stays in lockstep with
|
|
// roomTypeToNodeKind in zone_graph.go.
|
|
func nodeKindToRoomType(k ZoneNodeKind) RoomType {
|
|
switch k {
|
|
case NodeKindEntry:
|
|
return RoomEntry
|
|
case NodeKindExploration, NodeKindSecret, NodeKindHarvest, NodeKindRestCamp, NodeKindFork, NodeKindMerge:
|
|
return RoomExploration
|
|
case NodeKindTrap:
|
|
return RoomTrap
|
|
case NodeKindElite:
|
|
return RoomElite
|
|
case NodeKindBoss:
|
|
return RoomBoss
|
|
}
|
|
return RoomExploration
|
|
}
|
|
|
|
// zoneCmdGo handles `!zone go <n>` (alias `!zone choose <n>`). Reads
|
|
// the pending fork from node_choices, validates the chosen edge is
|
|
// unlocked, advances the run state to the chosen node, and emits the
|
|
// arrival teaser. The player still has to !zone advance to resolve
|
|
// the new room — same cadence as auto-advance.
|
|
func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
|
|
run, isLeader, err := activeZoneRunFor(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
|
}
|
|
if run == nil {
|
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
|
}
|
|
if !isLeader {
|
|
// The fork is one choice for one party, and the run is the leader's row.
|
|
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
|
|
}
|
|
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
|
|
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
|
|
}
|
|
pf, derr := decodePendingFork(run.NodeChoices)
|
|
if derr != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
|
|
}
|
|
if pf == nil {
|
|
return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender))
|
|
}
|
|
rest = strings.TrimSpace(rest)
|
|
if rest == "" {
|
|
zone := zoneOrFallback(run.ZoneID)
|
|
return p.SendDM(ctx.Sender, "**Pick a path:**\n\n"+renderForkPrompt(zone, *pf)+"\n\n_Use `!zone go <n>`._")
|
|
}
|
|
choice := atoiSafe(rest)
|
|
if choice <= 0 {
|
|
return p.SendDM(ctx.Sender, "Choice must be a number from the menu (`!zone go 1`, `!zone go 2`, …).")
|
|
}
|
|
chosen, cerr := resolveForkChoice(pf, choice)
|
|
if cerr != nil {
|
|
return p.SendDM(ctx.Sender, cerr.Error())
|
|
}
|
|
nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
|
|
if aerr != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
|
|
}
|
|
markActedToday(ctx.Sender)
|
|
g, _ := loadZoneGraph(run.ZoneID)
|
|
zone := zoneOrFallback(run.ZoneID)
|
|
fromNode := g.Nodes[run.CurrentNode]
|
|
nextNode := g.Nodes[chosen.To]
|
|
fireGraphRegionTransition(run.UserID, fromNode, nextNode)
|
|
nextRoom := nodeKindToRoomType(nextNode.Kind)
|
|
var b strings.Builder
|
|
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
|
|
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))
|
|
}
|
|
b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label))
|
|
if nextRoom == RoomBoss {
|
|
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {
|
|
b.WriteString(line)
|
|
b.WriteString("\n\n")
|
|
}
|
|
} else {
|
|
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, nextIdx); line != "" {
|
|
b.WriteString(line)
|
|
b.WriteString("\n\n")
|
|
}
|
|
}
|
|
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
|
|
switch nextRoom {
|
|
case RoomBoss:
|
|
b.WriteString("`!fight` when you're ready for the boss.")
|
|
case RoomElite:
|
|
b.WriteString("`!fight` when ready.")
|
|
default:
|
|
b.WriteString(continueHint(ctx.Sender))
|
|
}
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|