aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_agility.go4
-rw-r--r--internal/game/act_effects.go10
-rw-r--r--internal/game/act_talk.go11
-rw-r--r--internal/game/casino.go9
-rw-r--r--internal/game/cmd_close.go5
-rw-r--r--internal/game/cmd_dig.go10
-rw-r--r--internal/game/cmd_remove.go4
-rw-r--r--internal/game/cmd_room.go3
-rw-r--r--internal/game/cmd_room_insert.go8
-rw-r--r--internal/game/cmd_room_mob.go11
-rw-r--r--internal/game/cmd_room_object.go16
-rw-r--r--internal/game/cmd_room_remove.go6
-rw-r--r--internal/game/cmd_room_spawn.go11
-rw-r--r--internal/game/cmd_shop.go17
-rw-r--r--internal/game/cmd_swapid.go2
-rw-r--r--internal/game/cmd_trigger_utility.go8
-rw-r--r--internal/game/cmd_undig.go2
-rw-r--r--internal/game/economy.go36
-rw-r--r--internal/game/game.go38
-rw-r--r--internal/game/production_engine.go12
-rw-r--r--internal/game/room_write.go13
-rw-r--r--internal/game/sys_triggers.go18
-rw-r--r--internal/game/tick.go31
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() {