diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/act_agility.go | 4 | ||||
| -rw-r--r-- | internal/game/act_effects.go | 10 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 11 | ||||
| -rw-r--r-- | internal/game/casino.go | 9 | ||||
| -rw-r--r-- | internal/game/cmd_close.go | 5 | ||||
| -rw-r--r-- | internal/game/cmd_dig.go | 10 | ||||
| -rw-r--r-- | internal/game/cmd_remove.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_room.go | 3 | ||||
| -rw-r--r-- | internal/game/cmd_room_insert.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_room_mob.go | 11 | ||||
| -rw-r--r-- | internal/game/cmd_room_object.go | 16 | ||||
| -rw-r--r-- | internal/game/cmd_room_remove.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_room_spawn.go | 11 | ||||
| -rw-r--r-- | internal/game/cmd_shop.go | 17 | ||||
| -rw-r--r-- | internal/game/cmd_swapid.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_trigger_utility.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_undig.go | 2 | ||||
| -rw-r--r-- | internal/game/economy.go | 36 | ||||
| -rw-r--r-- | internal/game/game.go | 38 | ||||
| -rw-r--r-- | internal/game/production_engine.go | 12 | ||||
| -rw-r--r-- | internal/game/room_write.go | 13 | ||||
| -rw-r--r-- | internal/game/sys_triggers.go | 18 | ||||
| -rw-r--r-- | internal/game/tick.go | 31 |
23 files changed, 206 insertions, 79 deletions
diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index fd16102..9842365 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -159,6 +159,10 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs if p.HP < 1 { p.HP = 1 } + // Match the combat/hazard damage paths: without StartRegen the first + // RegenTick sees a zero timer and heals on the very next tick instead of + // waiting the full regen interval. + p.StartRegen() sess.WriteLine(g.colorize(sess, "damage_taken", fmt.Sprintf("You take %s damage. HP: %d/%d", g.dmgDisplay(sess, damage), p.HP, p.MaxHP()))) diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go index a107320..e51deab 100644 --- a/internal/game/act_effects.go +++ b/internal/game/act_effects.go @@ -173,10 +173,16 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste } // 10. credits (signed). A negative cost was preflighted for affordability, - // so the debit is unconditional here. + // so the debit is unconditional here. Clamp defensively anyway: a + // multi-step batch that shares one tick must never drive the balance + // negative, an invariant every wallet/economy path relies on. if step.Credits != 0 { if step.Credits < 0 { - p.Credits += step.Credits + debit := step.Credits + if p.Credits+debit < 0 { + debit = -p.Credits + } + p.Credits += debit g.saveCharacter(p) } else { g.grantCredits(p, step.Credits) diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go index 9ae9959..239e6f5 100644 --- a/internal/game/act_talk.go +++ b/internal/game/act_talk.go @@ -192,7 +192,16 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { } td, ok := p.Action.Data.(*behavior.TalkData) - if !ok || td.Cfg == nil { + if !ok { + g.cancelAction(p) + sess.SetState(net.StateGame) + g.writePrompt(sess) + return + } + // The estate-directory prompt is a talk action with no dialogue tree + // (Cfg == nil), so it must be allowed through to its own handler below + // instead of being cancelled as a malformed talk node. + if td.Cfg == nil && !td.EstateDirectory { g.cancelAction(p) sess.SetState(net.StateGame) g.writePrompt(sess) diff --git a/internal/game/casino.go b/internal/game/casino.go index 4dfced6..dcf4273 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -932,7 +932,14 @@ func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool if reel < snapshot.StoppedReels { value = snapshot.Grid[row][reel] } - line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", 7-len(value)) + " " + frameVertical + // Pad to the fixed 7-cell reel width; a malformed profile could + // otherwise make this negative and panic. Validation caps the id + // length, so this is a defence-in-depth backstop. + pad := 7 - len(value) + if pad < 0 { + pad = 0 + } + line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical } lines = append(lines, line) } diff --git a/internal/game/cmd_close.go b/internal/game/cmd_close.go index 217ae9a..20c49ba 100644 --- a/internal/game/cmd_close.go +++ b/internal/game/cmd_close.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" @@ -60,7 +59,7 @@ func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -74,7 +73,7 @@ func (g *Game) executeClose(sess *net.Session, args []string, rawInput string) { if tok2 { targetData, terr := yaml.Marshal(targetRoom) if terr == nil { - if werr := os.WriteFile(targetPath, targetData, 0644); werr == nil { + if werr := writeRoomFile(targetPath, targetData); werr == nil { reciprocalRemoved = true } } diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index 1497c34..c58ba63 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -63,7 +63,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -75,7 +75,9 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { if ok { conflictData, cerr := yaml.Marshal(conflictRoom) if cerr == nil { - os.WriteFile(conflictPath, conflictData, 0644) + if werr := writeRoomFile(conflictPath, conflictData); werr != nil { + sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", conflictID, werr)) + } } } } @@ -161,7 +163,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error creating directory.") return } - if err := os.WriteFile(newPath, data, 0644); err != nil { + if err := writeRoomFile(newPath, data); err != nil { sess.WriteLine("Error writing room file.") return } @@ -174,7 +176,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index 5bc02a7..53dd313 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -151,10 +151,6 @@ func (g *Game) doRemoveAll(sess *net.Session) { } needed := len(p.Equipment) - if _, hasAmmo := p.Equipment[item.SlotAmmo]; hasAmmo { - needed-- - needed++ - } if needed > p.FreeSlots() { sess.WriteLine("You don't have room to remove everything!") return diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go index 3210ef5..b381cf3 100644 --- a/internal/game/cmd_room.go +++ b/internal/game/cmd_room.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strings" "gopkg.in/yaml.v3" @@ -190,7 +189,7 @@ func (g *Game) writeRoom(sess *net.Session, fn func(*world.Room)) { sess.WriteLine("Error marshaling room YAML.") return } - if err := os.WriteFile(path, data, 0644); err != nil { + if err := writeRoomFile(path, data); err != nil { sess.WriteLine("Error writing room file.") return } diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 92ef38b..8e01326 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -127,7 +127,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { sess.WriteLine("Error creating directory.") return } - if err := os.WriteFile(newPath, data, 0644); err != nil { + if err := writeRoomFile(newPath, data); err != nil { sess.WriteLine("Error writing room file.") return } @@ -147,7 +147,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(curPath, curData, 0644); err != nil { + if err := writeRoomFile(curPath, curData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -166,7 +166,9 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { if tok2 { targetData, terr := yaml.Marshal(targetRoom) if terr == nil { - os.WriteFile(targetPath, targetData, 0644) + if werr := writeRoomFile(targetPath, targetData); werr != nil { + sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", targetID, werr)) + } } } diff --git a/internal/game/cmd_room_mob.go b/internal/game/cmd_room_mob.go index ced170f..ce729d4 100644 --- a/internal/game/cmd_room_mob.go +++ b/internal/game/cmd_room_mob.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "gopkg.in/yaml.v3" "thehouseoficarus/internal/net" @@ -44,7 +43,10 @@ func (g *Game) roomAddMob(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Added mob '%s' to room.", mobID)) } @@ -77,6 +79,9 @@ func (g *Game) roomRemMob(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Removed mob '%s' from room.", mobID)) } diff --git a/internal/game/cmd_room_object.go b/internal/game/cmd_room_object.go index 713a8f3..38b828a 100644 --- a/internal/game/cmd_room_object.go +++ b/internal/game/cmd_room_object.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strings" "gopkg.in/yaml.v3" @@ -118,7 +117,10 @@ func (g *Game) roomAddLocalObj(sess *net.Session, name string, aliases []string, path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Added local object '%s' to room.", name)) } @@ -153,7 +155,10 @@ func (g *Game) roomRemLocalObj(sess *net.Session, name string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Removed local object '%s' from room.", name)) } @@ -217,7 +222,10 @@ func (g *Game) roomToggleHidden(sess *net.Session, input string, hidden bool) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } action := "Hidden" if !hidden { diff --git a/internal/game/cmd_room_remove.go b/internal/game/cmd_room_remove.go index af745fd..77a09b3 100644 --- a/internal/game/cmd_room_remove.go +++ b/internal/game/cmd_room_remove.go @@ -180,7 +180,7 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { sess.WriteLine("Error updating current room YAML.") return } - if err := os.WriteFile(aPath, aData, 0644); err != nil { + if err := writeRoomFile(aPath, aData); err != nil { sess.WriteLine("Error writing current room file.") return } @@ -198,7 +198,9 @@ func (g *Game) roomRemove(sess *net.Session, args []string) { if cPathOK { cData, cerr := yaml.Marshal(cRoom) if cerr == nil { - os.WriteFile(cPath, cData, 0644) + if werr := writeRoomFile(cPath, cData); werr != nil { + sess.WriteLine(fmt.Sprintf("Warning: could not link the opposite exit in room %d: %v", cID, werr)) + } } } diff --git a/internal/game/cmd_room_spawn.go b/internal/game/cmd_room_spawn.go index d4353e4..c626acb 100644 --- a/internal/game/cmd_room_spawn.go +++ b/internal/game/cmd_room_spawn.go @@ -2,7 +2,6 @@ package game import ( "fmt" - "os" "strconv" "gopkg.in/yaml.v3" @@ -63,7 +62,10 @@ func (g *Game) roomAddSpawn(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Added item spawn '%s' x%d (respawn: %.0f ticks) to room.", itemID, quantity, respawnTicks)) } @@ -96,6 +98,9 @@ func (g *Game) roomRemSpawn(sess *net.Session, args []string) { path, _ := g.World.GetRoomPath(p.RoomID) data, _ := yaml.Marshal(room) - os.WriteFile(path, data, 0644) + if err := writeRoomFile(path, data); err != nil { + sess.WriteLine(fmt.Sprintf("Error saving room: %v", err)) + return + } sess.WriteLine(fmt.Sprintf("Removed item spawn '%s' from room.", itemID)) } diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index f3211e0..e2209fc 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -345,10 +345,25 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin } stock := g.MobStore.ShopStockOf(mob.InstanceID, match.ID) + // Only sell as many as the balance headroom can pay for: grantCredits + // clamps at the economy cap, so selling past it would consume the items + // and destroy the excess credits (the pickup paths take only the headroom). + headroom := creditHeadroom(p) total := 0 + sellQty := 0 for k := 0; k < actualQty; k++ { - total += mob.Shop.SellPrice(value, stock+k) + price := mob.Shop.SellPrice(value, stock+k) + if total+price > headroom { + break + } + total += price + sellQty++ + } + if sellQty <= 0 { + sess.WriteLine("You can't carry any more credits.") + return } + actualQty = sellQty p.RemoveItem(match.ID, actualQty) g.grantCredits(p, total) diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go index 9ee832a..00b6568 100644 --- a/internal/game/cmd_swapid.go +++ b/internal/game/cmd_swapid.go @@ -135,7 +135,7 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string) if merr != nil { continue } - if werr := os.WriteFile(path, data, 0644); werr != nil { + if werr := writeRoomFile(path, data); werr != nil { continue } updated++ diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index f9e3692..75512d1 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -59,6 +59,14 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe } } + // Refuse before consuming the item when the balance is at the cap: + // grantCredits clamps, so processing would otherwise destroy the item's + // value entirely. + if creditValue > creditHeadroom(p) { + sess.WriteLine("You can't carry any more credits.") + return + } + if inv.Quantity > 1 { inv.Quantity-- } else { diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go index 8a47db9..a0fd1a0 100644 --- a/internal/game/cmd_undig.go +++ b/internal/game/cmd_undig.go @@ -204,7 +204,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) { if merr != nil { return nil } - if werr := os.WriteFile(path, newData, 0644); werr != nil { + if werr := writeRoomFile(path, newData); werr != nil { return werr } cleaned++ diff --git a/internal/game/economy.go b/internal/game/economy.go index 9ce4404..0b9e92f 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -863,6 +863,24 @@ func pruneOpenPayouts(j *economyJournal, p *player.Player) bool { return changed } +// reanchorOwedPayout rewrites an open payout record's checkpoints to the +// current durable balance so a later recovery reads it as still owed (the +// balance sits at the record's before-checkpoint). It is used when a recovery +// run could not pay every sibling in an identical-signature group: the paid +// siblings moved the durable balance, so leaving the unpaid remainder at its +// original checkpoints would let a later run mistake the balance for its own +// durability and settle the liability without paying it. The post-checkpoint +// respects the economy cap: when the credit cannot fit, before == after, so +// the record keeps reading as owed at this balance instead of claiming an +// impossible post-state. +func reanchorOwedPayout(rec *economyRecord, credits, credit int) { + rec.CreditsBefore = credits + rec.CreditsAfter = credits + if after, err := addCreditsChecked(credits, credit); err == nil { + rec.CreditsAfter = after + } +} + // recoverEconomy compensates open records from the last crash. It is called // once from New, before any session connects. // @@ -885,24 +903,6 @@ func pruneOpenPayouts(j *economyJournal, p *player.Player) bool { // written later proves the round resolved (a win/push/refund already // returned the stake inside that payout) → settled with no refund. // Anything else stays open for operator inspection. -// reanchorOwedPayout rewrites an open payout record's checkpoints to the -// current durable balance so a later recovery reads it as still owed (the -// balance sits at the record's before-checkpoint). It is used when a recovery -// run could not pay every sibling in an identical-signature group: the paid -// siblings moved the durable balance, so leaving the unpaid remainder at its -// original checkpoints would let a later run mistake the balance for its own -// durability and settle the liability without paying it. The post-checkpoint -// respects the economy cap: when the credit cannot fit, before == after, so -// the record keeps reading as owed at this balance instead of claiming an -// impossible post-state. -func reanchorOwedPayout(rec *economyRecord, credits, credit int) { - rec.CreditsBefore = credits - rec.CreditsAfter = credits - if after, err := addCreditsChecked(credits, credit); err == nil { - rec.CreditsAfter = after - } -} - func (g *Game) recoverEconomy() { if g.Economy == nil { return diff --git a/internal/game/game.go b/internal/game/game.go index 3ffc72e..5ad1033 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -79,6 +79,13 @@ type Game struct { sequences map[string]*sequence globalSeqs []*sequence + // disconnectDeferrals counts, per session, how many ticks its pending + // disconnect has been deferred for combat. Per-Game (not package-level) + // so a second Game in one process cannot inherit stale counters, and + // pruned each tick so a session hard-removed elsewhere cannot pin its + // Player in memory for the life of the process. + disconnectDeferrals map[*net.Session]int + shutdownMu sync.Mutex // shutdownOnce guards RequestShutdown; transportStop is the registered @@ -134,18 +141,19 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * // Entropy-seeded, not clock-seeded: a wall-clock seed makes every // shoe and reel stream reconstructible. casino.NewManager(seed) stays // the deterministic test seam. - Casino: casino.NewManagerEntropy(), - flagIndex: newFlagTriggerIndex(), - queue: NewCommandQueue(), - safespot: NewSafespotManager(), - ValidationConfig: valConfig, - StartingRoom: startingRoom, - loggedInChars: make(map[string]*net.Session), - restTimers: make(map[string]uint64), - guardWatchTimers: make(map[string]int), - hackingStates: make(map[string]*hacking.Session), - sequences: make(map[string]*sequence), - shutdownDone: make(chan struct{}), + Casino: casino.NewManagerEntropy(), + flagIndex: newFlagTriggerIndex(), + queue: NewCommandQueue(), + safespot: NewSafespotManager(), + ValidationConfig: valConfig, + StartingRoom: startingRoom, + loggedInChars: make(map[string]*net.Session), + restTimers: make(map[string]uint64), + guardWatchTimers: make(map[string]int), + hackingStates: make(map[string]*hacking.Session), + sequences: make(map[string]*sequence), + disconnectDeferrals: make(map[*net.Session]int), + shutdownDone: make(chan struct{}), } g.owner = newOwner() // Runtime assignment (not initializer-level) so the command registry, @@ -700,6 +708,12 @@ func (g *Game) ProcessQueuedCommands() { }) for _, qc := range actives { + // A queued command can outlive its session (logout, disconnect, or a + // re-login on a new connection): never dereference a departed one on + // the owner goroutine. Mirrors the free-queue guard above. + if qc.Session == nil || qc.Session.Player == nil { + continue + } raw := qc.Command if qc.Args != "" { raw += " " + qc.Args diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go index ddada6b..89d6f55 100644 --- a/internal/game/production_engine.go +++ b/internal/game/production_engine.go @@ -128,7 +128,15 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item return } - if craft.Type == "combine" { + // Normalize the empty craft type to "combine" here as well as in the + // craft index: an omitted `type:` is combine, but the index's normalized + // key was never written back to the recipe, so the literal comparison + // below would fall through to the production path with an empty key. + craftType := craft.Type + if craftType == "" { + craftType = "combine" + } + if craftType == "combine" { g.startCombine(sess, p, item, craft, count) return } @@ -140,7 +148,7 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item cKey := craft.Subtype if cKey == "" { - cKey = craft.Type + cKey = craftType } info, ok := productionTypes[cKey] diff --git a/internal/game/room_write.go b/internal/game/room_write.go new file mode 100644 index 0000000..5ba24c1 --- /dev/null +++ b/internal/game/room_write.go @@ -0,0 +1,13 @@ +package game + +import ( + "thehouseoficarus/internal/persistence" +) + +// writeRoomFile durably writes an authored world/content file: temp file + +// rename + directory fsync. The in-game admin room commands previously used a +// bare os.WriteFile, which can truncate world data if the process dies +// mid-write. +func writeRoomFile(path string, data []byte) error { + return persistence.WriteFile(path, data, 0644) +} diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index 312fe50..8bba630 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -151,6 +151,7 @@ func (g *Game) advanceSequence(seq *sequence) { if len(seq.steps) == 0 { g.completePlayerSequence(seq, p) } + g.syncPendingSequence(seq, p) return } @@ -172,6 +173,7 @@ func (g *Game) advanceSequence(seq *sequence) { if len(seq.steps) == 0 { g.completePlayerSequence(seq, p) } + g.syncPendingSequence(seq, p) return } @@ -220,6 +222,22 @@ func (g *Game) completePlayerSequence(seq *sequence, p *player.Player) { } } +// syncPendingSequence keeps a locked sequence's persisted snapshot in step +// with the live sequence as it advances, so a crash before the next clean +// disconnect/shutdown cannot replay already-fired steps (and re-grant their +// rewards) on reconnect. It is a no-op for unlocked/completed sequences. +func (g *Game) syncPendingSequence(seq *sequence, p *player.Player) { + if p == nil || !seq.locked || p.PendingSequence == nil || p.PendingSequence.Key != seq.key { + return + } + if len(seq.steps) == 0 { + return + } + p.PendingSequence.Steps = append([]behavior.Step(nil), seq.steps...) + p.PendingSequence.Wait = seq.wait + g.saveCharacter(p) +} + func (g *Game) completeGlobalSequence(seq *sequence) { g.removeSequence(seq) } diff --git a/internal/game/tick.go b/internal/game/tick.go index 4181622..ef8089a 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -16,19 +16,18 @@ import ( // lockout) forever. const disconnectCombatGraceTicks = 5 -// disconnectCombatDeferrals counts, per session, how many ticks its pending -// disconnect has already been deferred for combat. DisconnectTick is its only -// reader/writer and every tick system runs on the owner goroutine, so the map -// needs no lock. An entry is dropped when this path completes the hard remove; -// a session hard-removed elsewhere (a second Hub.Remove, shutdown) leaves at -// most one stale int behind. -var disconnectCombatDeferrals = map[*net.Session]int{} - func (g *Game) DisconnectTick() { if g.Hub == nil { return } - for _, sess := range g.Hub.AllSessions() { + // Lazy init: tests construct Game literals directly rather than via New. + if g.disconnectDeferrals == nil { + g.disconnectDeferrals = make(map[*net.Session]int) + } + sessions := g.Hub.AllSessions() + seen := make(map[*net.Session]bool, len(sessions)) + for _, sess := range sessions { + seen[sess] = true if !sess.IsDisconnecting() { continue } @@ -44,18 +43,26 @@ func (g *Game) DisconnectTick() { // until a restart. Defer while the fight can still resolve, but only // for a bounded grace period, then let the countdown proceed. if g.Combat.Get(p.Name) != nil { - if deferred := disconnectCombatDeferrals[sess]; deferred < disconnectCombatGraceTicks { - disconnectCombatDeferrals[sess] = deferred + 1 + if deferred := g.disconnectDeferrals[sess]; deferred < disconnectCombatGraceTicks { + g.disconnectDeferrals[sess] = deferred + 1 continue } } if sess.TickDisconnectCountdown() { - delete(disconnectCombatDeferrals, sess) + delete(g.disconnectDeferrals, sess) g.restoreGodPlayer(p) g.saveCharacter(p) g.Hub.HardRemove(sess) } } + // Drop counters for sessions hard-removed elsewhere (a second Hub.Remove, + // shutdown) so a departed session (and its Player) cannot be retained for + // the life of the process. + for sess := range g.disconnectDeferrals { + if !seen[sess] { + delete(g.disconnectDeferrals, sess) + } + } } func (g *Game) RegenTick() { |
