aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_clean.go2
-rw-r--r--internal/game/act_farm.go4
-rw-r--r--internal/game/act_gather.go2
-rw-r--r--internal/game/act_identify.go6
-rw-r--r--internal/game/act_steal.go2
-rw-r--r--internal/game/casino.go135
-rw-r--r--internal/game/casino_test.go10
-rw-r--r--internal/game/cmd_bank.go4
-rw-r--r--internal/game/cmd_dig.go26
-rw-r--r--internal/game/cmd_dig_test.go57
-rw-r--r--internal/game/cmd_drop.go5
-rw-r--r--internal/game/cmd_estate_directory.go11
-rw-r--r--internal/game/cmd_get.go8
-rw-r--r--internal/game/cmd_inventory.go3
-rw-r--r--internal/game/cmd_remove.go4
-rw-r--r--internal/game/cmd_room_insert.go2
-rw-r--r--internal/game/cmd_shop.go4
-rw-r--r--internal/game/cmd_swapid.go19
-rw-r--r--internal/game/cmd_trigger_combat.go2
-rw-r--r--internal/game/cmd_trigger_utility.go4
-rw-r--r--internal/game/cmd_undig.go2
-rw-r--r--internal/game/cmd_wear.go4
-rw-r--r--internal/game/content_refs.go18
-rw-r--r--internal/game/content_refs_test.go21
-rw-r--r--internal/game/core_login_account.go5
-rw-r--r--internal/game/core_login_char.go13
-rw-r--r--internal/game/core_production.go2
-rw-r--r--internal/game/game.go4
-rw-r--r--internal/game/look_target.go2
-rw-r--r--internal/game/production_engine.go2
-rw-r--r--internal/game/production_index.go4
-rw-r--r--internal/game/sys_triggers.go7
32 files changed, 281 insertions, 113 deletions
diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go
index 4efe293..0540cb1 100644
--- a/internal/game/act_clean.go
+++ b/internal/game/act_clean.go
@@ -68,7 +68,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
}
consumeID := plan[0].id
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == consumeID {
slot.ItemID = matchItem.ID
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index 6725351..e83beb1 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -257,7 +257,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
noRoom := false
if stackable {
quality, maxQuality := stackProps(productDef)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh harvest stack gets
// may absorb it; merging into a differently qualified stack
@@ -275,7 +275,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) {
// One slot per unit: the clamp and the placement loop must count
// slots the same way (a nil 0..27 slot).
freeSlots := 0
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if p.InvSlot(i) == nil {
freeSlots++
}
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index 3bef8a0..df0ae49 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -417,7 +417,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *behavior.
}
freeSlot := -1
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if i == currentDropSlot {
continue
}
diff --git a/internal/game/act_identify.go b/internal/game/act_identify.go
index 6ff4d61..89bef35 100644
--- a/internal/game/act_identify.go
+++ b/internal/game/act_identify.go
@@ -29,7 +29,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
totalJunk := scrapCount * multiplier
hasExistingStack := false
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == junkID {
hasExistingStack = true
@@ -42,7 +42,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
return
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == "scrap" {
p.SetInvSlot(i, nil)
@@ -50,7 +50,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) {
}
placed := false
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == junkID {
slot.Quantity += totalJunk
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 5a7f93e..7df400b 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: 99999162},
+ Action: &behavior.Step{Teleport: jailRoomID},
Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 971952a..736add2 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -489,42 +489,47 @@ func (g *Game) executeBet(sess *net.Session, args []string, rawInput string) {
sess.WriteLine("You're not playing a game.")
return
}
- // Table bet syntax: bet | bet <amount> [on] [spot] | bet min|max [on] [spot].
- // Tokens that parse as an amount (or min/max) set the wager; any other
- // token is the bet target and the table game validates it.
- amount, spot, hasAmount := 0, "", false
+ // Table bet syntax: bet | bet <amount> [on] <target> | bet min|max [on] <target>.
+ // BEFORE an "on" keyword, a token that parses as an amount (or min/max)
+ // sets the wager and any other token is the bet target; AFTER "on", the
+ // next token is always the target. That makes numeric targets reachable
+ // ("bet 5 on 17"), which roulette, craps, and keno need.
+ amount, spot, hasAmount, sawOn := 0, "", false, false
for _, arg := range args {
if strings.EqualFold(arg, "on") {
+ sawOn = true
continue
}
- if strings.EqualFold(arg, "min") {
- if hasAmount {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
- }
- amount, hasAmount = table.Config.MinBet, true
- continue
- }
- if strings.EqualFold(arg, "max") {
- if hasAmount {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
+ if !sawOn {
+ if strings.EqualFold(arg, "min") {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount, hasAmount = table.Config.MinBet, true
+ continue
}
- amount = min(table.Config.MaxBet, casinoAffordable(p))
- if amount < 1 {
- sess.WriteLine("You don't have any chips or credits to bet.")
- return
+ if strings.EqualFold(arg, "max") {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount = min(table.Config.MaxBet, casinoAffordable(p))
+ if amount < 1 {
+ sess.WriteLine("You don't have any chips or credits to bet.")
+ return
+ }
+ hasAmount = true
+ continue
}
- hasAmount = true
- continue
- }
- if value, err := strconv.Atoi(arg); err == nil && value > 0 {
- if hasAmount {
- sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
- return
+ if value, err := strconv.Atoi(arg); err == nil && value > 0 {
+ if hasAmount {
+ sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
+ return
+ }
+ amount, hasAmount = value, true
+ continue
}
- amount, hasAmount = value, true
- continue
}
if spot != "" {
sess.WriteLine("Use bet, bet <amount> [on <target>], bet min, or bet max.")
@@ -579,12 +584,12 @@ func (g *Game) CasinoTick() {
func (g *Game) emitCasinoEvents(events []casino.Event) {
for _, event := range events {
- if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Blackjack == nil && event.Baccarat == nil && event.Slot == nil {
+ if event.Message == "" && event.PrivateMessage == "" && event.Menu == nil && event.Snapshot == nil {
continue
}
category := event.Color
if category == "" {
- category = casinoEventColor(event.Type)
+ category = "casino_action"
}
if event.Public && g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(event.RoomID) {
@@ -592,14 +597,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
continue
}
message := event.Message
- if event.Slot != nil && sess.Player != nil {
- message += "\n" + renderSlotSnapshot(event.Slot, g.colorMode(sess), sess.Player.OptionBool("unicode"))
- }
- if event.Blackjack != nil && sess.Player != nil {
- message += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
- }
- if event.Baccarat != nil && sess.Player != nil {
- message += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
+ message += "\n" + rendered
}
sess.WriteLine(g.colorize(sess, category, message))
}
@@ -621,11 +620,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
continue
}
privateMessage := g.casinoPrivateMessage(sess, category, event.Menu, text)
- if event.Blackjack != nil && sess.Player != nil {
- privateMessage += "\n" + renderBlackjackSnapshot(event.Blackjack, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
- }
- if event.Baccarat != nil && sess.Player != nil {
- privateMessage += "\n" + renderBaccaratSnapshot(event.Baccarat, g.colorMode(sess), sess.Player.OptionBool("unicode"), sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ if rendered := g.renderCasinoSnapshot(sess, event.Snapshot); rendered != "" {
+ privateMessage += "\n" + rendered
}
sess.WriteLine(g.renderCasinoMessage(sess, category, event.Menu, privateMessage))
if category == "casino_menu" {
@@ -643,29 +639,42 @@ func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *cas
return g.colorize(sess, category, message)
}
-func casinoEventColor(eventType casino.EventType) string {
- if eventType == casino.EventPayout {
- return "casino_win"
- }
- return "casino_action"
-}
-
func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
if category != "casino_menu" || menu == nil {
return message
}
- chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player)))
- credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
- balance := fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
- switch menu.Kind {
- case casino.MenuBlackjackBet, casino.MenuBaccaratBet:
- return g.renderCasinoCommands(sess, balance, message, menu.Commands)
- case casino.MenuBlackjackTurn:
- return g.renderCasinoCommands(sess, "", menu.Title, menu.Commands)
- case casino.MenuSlotCommands:
- return g.renderCasinoCommands(sess, balance, message, menu.Commands)
- }
- return message
+ balance := ""
+ if menu.ShowBalance {
+ chips := g.colorize(sess, "casino_chips", fmt.Sprintf("%d", countChips(sess.Player)))
+ credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
+ balance = fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
+ }
+ heading := menu.Heading
+ if heading == "" {
+ heading = message
+ }
+ return g.renderCasinoCommands(sess, balance, heading, menu.Commands)
+}
+
+// renderCasinoSnapshot renders a game's structured view for one session. It is
+// the only per-game switch in the presentation layer: adding a game means
+// adding its snapshot type here, and the Snapshot interface guarantees the
+// event carries it rather than being silently dropped by a field filter.
+func (g *Game) renderCasinoSnapshot(sess *net.Session, snapshot casino.Snapshot) string {
+ if snapshot == nil || sess.Player == nil {
+ return ""
+ }
+ mode := g.colorMode(sess)
+ unicode := sess.Player.OptionBool("unicode")
+ switch s := snapshot.(type) {
+ case *casino.SlotSnapshot:
+ return renderSlotSnapshot(s, mode, unicode)
+ case *casino.BlackjackSnapshot:
+ return renderBlackjackSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ case *casino.BaccaratSnapshot:
+ return renderBaccaratSnapshot(s, mode, unicode, sess.Player.OptionInt("card_size"), sess.Player.OptionString("card_style"), g.CardArt)
+ }
+ return ""
}
// renderCasinoCommands lays every casino menu out in the same style: an
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index 026415f..ffd9958 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -74,6 +74,14 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) {
if !strings.Contains(string(conn.data), "must be on player, banker, or tie") {
t.Fatalf("bet red 100: expected spot rejection, got %q", string(conn.data))
}
+ // A numeric token AFTER "on" is the target, not a second amount, so
+ // numeric spots (roulette numbers, craps hard ways) reach the table.
+ // Baccarat rejects the value, which proves the parser forwarded it.
+ conn.data = nil
+ g.executeBet(sess, []string{"5", "on", "17"}, "bet 5 on 17")
+ if !strings.Contains(string(conn.data), "must be on player, banker, or tie") {
+ t.Fatalf("bet 5 on 17: expected the table to receive the numeric spot, got %q", string(conn.data))
+ }
// Two amounts or two spots are usage errors.
for _, args := range [][]string{{"10", "20"}, {"player", "banker"}} {
conn.data = nil
@@ -95,7 +103,7 @@ func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) {
conn := &casinoTestConn{}
sess := &net.Session{Player: player.New("alice"), Conn: conn}
g.loggedInChars = map[string]*net.Session{"alice": sess}
- menu := &casino.MenuView{Kind: casino.MenuSlotCommands, Commands: []casino.MenuCommand{
+ menu := &casino.MenuView{Heading: "Your slot machine bet is set to 10.", ShowBalance: true, Commands: []casino.MenuCommand{
{Command: "bet <amount>", Desc: "Set your wager"},
{Command: "spin", Desc: "Spin using your current wager"},
}}
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 0bfc2a3..1ec0b7c 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -182,7 +182,7 @@ func (g *Game) doBankDepositAll(sess *net.Session) {
p := sess.Player
var deposited int
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
@@ -299,7 +299,7 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx
// means the exact same item instance: merging two different-quality
// stacks would silently destroy the withdrawn stack's properties.
if def != nil && def.Stackable {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID &&
slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality {
slot.Quantity += bs.Quantity
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index c58ba63..ff33490 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -127,7 +127,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
return
}
- newID, err := findNextRoomID(curPath)
+ newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex())
if err != nil {
sess.WriteLine("Error scanning room directory.")
return
@@ -222,14 +222,22 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
g.checkAggro(sess)
}
-func findNextRoomID(currentRoomPath string) (int, error) {
- dir := filepath.Dir(currentRoomPath)
- entries, err := os.ReadDir(dir)
+// findNextRoomID returns the first free room ID at or above localDir's
+// minimum, checked against every room ID in the world (globallyUsed). The
+// admin allocator (admin/id_alloc.go) does the same. A directory-scoped scan
+// could hand out an ID that already exists in another area, and
+// World.AddRoomPath would then silently shadow that room for the rest of the
+// process while leaving a duplicate file on disk.
+func findNextRoomID(localDir string, globallyUsed map[int]bool) (int, error) {
+ entries, err := os.ReadDir(localDir)
if err != nil {
return 0, err
}
- used := make(map[int]bool)
+ used := make(map[int]bool, len(globallyUsed)+len(entries))
+ for id := range globallyUsed {
+ used[id] = true
+ }
minID := 0
first := true
for _, entry := range entries {
@@ -248,11 +256,11 @@ func findNextRoomID(currentRoomPath string) (int, error) {
}
}
- if first {
- return 1, nil
+ start := 1
+ if !first {
+ start = minID
}
-
- for id := minID; ; id++ {
+ for id := start; ; id++ {
if !used[id] {
return id, nil
}
diff --git a/internal/game/cmd_dig_test.go b/internal/game/cmd_dig_test.go
new file mode 100644
index 0000000..1a96c25
--- /dev/null
+++ b/internal/game/cmd_dig_test.go
@@ -0,0 +1,57 @@
+package game
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestFindNextRoomIDSkipsIDsInOtherDirectories: the in-game allocator must
+// check every room ID in the world, not just the current room's directory.
+// A directory-scoped scan hands out an ID that already exists elsewhere, and
+// World.AddRoomPath then silently shadows that room.
+func TestFindNextRoomIDSkipsIDsInOtherDirectories(t *testing.T) {
+ root := t.TempDir()
+ intro := filepath.Join(root, "intro")
+ arena := filepath.Join(root, "arena")
+ for _, dir := range []string{intro, arena} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // intro owns 1001 and 1002; arena owns 1003.
+ for _, id := range []string{"1001", "1002"} {
+ if err := os.WriteFile(filepath.Join(intro, id+".yaml"), []byte("name: intro\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(arena, "1003.yaml"), []byte("name: arena\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ globallyUsed := map[int]bool{1001: true, 1002: true, 1003: true}
+
+ got, err := findNextRoomID(intro, globallyUsed)
+ if err != nil {
+ t.Fatalf("findNextRoomID: %v", err)
+ }
+ if got != 1004 {
+ t.Fatalf("findNextRoomID = %d, want 1004 (1003 is used by arena)", got)
+ }
+}
+
+// TestFindNextRoomIDEmptyDirectoryStartsAtOne: an empty directory still
+// allocates the globally lowest free ID rather than blindly returning 1.
+func TestFindNextRoomIDEmptyDirectoryStartsAtOne(t *testing.T) {
+ root := t.TempDir()
+ empty := filepath.Join(root, "empty")
+ if err := os.MkdirAll(empty, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ got, err := findNextRoomID(empty, map[int]bool{1: true, 2: true})
+ if err != nil {
+ t.Fatalf("findNextRoomID: %v", err)
+ }
+ if got != 3 {
+ t.Fatalf("findNextRoomID = %d, want 3", got)
+ }
+}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 278a57d..1c30cce 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -5,6 +5,7 @@ import (
"strings"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
)
func (g *Game) doDropAll(sess *net.Session) {
@@ -120,7 +121,7 @@ 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++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 {
qty += slot.Quantity
@@ -239,7 +240,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) {
}
var dropped []string
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.Quantity <= 0 {
continue
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index 9966e52..83c5b80 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "log"
"os"
"path/filepath"
"sort"
@@ -182,12 +183,20 @@ func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int {
}
houseMap := map[string]int{
- "local_neighborhood": 99999300,
+ "local_neighborhood": estateNeighborhoodID,
}
return houseMap[v]
}
func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
+ // Refuse a destination that does not exist: persisting a nonexistent
+ // RoomID soft-locks the character on login (see the login rescue in
+ // core_login_char.go). This is the single choke point every trigger,
+ // module, and estate teleport flows through.
+ if _, err := g.World.LoadRoom(roomID); err != nil {
+ log.Printf("teleport %s: destination room %d does not exist; ignoring", p.Name, roomID)
+ return
+ }
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess {
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index 33f55de..a806ffa 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -76,7 +76,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
if def != nil && def.Stackable {
quality, maxQuality := g.groundStackProps(p, itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
continue
@@ -198,7 +198,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
if def != nil && def.Stackable {
quality, maxQuality := g.groundStackProps(p, itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
continue
@@ -306,7 +306,7 @@ func (g *Game) doGetAll(sess *net.Session) {
if def != nil && def.Stackable {
stacked := false
quality, maxQuality := g.groundStackProps(p, itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil || slot.ItemID != itemID || slot.Quality != quality || slot.MaxQuality != maxQuality {
continue
@@ -485,7 +485,7 @@ func (g *Game) findGroundMatches(input string, roomID int) []itemMatch {
func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch {
return g.collectMatches(input, func(yield func(string, int) bool) {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go
index b7b52ed..fbde186 100644
--- a/internal/game/cmd_inventory.go
+++ b/internal/game/cmd_inventory.go
@@ -4,6 +4,7 @@ import (
"fmt"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
)
func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) {
@@ -15,7 +16,7 @@ func (g *Game) doInventory(sess *net.Session) {
sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots()))
num := 0
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index 53dd313..d03daec 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -98,7 +98,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) {
}
quality, maxQuality := g.pickedStackProps(ammoID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh stack gets may absorb the
// equipped stack (see pickedStackProps).
@@ -198,7 +198,7 @@ func (g *Game) doRemoveAll(sess *net.Session) {
// failure instead of silently destroying the ammo.
func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string) {
quality, maxQuality := g.pickedStackProps(itemID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh stack gets may absorb the
// equipped stack (see pickedStackProps).
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 8e01326..bf95282 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -58,7 +58,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
oppositeDir := world.OppositeExit[dir]
- newID, err := findNextRoomID(curPath)
+ newID, err := findNextRoomID(filepath.Dir(curPath), g.World.RoomIndex())
if err != nil {
sess.WriteLine("Error scanning room directory.")
return
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index e2209fc..282d3ca 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -382,7 +382,7 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin
func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDef) int {
if def != nil && def.Stackable {
quality, maxQuality := stackProps(def)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
// Capacity must agree with giveShopItems: only a stack with the
// properties a fresh stack gets can absorb a purchase (see
// stackProps). Counting a differently qualified stack here would
@@ -408,7 +408,7 @@ func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item
// harvest, ammo return and pickup: a differently qualified stack is
// not a merge target.
quality, maxQuality := stackProps(def)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
slot.Quantity += qty
return
diff --git a/internal/game/cmd_swapid.go b/internal/game/cmd_swapid.go
index 00b6568..8b637fb 100644
--- a/internal/game/cmd_swapid.go
+++ b/internal/game/cmd_swapid.go
@@ -117,7 +117,12 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
// The two swapped rooms are always re-marshaled to correct their `id:`
// field; other rooms are re-marshaled only if a reference actually moved.
idMap := map[int]int{id1: id2, id2: id1}
- var updated int
+ // Rewrite every room that references the swapped IDs in ONE journal
+ // transaction. The old per-file writeRoomFile loop swallowed errors, so
+ // a crash or a failed write mid-loop left a half-migrated world with no
+ // recovery record.
+ rewriteTxn := g.newTxn()
+ updated := 0
for id := range g.World.RoomIndex() {
room, err := g.World.LoadRoom(id)
if err != nil {
@@ -133,13 +138,19 @@ func (g *Game) executeSwapID(sess *net.Session, args []string, rawInput string)
}
data, merr := yaml.Marshal(room)
if merr != nil {
- continue
+ sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, merr))
+ return
}
- if werr := writeRoomFile(path, data); werr != nil {
- continue
+ if rerr := rewriteTxn.Replace(path, data, 0644); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error rewriting room %d: %v", id, rerr))
+ return
}
updated++
}
+ if rerr := rewriteTxn.Commit(); rerr != nil {
+ sess.WriteLine(fmt.Sprintf("Error rewriting room references: %v", rerr))
+ return
+ }
// Full runtime migration: Hub room buckets, casino room keys, world
// room-keyed state (ground items, seeded markers, object states), live
diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go
index 0578c2b..0d53e94 100644
--- a/internal/game/cmd_trigger_combat.go
+++ b/internal/game/cmd_trigger_combat.go
@@ -105,7 +105,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob
func (g *Game) findInventoryItem(p *player.Player, input string) (int, *player.InventorySlot) {
lower := strings.ToLower(strings.TrimSpace(input))
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index 481cd40..fa81f23 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -97,7 +97,7 @@ func (g *Game) triggerBonesToNuts(sess *net.Session, p *player.Player, mod *ModD
g.cancelAction(p)
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == "bones" {
slot.ItemID = "nutrient_bar"
@@ -283,7 +283,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
outputQty = 1
}
placed := false
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == match.ID {
slot.Quantity += outputQty
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
index a0fd1a0..c752c86 100644
--- a/internal/game/cmd_undig.go
+++ b/internal/game/cmd_undig.go
@@ -191,7 +191,7 @@ func (g *Game) performUndig(sess *net.Session, p *player.Player, targetID int) {
return nil
}
- changed := false
+ changed := room.RemoveRoomRefs(targetID)
for ed, exitDef := range room.Exits {
if exitDef.Room == targetID {
delete(room.Exits, ed)
diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go
index e459386..773e3c6 100644
--- a/internal/game/cmd_wear.go
+++ b/internal/game/cmd_wear.go
@@ -196,7 +196,7 @@ func (g *Game) unequipAmmo(p *player.Player) bool {
return true
}
quality, maxQuality := g.pickedStackProps(ammoID)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
s := p.InvSlot(i)
// Only a stack with the properties a fresh stack gets may absorb the
// equipped stack (see pickedStackProps).
@@ -227,7 +227,7 @@ func (g *Game) doWearAll(sess *net.Session) {
invSlot int
})
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/content_refs.go b/internal/game/content_refs.go
new file mode 100644
index 0000000..d3328a1
--- /dev/null
+++ b/internal/game/content_refs.go
@@ -0,0 +1,18 @@
+package game
+
+// Code-referenced room IDs.
+//
+// A few rooms are referenced by literal ID in game code rather than by YAML.
+// Renaming, moving, or deleting them compiles clean and validates clean (no
+// check inspects Go literals), but silently soft-locks the affected flow: the
+// thief's "take me to jail" option and the estate directory's "enter" both
+// teleport there. TestCodeReferencedRoomsExist pins their existence against
+// the shipped data so a move fails loudly instead.
+const (
+ // jailRoomID is the destination of the stall-guard's "Take me to jail"
+ // step in act_steal.go.
+ jailRoomID = 99999162
+ // estateNeighborhoodID is the local-neighborhood house room used by the
+ // construction/estate directory flow in cmd_estate_directory.go.
+ estateNeighborhoodID = 99999300
+)
diff --git a/internal/game/content_refs_test.go b/internal/game/content_refs_test.go
new file mode 100644
index 0000000..456a0ee
--- /dev/null
+++ b/internal/game/content_refs_test.go
@@ -0,0 +1,21 @@
+package game
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+// TestCodeReferencedRoomsExist guards the literal room IDs in content_refs.go.
+// If one of those rooms is moved or deleted, the thief-jail and
+// estate-directory teleports would silently soft-lock players, so the shipped
+// data must still contain them.
+func TestCodeReferencedRoomsExist(t *testing.T) {
+ w := world.New(filepath.Join("..", "..", "data"))
+ for _, id := range []int{jailRoomID, estateNeighborhoodID} {
+ if _, err := w.LoadRoom(id); err != nil {
+ t.Errorf("code-referenced room %d is missing from data/rooms: %v", id, err)
+ }
+ }
+}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index e9fa67f..3b35b15 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -158,7 +158,10 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) {
return
}
throttle := g.ensureLoginThrottle()
- key := loginThrottleKey(name, sess.RemoteAddr())
+ // Key on the host, not RemoteAddr: the latter carries the ephemeral
+ // source port, so a reconnect would land on a fresh key and reset the
+ // escalation. RemoteHost strips the port (see net.Session.RemoteHost).
+ key := loginThrottleKey(name, sess.RemoteHost())
auth := passwordAuth{}
acc, err := g.AccountStore.LoadAccount(name)
switch {
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 3a2e324..41e25b8 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "log"
"strings"
"thehouseoficarus/internal/net"
@@ -128,6 +129,18 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
}
}
+ // Rescue a character whose saved room no longer exists (the room was
+ // deleted, or a code-referenced teleport target was removed). Persisting
+ // a nonexistent RoomID soft-locks the character on every later login
+ // because movement and every room lookup fail.
+ if _, err := g.World.LoadRoom(p.RoomID); err != nil {
+ log.Printf("character %s: saved room %d no longer exists; relocating to starting room %d", p.Name, p.RoomID, g.StartingRoom)
+ p.RoomID = g.StartingRoom
+ if _, err := g.World.LoadRoom(p.RoomID); err != nil {
+ log.Printf("character %s: starting room %d also missing; leaving room as %d", p.Name, g.StartingRoom, p.RoomID)
+ }
+ }
+
p.Stats.RecordRoomVisit(p.RoomID)
g.World.SeedGroundItems(p.RoomID)
g.seedRoomMobs(p.RoomID)
diff --git a/internal/game/core_production.go b/internal/game/core_production.go
index afffa7c..7a4b6e9 100644
--- a/internal/game/core_production.go
+++ b/internal/game/core_production.go
@@ -16,7 +16,7 @@ func (g *Game) findTool(p *player.Player, toolTypes []string) (toolSpeed float64
}
}
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/game.go b/internal/game/game.go
index af2ff66..c0c6488 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -764,6 +764,10 @@ func (g *Game) ProcessQueuedCommands() {
func (g *Game) buildCraftIndex() {
items, err := g.ItemStore.LoadAll()
if err != nil {
+ // A malformed item file must not silently empty every crafting menu;
+ // surface it so the operator can fix the file (the validator also
+ // reports it).
+ log.Printf("craft index: item load failed; craft index left empty: %v", err)
return
}
g.CraftIndex.Build(items)
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 5f3818d..49a2ec7 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -234,7 +234,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot == nil {
continue
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 89d6f55..9795db0 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -440,7 +440,7 @@ func (g *Game) canContinueProduction(p *player.Player, item *item.ItemDef) bool
}
outDef, _ := g.ItemStore.Load(item.ID)
if outDef != nil && outDef.Stackable {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == item.ID {
return true
diff --git a/internal/game/production_index.go b/internal/game/production_index.go
index 35466a7..22e944e 100644
--- a/internal/game/production_index.go
+++ b/internal/game/production_index.go
@@ -293,7 +293,7 @@ func stackProps(def *item.ItemDef) (quality, maxQuality int) {
func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool {
if outDef != nil && outDef.Stackable {
quality, maxQuality := stackProps(outDef)
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
return true
@@ -311,7 +311,7 @@ func craftOutputFits(p *player.Player, itemID string, outDef *item.ItemDef) bool
func placeCraftOutput(p *player.Player, itemID string, outDef *item.ItemDef, outputQty int) bool {
quality, maxQuality := stackProps(outDef)
if outDef != nil && outDef.Stackable {
- for i := 0; i < 28; i++ {
+ for i := 0; i < player.MaxInventorySlots; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == itemID && slot.Quality == quality && slot.MaxQuality == maxQuality {
slot.Quantity += outputQty
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index a6feec2..61eb325 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -569,6 +569,9 @@ func (g *Game) loadGlobalTriggers(dataDir string) error {
}
data, err := os.ReadFile(full)
if err != nil {
+ // A permissions/IO error here would otherwise disable a
+ // file-backed flag trigger with no trace at all.
+ log.Printf("trigger load %q: %v (skipped)", full, err)
continue
}
var t behavior.Trigger
@@ -603,7 +606,9 @@ func copyTrigger(t behavior.Trigger) *behavior.Trigger {
func (g *Game) loadAllFlagTriggers() {
g.flagIndex.clear()
- _ = g.loadGlobalTriggers(g.DataDir)
+ if err := g.loadGlobalTriggers(g.DataDir); err != nil {
+ log.Printf("trigger load: %v (some file-backed flag triggers may be missing)", err)
+ }
}
// firePlayerFlagTriggers starts sequences for every on_flag_change trigger