diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/act_effects.go | 86 | ||||
| -rw-r--r-- | internal/game/act_farm.go | 3 | ||||
| -rw-r--r-- | internal/game/act_gather.go | 37 | ||||
| -rw-r--r-- | internal/game/act_steal.go | 19 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 47 | ||||
| -rw-r--r-- | internal/game/casino.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_bank.go | 7 | ||||
| -rw-r--r-- | internal/game/cmd_consume.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_drop.go | 61 | ||||
| -rw-r--r-- | internal/game/cmd_estate_directory.go | 10 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 11 | ||||
| -rw-r--r-- | internal/game/cmd_swapid.go | 13 | ||||
| -rw-r--r-- | internal/game/cmd_trigger_utility.go | 62 | ||||
| -rw-r--r-- | internal/game/core_validate.go | 33 | ||||
| -rw-r--r-- | internal/game/economy.go | 68 | ||||
| -rw-r--r-- | internal/game/exit_migrate.go | 7 | ||||
| -rw-r--r-- | internal/game/game.go | 18 | ||||
| -rw-r--r-- | internal/game/hardening_fixes_batch2_test.go | 203 | ||||
| -rw-r--r-- | internal/game/production_menu.go | 3 | ||||
| -rw-r--r-- | internal/game/sys_combat.go | 21 |
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) } |
