aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 15:16:44 -0400
committerhistoria <[not public]>2026-09-18 15:16:44 -0400
commit7eab582d8a8ee2af1a6baae7baf0696c7005de2e (patch)
tree902bbc8c6e39c9f62f779ca23267714ec420b9f5 /internal/game
parent153acb7f644d755c45533b7c5311e3ffa47e8973 (diff)
downloadthehouseoficarus-7eab582d8a8ee2af1a6baae7baf0696c7005de2e.tar.gz
fix: harden triggers, economy, casino, world data, and admin/net paths
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_effects.go86
-rw-r--r--internal/game/act_farm.go3
-rw-r--r--internal/game/act_gather.go37
-rw-r--r--internal/game/act_steal.go19
-rw-r--r--internal/game/act_talk.go47
-rw-r--r--internal/game/casino.go2
-rw-r--r--internal/game/cmd_bank.go7
-rw-r--r--internal/game/cmd_consume.go4
-rw-r--r--internal/game/cmd_drop.go61
-rw-r--r--internal/game/cmd_estate_directory.go10
-rw-r--r--internal/game/cmd_quit.go11
-rw-r--r--internal/game/cmd_swapid.go13
-rw-r--r--internal/game/cmd_trigger_utility.go62
-rw-r--r--internal/game/core_validate.go33
-rw-r--r--internal/game/economy.go68
-rw-r--r--internal/game/exit_migrate.go7
-rw-r--r--internal/game/game.go18
-rw-r--r--internal/game/hardening_fixes_batch2_test.go203
-rw-r--r--internal/game/production_menu.go3
-rw-r--r--internal/game/sys_combat.go21
20 files changed, 563 insertions, 152 deletions
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
index cae54a5..a107320 100644
--- a/internal/game/act_effects.go
+++ b/internal/game/act_effects.go
@@ -51,9 +51,9 @@ const (
// 12. despawn_mob (player scope: owner-filtered; global scope: all)
// 13. teleport (player scope; re-runs look + on_enter of target)
// 14. aps_node (player scope; marks "aps_node_<roomID>" player flag)
-func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, roomID int, sc effectScope, flagValue any) {
+func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Step, roomID int, sc effectScope, flagValue any) bool {
if step == nil {
- return
+ return true
}
var playerName string
@@ -61,6 +61,20 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
playerName = p.Name
}
+ // Player-scope effects are transactional: preflight every cost (a required
+ // take_item, give_item placement, and negative credits) before applying
+ // anything, so a trade can never take/destroy an item or charge credits
+ // without delivering its reward. Returns false when the whole step was
+ // aborted; callers that show follow-up success text must honour it.
+ if sc == scopePlayer && p != nil {
+ if reason := g.stepPreflight(p, step); reason != "" {
+ if sess != nil {
+ sess.WriteLine(reason)
+ }
+ return false
+ }
+ }
+
// 1. messages — direct to the triggering session.
if sc != scopeGlobal {
for _, msg := range step.Messages {
@@ -103,11 +117,11 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
if step.DespawnMob != "" {
g.despawnTriggerMobs(step.DespawnMob, "")
}
- return
+ return true
}
if p == nil {
- return
+ return true
}
// 5. set_player_flags (and globals, folded in so the SetAll happens once).
@@ -124,16 +138,15 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
}
}
- // 7. give_item
+ // 7. give_item (placement was preflighted above; merges stackables and
+ // stamps quality so the received stack matches every other placement path).
if step.GiveItem != "" {
- slot := p.FirstFreeSlot()
- if slot == -1 {
- if sess != nil {
- sess.WriteLine("Your inventory is too full to receive that.")
- }
- } else {
- p.SetInvSlot(slot, &player.InventorySlot{ItemID: step.GiveItem, Quantity: 1})
+ def, _ := g.ItemStore.Load(step.GiveItem)
+ if placeCraftOutput(p, step.GiveItem, def, 1) {
g.saveCharacter(p)
+ } else if sess != nil {
+ // Defensive: preflight verified placement on the owner goroutine.
+ sess.WriteLine("Your inventory is too full to receive that.")
}
}
@@ -159,15 +172,12 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
}
}
- // 10. credits (signed; negative is gated).
+ // 10. credits (signed). A negative cost was preflighted for affordability,
+ // so the debit is unconditional here.
if step.Credits != 0 {
if step.Credits < 0 {
- if p.Credits >= -step.Credits {
- p.Credits += step.Credits
- g.saveCharacter(p)
- } else if sess != nil {
- sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits))
- }
+ p.Credits += step.Credits
+ g.saveCharacter(p)
} else {
g.grantCredits(p, step.Credits)
g.saveCharacter(p)
@@ -197,6 +207,35 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
g.saveCharacter(p)
}
+ return true
+}
+
+// stepPreflight reports why a player-scope step cannot be applied in full, or
+// "" when every cost can be met. It is the transaction gate for trigger/talk
+// effects: flags, items, and credits are all-or-nothing, so a step whose
+// take_item is missing, whose give_item cannot be placed, or whose negative
+// credits are unaffordable must apply nothing at all.
+func (g *Game) stepPreflight(p *player.Player, step *behavior.Step) string {
+ if p == nil {
+ return ""
+ }
+ if step.TakeItem != "" && !p.HasItem(step.TakeItem) {
+ name := step.TakeItem
+ if def, err := g.ItemStore.Load(step.TakeItem); err == nil && def.Name != "" {
+ name = def.Name
+ }
+ return fmt.Sprintf("You don't have %s.", name)
+ }
+ if step.GiveItem != "" {
+ def, _ := g.ItemStore.Load(step.GiveItem)
+ if !craftOutputFits(p, step.GiveItem, def) {
+ return "Your inventory is too full to receive that."
+ }
+ }
+ if step.Credits < 0 && p.Credits < -step.Credits {
+ return fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -step.Credits, p.Credits)
+ }
+ return ""
}
// writePlayerMessage expands the message template (player name, flag value),
@@ -253,13 +292,14 @@ func (g *Game) giveTriggerDrop(sess *net.Session, p *player.Player, drop *behavi
}
// applyTalkStep fires a talk node/option's Action (a single Step)
-// synchronously at scopePlayer, scoped to the player's current room.
-func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) {
+// synchronously at scopePlayer, scoped to the player's current room. It
+// returns false when the step's preflight failed and no effect was applied.
+func (g *Game) applyTalkStep(sess *net.Session, step *behavior.Step) bool {
p := sess.Player
if p == nil || step == nil {
- return
+ return true
}
- g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil)
+ return g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil)
}
// runTrigger runs the first matching entry in a trigger block. itemMatch
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 48fbec7..6725351 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -238,6 +238,9 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
return
}
+ if maxYield < minYield {
+ maxYield = minYield
+ }
yield := int(minYield) + rand.Intn(int(maxYield)-int(minYield)+1)
bonus := p.Level(player.Farming) / 20
yield += bonus
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index a1c87b9..08ffbb1 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -110,10 +110,6 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
}
}
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is too full!")
- return
- }
verb := cfg.Skill
switch cfg.Skill {
case "woodcutting":
@@ -162,11 +158,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
shared := d.DepleteTimer
if step == 0 {
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
- g.cancelAction(p)
- return
- }
if cfg.Bait != "" {
if !p.HasItem(cfg.Bait) {
baitName := cfg.Bait
@@ -216,13 +207,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
eligible = filterDropsByTool(eligible, d.ToolType, len(cfg.Tools))
drop := behavior.ResolveDrop(g.DataDir, eligible)
if drop != nil {
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
- g.cancelAction(p)
- return
- }
-
qty := drop.Quantity
if qty <= 0 {
qty = 1
@@ -234,7 +218,14 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
itemName = itemDef.Name
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
+ // Stack-aware placement: a stackable drop merges into an existing
+ // stack, so a full inventory with a matching stack is not a failure
+ // (matching doGet/shop/bank withdrawal).
+ if !placeCraftOutput(p, drop.ItemID, itemDef, qty) {
+ sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
+ g.cancelAction(p)
+ return
+ }
xp := drop.XP
if xp > 0 {
g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), xp)
@@ -254,7 +245,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
sess.WriteLine(msg)
- g.checkBirdNest(sess, p, cfg, freeSlot)
+ g.checkBirdNest(sess, p, cfg, -1)
if shared {
st := g.World.GetObjStateByKey(instanceKey)
@@ -306,11 +297,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
return
}
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
- g.cancelAction(p)
- return
- }
d.Step = 0
p.Action.WaitLeft = engine.ToTicks(wait)
return
@@ -318,11 +304,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
sess.WriteLine(cfg.FailMessage)
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
- g.cancelAction(p)
- return
- }
d.Step = 0
p.Action.WaitLeft = engine.ToTicks(wait)
}
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 32889ce..5a7f93e 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -42,7 +42,7 @@ var stallGuardTalk = &behavior.TalkConfig{
},
"jail": {
Messages: []string{"Off to the detention cell with you!"},
- Action: &behavior.Step{Teleport: 162},
+ Action: &behavior.Step{Teleport: 99999162},
Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
@@ -254,11 +254,6 @@ func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string
}
}
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is too full!")
- return
- }
-
sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName))
g.broadcastAction(sess, "%s glances around the room.", p.Name)
@@ -379,12 +374,6 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) {
g.saveCharacter(p)
- if p.FirstFreeSlot() == -1 {
- sess.WriteLine("Your inventory is full.")
- g.cancelAction(p)
- return
- }
-
p.Action.WaitLeft = engine.ToTicks(stealSpeed)
} else {
if targetType == "mob" {
@@ -429,14 +418,14 @@ func (g *Game) giveStealLoot(sess *net.Session, p *player.Player, drop *behavior
name = lootDef.Name
}
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
+ // Stack-aware placement: a stackable steal merges into an existing stack,
+ // so a full inventory with a matching stack is not a failure.
+ if !placeCraftOutput(p, drop.ItemID, lootDef, qty) {
g.World.AddGroundItem(p.RoomID, drop.ItemID, qty)
sess.WriteLine(fmt.Sprintf("You steal %s but your inventory is full. It falls to the ground.", g.itemColorize(sess, lootDef, name)))
return
}
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
sess.WriteLine(fmt.Sprintf("You steal %s.", g.itemColorize(sess, lootDef, name)))
}
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index ef3c6e5..9ae9959 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -55,8 +55,8 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object
}
}
-func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercepted bool) {
- g.applyTalkStep(sess, na)
+func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercepted bool, applied bool) {
+ applied = g.applyTalkStep(sess, na)
if v, ok := g.GlobalFlags.Get("guard_hostile"); ok && v != nil {
g.GlobalFlags.Delete("guard_hostile")
@@ -70,11 +70,11 @@ func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercept
guardMob.Protected = false
g.startCombat(sess, p, guardMob)
}
- return true
+ return true, applied
}
}
}
- return false
+ return false, applied
}
func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
@@ -90,12 +90,19 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
for {
if node.Condition == nil || g.checkCondition(sess, node.Condition) {
- if len(node.Messages) > 0 {
- if node.Action != nil {
- if g.handleNodeAction(sess, node.Action) {
- return
- }
+ // Apply the node's action first. A failed preflight (missing item,
+ // full inventory, unaffordable cost) applies nothing, so skip the
+ // node's success messages rather than narrating a trade that did
+ // not happen; the player still gets the node's options.
+ actionApplied := true
+ if node.Action != nil {
+ intercepted, applied := g.handleNodeAction(sess, node.Action)
+ if intercepted {
+ return
}
+ actionApplied = applied
+ }
+ if actionApplied && len(node.Messages) > 0 {
td.SeqIndex = 0
msg := node.Messages[0]
if msg != "" {
@@ -107,12 +114,6 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
sess.Write(g.colorize(sess, "dialog_options", "[enter to continue]\n"))
return
}
- } else {
- if node.Action != nil {
- if g.handleNodeAction(sess, node.Action) {
- return
- }
- }
}
visible := g.visibleOptions(sess, node.Options)
if len(visible) > 0 {
@@ -313,7 +314,21 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
td.PendingChoice = ""
td.PendingAction = nil
if action != nil {
- if g.handleNodeAction(sess, action) {
+ intercepted, applied := g.handleNodeAction(sess, action)
+ if intercepted {
+ return
+ }
+ if !applied {
+ // The option's cost could not be met and no effect was applied.
+ // Re-show the current node's options instead of advancing to the
+ // success node and its misleading success text.
+ if node, ok := td.Cfg.Nodes[td.Node]; ok {
+ g.finishSequence(sess, node)
+ } else {
+ g.cancelAction(p)
+ sess.SetState(net.StateGame)
+ g.writePrompt(sess)
+ }
return
}
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index bc702e1..4dfced6 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -568,7 +568,7 @@ func (g *Game) CasinoTick() {
func (g *Game) emitCasinoEvents(events []casino.Event) {
for _, event := range events {
- if event.Message == "" && event.Menu == nil && event.Blackjack == nil && event.Baccarat == nil && event.Slot == nil {
+ if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Blackjack == nil && event.Baccarat == nil && event.Slot == nil {
continue
}
category := event.Color
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 468b173..0bfc2a3 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -236,8 +236,11 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) {
entries = append(entries, entryRef{slot: i, pos: len(entries) + 1, name: itemName, id: bs.ItemID, qty: bs.Quantity})
}
- idx, err := parseChoiceIndex(input)
- if err == nil && idx > 0 && idx <= len(entries) {
+ // Only a pure number selects a bank slot by position: parseChoiceIndex
+ // accepts a leading integer and ignores trailing text, so "withdraw 2
+ // chips" would otherwise withdraw display entry #2 instead of the named
+ // item.
+ if idx, err := strconv.Atoi(strings.TrimSpace(input)); err == nil && idx > 0 && idx <= len(entries) {
e := entries[idx-1]
g.withdrawBankItem(sess, p, e.slot)
return
diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go
index a49d897..2e05620 100644
--- a/internal/game/cmd_consume.go
+++ b/internal/game/cmd_consume.go
@@ -228,8 +228,8 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d
case "battery":
batteryRestore := def.PotionBonus
p.Battery += float64(batteryRestore)
- if p.Battery > 100 {
- p.Battery = 100
+ if max := p.MaxBattery(); p.Battery > max {
+ p.Battery = max
}
sess.WriteLine(fmt.Sprintf("You drink the %s. Your battery is restored by %d%%.",
g.itemColorize(sess, def, def.Name), batteryRestore))
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 5920696..2e27f91 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -51,15 +51,24 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
coloredName := g.itemColorize(sess, def, name)
if def != nil && def.Stackable {
- slot := p.InvSlot(matches[0].Slot)
- qty := slot.Quantity
- p.SetInvSlot(matches[0].Slot, nil)
- g.World.AddGroundItem(p.RoomID, itemID, qty)
+ // Drop every matching stack, not just the first: multiple stacks of
+ // one stackable item are normal (trigger/gather/steal placement does
+ // not always merge), so "drop all <item>" must span them.
+ total := 0
+ for _, match := range matches {
+ slot := p.InvSlot(match.Slot)
+ if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 {
+ continue
+ }
+ total += slot.Quantity
+ p.SetInvSlot(match.Slot, nil)
+ g.World.AddGroundItem(p.RoomID, itemID, slot.Quantity)
+ }
g.saveCharacter(p)
- if qty == 1 {
+ if total == 1 {
sess.WriteLine(fmt.Sprintf("You drop a %s.", coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, coloredName))
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, coloredName))
}
return
}
@@ -110,11 +119,11 @@ func (g *Game) doDrop(sess *net.Session, input string) {
if qty == 0 {
if def != nil && def.Stackable {
+ // "drop <item>" means the whole item: sum every matching stack.
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 {
- qty = slot.Quantity
- break
+ qty += slot.Quantity
}
}
} else {
@@ -128,21 +137,35 @@ func (g *Game) doDrop(sess *net.Session, input string) {
}
if def != nil && def.Stackable {
- slot := p.InvSlot(matches[0].Slot)
- if qty > slot.Quantity {
- qty = slot.Quantity
- }
- if slot.Quantity > qty {
- slot.Quantity -= qty
- } else {
- p.SetInvSlot(matches[0].Slot, nil)
+ // Consume the requested quantity across every matching stack, not just
+ // the first one.
+ remaining := qty
+ for _, match := range matches {
+ if remaining <= 0 {
+ break
+ }
+ slot := p.InvSlot(match.Slot)
+ if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 {
+ continue
+ }
+ take := slot.Quantity
+ if take > remaining {
+ take = remaining
+ }
+ if slot.Quantity > take {
+ slot.Quantity -= take
+ } else {
+ p.SetInvSlot(match.Slot, nil)
+ }
+ g.World.AddGroundItem(p.RoomID, itemID, take)
+ remaining -= take
}
- g.World.AddGroundItem(p.RoomID, itemID, qty)
+ dropped := qty - remaining
g.saveCharacter(p)
- if qty == 1 {
+ if dropped == 1 {
sess.WriteLine(fmt.Sprintf("You drop a %s.", g.itemColorize(sess, def, name)))
} else {
- sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, g.itemColorize(sess, def, name)))
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", dropped, g.itemColorize(sess, def, name)))
}
return
}
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index 3da2c18..9966e52 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -182,7 +182,7 @@ func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int {
}
houseMap := map[string]int{
- "local_neighborhood": 300,
+ "local_neighborhood": 99999300,
}
return houseMap[v]
}
@@ -196,6 +196,14 @@ func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
}
}
+ // A teleport changes rooms just like a walk, so it must clear any safespot
+ // cover: isSafespotted/safespotBlocksMelee/safespotBlocksHazard compare no
+ // room, so leaving the state active grants melee and hazard immunity in the
+ // destination and keeps the player as a phantom occupant of the old cover.
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+
p.RoomID = roomID
p.Stats.RecordRoomVisit(roomID)
if g.Hub != nil {
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index bf46748..249df80 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -74,7 +74,16 @@ func (g *Game) beginQuitCountdown(sess *net.Session) {
g.broadcastAction(sess, "%s sits down to rest.", p.Name)
ticksLeft := 10
- id := g.SubscribeTicks(engine.ToTicks(1), func() bool {
+ var id uint64
+ id = g.SubscribeTicks(engine.ToTicks(1), func() bool {
+ // The engine snapshots its subscribers before firing them, so this
+ // callback can still run once after cancelRest unsubscribes it. Bail
+ // out unless this is still the registered countdown, so a quit
+ // cancelled on the final tick never force-disconnects the player (and
+ // never forfeits a committed casino wager).
+ if g.restTimers[p.Name] != id {
+ return false
+ }
switch ticksLeft {
case 10:
sess.WriteLine("You sit down to rest...")
diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go
index ef862b9..9ee832a 100644
--- a/internal/game/cmd_swapid.go
+++ b/internal/game/cmd_swapid.go
@@ -141,12 +141,13 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
updated++
}
- // Migrate room-ID-embedded state across all characters (online + offline):
- // discovered-exit player flags, current RoomID, EnterSeqRoom, MapSymbols,
- // and RoomsVisited. This is what the player asked for — without it,
- // swapid leaves every character's hidden_exit_<id>_<dir> flags (and more)
- // pointing at stale room IDs.
- g.RewriteRoomIDs(idMap)
+ // Full runtime migration: Hub room buckets, casino room keys, world
+ // room-keyed state (ground items, seeded markers, object states), live
+ // mob instances, the room index, and every character's embedded room-ID
+ // state. Without this, a swap leaves live gameplay attached to the old
+ // locations until restart. This runs on the owner (swapid is executed
+ // from the owner command loop) and must not dispatch again.
+ g.MigrateRoomIDsOnOwner(idMap)
sess.WriteLine(fmt.Sprintf("Swapped room IDs %d and %d (%d files updated).", id1, id2, updated))
}
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index b5a168b..f9e3692 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -107,12 +107,9 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t
return
}
- if p.FirstFreeSlot() < 0 {
- sess.WriteLine("Your inventory is full.")
- return
- }
-
- groundItems := g.World.GroundItems(p.RoomID)
+ // Reservation-aware: only items this player may take are considered, so
+ // em grab cannot magnetize kill loot reserved for another player.
+ groundItems := g.World.AccessibleGround(p.RoomID, p.Name)
var matchedID string
for itemID := range groundItems {
def, _ := g.ItemStore.Load(itemID)
@@ -143,41 +140,58 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t
}
// Credits are currency, not an inventory item: collect them the same way
- // the get command does.
+ // the get command does, respecting the balance cap so the excess stays on
+ // the ground instead of being removed and clamped away.
if matchedID == "credits" {
- g.World.RemoveGroundItem(p.RoomID, matchedID, qty)
- g.grantCredits(p, qty)
+ room := creditHeadroom(p)
+ if room <= 0 {
+ sess.WriteLine(g.colorize(sess, "currency_pickup", "Your purse can't hold any more credits."))
+ return
+ }
+ taken := g.World.RemoveAvailableItem(p.RoomID, matchedID, room, p.Name)
+ if taken <= 0 {
+ return
+ }
+ g.grantCredits(p, taken)
+ g.saveCharacter(p)
g.triggerModReward(sess, p, mod, 1.0)
- sess.WriteLine(fmt.Sprintf("You magnetically pull %d credits toward you.", qty))
+ sess.WriteLine(fmt.Sprintf("You magnetically pull %d credits toward you.", taken))
return
}
if def != nil && def.Stackable {
- g.World.RemoveGroundItem(p.RoomID, matchedID, qty)
- if !placeCraftOutput(p, matchedID, def, qty) {
- g.World.AddGroundItem(p.RoomID, matchedID, qty)
+ taken := g.World.RemoveAvailableItem(p.RoomID, matchedID, qty, p.Name)
+ if taken <= 0 {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+ if !placeCraftOutput(p, matchedID, def, taken) {
+ g.World.AddGroundItem(p.RoomID, matchedID, taken)
sess.WriteLine("Your inventory is full.")
return
}
} else {
// Non-stackable items take one slot each; leave any remainder on the
- // ground instead of cramming N items into a single slot.
- taken := 0
- for taken < qty {
+ // ground instead of cramming N items into a single slot. Remove from
+ // the ground first (reservation-aware) so a placement failure cannot
+ // duplicate stock.
+ taken, ok := g.World.RemoveReservedItem(p.RoomID, matchedID, qty, p.Name)
+ if !ok || taken == 0 {
+ sess.WriteLine("Your inventory is full.")
+ return
+ }
+ placed := 0
+ for placed < taken {
freeSlot := p.FirstFreeSlot()
if freeSlot < 0 {
break
}
p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: 1})
- taken++
- }
- if taken == 0 {
- sess.WriteLine("Your inventory is full.")
- return
+ placed++
}
- g.World.RemoveGroundItem(p.RoomID, matchedID, taken)
- if taken < qty {
- sess.WriteLine(fmt.Sprintf("Your inventory fills up; %d %s remain on the ground.", qty-taken, name))
+ if placed < taken {
+ g.World.AddGroundItem(p.RoomID, matchedID, taken-placed)
+ sess.WriteLine(fmt.Sprintf("Your inventory fills up; %d %s remain on the ground.", taken-placed, name))
}
}
diff --git a/internal/game/core_validate.go b/internal/game/core_validate.go
index 0e302c0..b6cb3b9 100644
--- a/internal/game/core_validate.go
+++ b/internal/game/core_validate.go
@@ -37,16 +37,29 @@ func (g *Game) validationSource() validate.Source {
techIDs[t.ID] = true
}
+ moduleElements := make(map[string]bool)
+ moduleDestinations := make(map[string]int)
+ for _, m := range AllMods {
+ if m.Element != "" {
+ moduleElements[m.Element] = true
+ }
+ if m.Destination > 0 {
+ moduleDestinations[m.ID] = m.Destination
+ }
+ }
+
return validate.Source{
- DataDir: g.DataDir,
- Items: g.ItemStore,
- Objects: g.ObjectStore,
- Mobs: g.MobStore,
- World: g.World,
- Courses: courseViews,
- Techs: techViews,
- TechIDs: techIDs,
- RootRooms: g.ValidationConfig.RootRooms,
- IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable,
+ DataDir: g.DataDir,
+ Items: g.ItemStore,
+ Objects: g.ObjectStore,
+ Mobs: g.MobStore,
+ World: g.World,
+ Courses: courseViews,
+ Techs: techViews,
+ TechIDs: techIDs,
+ RootRooms: g.ValidationConfig.RootRooms,
+ IgnoreUnreachable: g.ValidationConfig.IgnoreUnreachable,
+ ModuleElements: moduleElements,
+ ModuleDestinations: moduleDestinations,
}
}
diff --git a/internal/game/economy.go b/internal/game/economy.go
index b436a29..3505a86 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -43,6 +43,7 @@ package game
import (
"bufio"
+ "bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -113,6 +114,12 @@ type economyJournal struct {
// does) or a crash can lose the whole journal — including the record of
// a debit that is already durable in the character file.
dirSynced bool
+ // tailChecked records whether the journal tail has been verified to end on
+ // a newline before the first append of this process. A torn final line
+ // (short write) must be truncated before a new record is appended, or the
+ // two merge into one malformed mid-file line that permanently aborts
+ // recovery.
+ tailChecked bool
// stakes indexes outstanding wager records per player identity key
// (economyKey: the character's stable ID when known, else its name).
// All journal mutations happen on the game owner (or during startup
@@ -166,6 +173,12 @@ func (j *economyJournal) append(rec economyRecord) error {
if err != nil {
return err
}
+ if !j.tailChecked {
+ if err := j.repairTornTail(); err != nil {
+ return fmt.Errorf("repair economy journal tail: %w", err)
+ }
+ j.tailChecked = true
+ }
f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("open economy journal: %w", err)
@@ -186,6 +199,61 @@ func (j *economyJournal) append(rec economyRecord) error {
return nil
}
+// repairTornTail guarantees the journal ends on a newline before a new record
+// is appended. A short write (ENOSPC, a flaky filesystem) can leave a partial
+// JSON suffix; appending a full record after it merges the two into one
+// malformed mid-file line, which loadFolded treats as fatal and which then
+// permanently disables recovery and compaction. Truncating the torn suffix is
+// safe: a partial record was never a committed transaction. This runs at most
+// once per process (append is otherwise hot).
+func (j *economyJournal) repairTornTail() error {
+ f, err := os.OpenFile(j.path, os.O_RDWR, 0600)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ defer f.Close()
+
+ fi, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ size := fi.Size()
+ if size == 0 {
+ return nil
+ }
+
+ last := make([]byte, 1)
+ if _, err := f.ReadAt(last, size-1); err != nil {
+ return err
+ }
+ if last[0] == '\n' {
+ return nil
+ }
+
+ // Scan backwards in bounded chunks for the last newline and truncate the
+ // torn suffix after it. If none exists the whole file is one torn line.
+ const chunk = 64 * 1024
+ buf := make([]byte, chunk)
+ for end := size; end > 0; {
+ start := end - chunk
+ if start < 0 {
+ start = 0
+ }
+ n := int(end - start)
+ if _, err := f.ReadAt(buf[:n], start); err != nil {
+ return err
+ }
+ if idx := bytes.LastIndexByte(buf[:n], '\n'); idx >= 0 {
+ return f.Truncate(start + int64(idx) + 1)
+ }
+ end = start
+ }
+ return f.Truncate(0)
+}
+
// appendRetry appends one durable line, retrying a bounded number of
// times: a transient write failure must not permanently lose a marker
// that money correctness depends on.
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
index 1fceca9..2c56d27 100644
--- a/internal/game/exit_migrate.go
+++ b/internal/game/exit_migrate.go
@@ -130,7 +130,10 @@ func (g *Game) RewriteRoomIDs(idMap map[int]int) {
if rewritePlayerRoomIDs(sess.Player, idMap) {
g.saveCharacter(sess.Player)
}
- online[sess.Player.Name] = true
+ // Key case-insensitively: a character file stem can differ in case
+ // from the loaded Player.Name, and a case-sensitive set would miss it
+ // and remap that online character a second time on disk.
+ online[strings.ToLower(sess.Player.Name)] = true
}
}
names, err := g.AccountStore.ListCharacterNames()
@@ -138,7 +141,7 @@ func (g *Game) RewriteRoomIDs(idMap map[int]int) {
return
}
for _, name := range names {
- if online[name] {
+ if online[strings.ToLower(name)] {
continue
}
p, err := g.AccountStore.LoadCharacter(name)
diff --git a/internal/game/game.go b/internal/game/game.go
index de383a6..3ffc72e 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -87,6 +87,11 @@ type Game struct {
shutdownRun *scheduledShutdown
shutdownHook func(reason string)
transportStop func()
+ // shutdownDone is closed after runShutdown's ordered sequence (drain,
+ // refund, final save, transport close) completes. main waits on it on the
+ // transport-failure path before reading ShutdownSaveError, so it cannot
+ // exit while the final save is still in flight.
+ shutdownDone chan struct{}
// shutdownSaveMu guards shutdownSaveErr, the error of the final save
// runShutdown performs (nil when it succeeded or no shutdown ran). main
@@ -140,6 +145,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig *
guardWatchTimers: make(map[string]int),
hackingStates: make(map[string]*hacking.Session),
sequences: make(map[string]*sequence),
+ shutdownDone: make(chan struct{}),
}
g.owner = newOwner()
// Runtime assignment (not initializer-level) so the command registry,
@@ -250,6 +256,15 @@ func (g *Game) ShutdownSaveError() error {
return g.shutdownSaveErr
}
+// ShutdownDone returns a channel that is closed once the graceful shutdown
+// sequence (including the final online-character save and the transport
+// close) has completed. A caller that starts shutdown itself (the
+// transport-failure path) waits on it before reading ShutdownSaveError and
+// exiting, so the save is not skipped.
+func (g *Game) ShutdownDone() <-chan struct{} {
+ return g.shutdownDone
+}
+
// recordShutdownSaveErr stores the final-save failure for ShutdownSaveError.
// The shutdown sequence runs on its own goroutine, so the field is mutex
// guarded against the reader in main.
@@ -300,6 +315,9 @@ func (g *Game) runShutdown() {
g.transportStop()
}
log.Println("shutdown complete")
+ // Publish completion last: main waits on this before reading
+ // ShutdownSaveError on the transport-failure path.
+ close(g.shutdownDone)
}
// saveAllOnlineCharacters persists every online player exactly once under
diff --git a/internal/game/hardening_fixes_batch2_test.go b/internal/game/hardening_fixes_batch2_test.go
new file mode 100644
index 0000000..e81dd9c
--- /dev/null
+++ b/internal/game/hardening_fixes_batch2_test.go
@@ -0,0 +1,203 @@
+package game
+
+// Regression tests for the second hardening pass: transactional trigger
+// effects, ammo preservation on death, safespot clearing on teleport, and
+// economy-journal torn-tail repair.
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/player"
+)
+
+func seedBatchItems(t *testing.T, dir string) {
+ t.Helper()
+ writeSeedFile(t, filepath.Join(dir, "items", "cowhide.yaml"),
+ []byte("id: cowhide\nname: cowhide\nvalue: 1\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "leather.yaml"),
+ []byte("id: leather\nname: leather\nvalue: 1\nstackable: true\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "rock.yaml"),
+ []byte("id: rock\nname: rock\nvalue: 1\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "arrow.yaml"),
+ []byte("id: arrow\nname: arrow\nvalue: 1\nstackable: true\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "helm.yaml"),
+ []byte("id: helm\nname: helm\nvalue: 100\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "body.yaml"),
+ []byte("id: body\nname: body\nvalue: 100\n"))
+ writeSeedFile(t, filepath.Join(dir, "items", "sword.yaml"),
+ []byte("id: sword\nname: sword\nvalue: 100\n"))
+}
+
+// TestApplyStepUnaffordablePurchaseIsAtomic: a step that both sets a flag and
+// charges credits must apply nothing when the player cannot afford it.
+func TestApplyStepUnaffordablePurchaseIsAtomic(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ p.Credits = 0
+ p.Flags = map[string]any{}
+
+ step := &behavior.Step{Credits: -10, SetPlayerFlags: map[string]any{"bought": true}}
+ if h.g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) {
+ t.Fatal("unaffordable purchase reported success")
+ }
+ if _, ok := p.Flags["bought"]; ok {
+ t.Fatal("flag granted on a failed purchase")
+ }
+ if p.Credits != 0 {
+ t.Fatalf("credits changed on a failed purchase: %d", p.Credits)
+ }
+}
+
+// TestApplyStepFullInventoryTradeIsAtomic: a take+give+charge step with no
+// room for the reward must consume nothing and charge nothing.
+func TestApplyStepFullInventoryTradeIsAtomic(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ p.Inventory = make(map[int]*player.InventorySlot)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "cowhide", Quantity: 1})
+ for i := 1; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "rock", Quantity: 1})
+ }
+ p.Credits = 100
+
+ step := &behavior.Step{TakeItem: "cowhide", GiveItem: "leather", Credits: -10}
+ if h.g.applyStep(sess, p, step, p.RoomID, scopePlayer, nil) {
+ t.Fatal("full-inventory trade reported success")
+ }
+ if !p.HasItem("cowhide") {
+ t.Fatal("cowhide consumed on a failed trade")
+ }
+ if p.HasItem("leather") {
+ t.Fatal("leather delivered on a failed trade")
+ }
+ if p.Credits != 100 {
+ t.Fatalf("credits charged on a failed trade: %d", p.Credits)
+ }
+}
+
+// TestApplyStepStackableRewardMergesWhenFull: a stackable reward that fits an
+// existing stack must still be delivered when every slot is occupied.
+func TestApplyStepStackableRewardMergesWhenFull(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ p.Inventory = make(map[int]*player.InventorySlot)
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "leather", Quantity: 1})
+ for i := 1; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "rock", Quantity: 1})
+ }
+
+ if !h.g.applyStep(sess, p, &behavior.Step{GiveItem: "leather"}, p.RoomID, scopePlayer, nil) {
+ t.Fatal("stackable reward should merge into a full inventory")
+ }
+ if slot := p.InvSlot(0); slot == nil || slot.Quantity != 2 {
+ t.Fatalf("reward did not merge into the existing stack: %+v", slot)
+ }
+}
+
+// TestDropItemsOnDeathPreservesAmmoStack: an equipped ammo stack must be
+// dropped in full (and AmmoQty cleared), not silently reduced to one unit.
+func TestDropItemsOnDeathPreservesAmmoStack(t *testing.T) {
+ h := newHarness(t)
+ seedBatchItems(t, h.dir)
+ h.g.ItemStore.Reload(h.dir)
+
+ p := player.New("alice")
+ p.RoomID = 1
+ p.Credits = 0
+ p.Equipment = map[item.EquipSlot]string{
+ item.SlotAmmo: "arrow",
+ item.SlotHead: "helm",
+ item.SlotTorso: "body",
+ item.SlotMainHand: "sword",
+ }
+ p.AmmoQty = 50
+
+ h.g.RunOnOwner(func() {
+ h.g.dropItemsOnDeath(p)
+ })
+
+ if p.AmmoQty != 0 {
+ t.Fatalf("AmmoQty not cleared after the stack dropped: %d", p.AmmoQty)
+ }
+ ground := h.g.World.GroundItems(p.RoomID)
+ if ground["arrow"] != 50 {
+ t.Fatalf("ammo stack not preserved on the ground: %+v", ground)
+ }
+}
+
+// TestTeleportPlayerClearsSafespot: cover immunity must not follow a teleport
+// into a room with no cover.
+func TestTeleportPlayerClearsSafespot(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+ p := sess.Player
+ h.g.safespot.Set(p.Name, SafespotState{Active: true, RoomID: p.RoomID, ObjectDefID: "rock"})
+
+ h.g.RunOnOwner(func() {
+ h.g.teleportPlayer(sess, p, 2)
+ })
+
+ if h.g.safespot.Has(p.Name) {
+ t.Fatal("safespot survived a teleport")
+ }
+ if p.RoomID != 2 {
+ t.Fatalf("room after teleport = %d, want 2", p.RoomID)
+ }
+}
+
+// TestEconomyJournalRepairsTornTail: a torn final line must be truncated
+// before the next append, so the journal does not become permanently
+// unreadable (which would disable recovery and compaction).
+func TestEconomyJournalRepairsTornTail(t *testing.T) {
+ dir := t.TempDir()
+ j := newEconomyJournal(dir)
+ if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(j.path, []byte(`{"id":"torn","kind":"payout","status":"open"`), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := j.append(economyRecord{ID: "good", Player: "alice", Kind: "payout", Status: "open", Credit: 5}); err != nil {
+ t.Fatalf("append after torn tail: %v", err)
+ }
+ open, err := j.loadOpen()
+ if err != nil {
+ t.Fatalf("loadOpen after torn-tail repair: %v", err)
+ }
+ for _, rec := range open {
+ if rec.ID == "torn" {
+ t.Fatal("torn record survived repair")
+ }
+ }
+ found := false
+ for _, rec := range open {
+ if rec.ID == "good" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("valid record missing after torn-tail repair")
+ }
+}
diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go
index 4f013e7..9b9f8e6 100644
--- a/internal/game/production_menu.go
+++ b/internal/game/production_menu.go
@@ -107,6 +107,9 @@ func (g *Game) formatMaterials(sess *net.Session, item *item.ItemDef) string {
}
var parts []string
for _, e := range craft.Ingredients {
+ if len(e.Items) == 0 {
+ continue
+ }
itemName := e.Items[0]
if def, err := g.ItemStore.Load(e.Items[0]); err == nil {
itemName = g.itemColorize(sess, def, def.Name)
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index b2a2463..af8cc75 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -5,6 +5,7 @@ import (
"math/rand"
"sort"
+ "thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
@@ -36,13 +37,23 @@ func (g *Game) dropItemsOnDeath(p *player.Player) {
}
for eqSlot, itemID := range p.Equipment {
+ quantity := 1
+ // Ammo keeps its stack count in AmmoQty, not in an inventory slot;
+ // dropping the equipment entry without moving that count would destroy
+ // the rest of the stack and leave a stale ammo_qty on disk.
+ if eqSlot == item.SlotAmmo {
+ quantity = p.AmmoQty
+ if quantity <= 0 {
+ quantity = 1
+ }
+ }
val := 0
if def, err := g.ItemStore.Load(itemID); err == nil {
- val = def.Value
+ val = def.Value * quantity
}
items = append(items, deathDrop{
itemID: itemID,
- quantity: 1,
+ quantity: quantity,
totalValue: val,
isEquip: true,
equipSlot: eqSlot,
@@ -61,6 +72,12 @@ func (g *Game) dropItemsOnDeath(p *player.Player) {
it := items[i]
if it.isEquip {
delete(p.Equipment, it.equipSlot)
+ // Equipped quality is keyed by item id and must not outlive the
+ // equipment entry, or a later drop/wear reads a stale quality.
+ delete(p.EquipQuality, it.itemID)
+ if it.equipSlot == item.SlotAmmo {
+ p.AmmoQty = 0
+ }
} else {
p.SetInvSlot(it.invSlot, nil)
}