diff options
| author | historia <[not public]> | 2026-09-15 13:56:10 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 13:56:48 -0400 |
| commit | b3568f03e4103dd46db2ccc680fbab0a6454d604 (patch) | |
| tree | db3f6bb4f3d7c1deefbcd50de0f5022756d6b9ae /internal | |
| parent | f4ea40c4b3589104dfaced3481ba19c5735889ae (diff) | |
| download | thehouseoficarus-b3568f03e4103dd46db2ccc680fbab0a6454d604.tar.gz | |
fix: telnet buffer reuse, bank stack handling,
transport casino gate, and admin cache invalidation
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_items.go | 11 | ||||
| -rw-r--r-- | internal/admin/api_items_undo_test.go | 82 | ||||
| -rw-r--r-- | internal/admin/api_players.go | 41 | ||||
| -rw-r--r-- | internal/admin/server.go | 44 | ||||
| -rw-r--r-- | internal/casino/blackjack.go | 9 | ||||
| -rw-r--r-- | internal/casino/blackjack_timer_test.go | 50 | ||||
| -rw-r--r-- | internal/casino/machine.go | 16 | ||||
| -rw-r--r-- | internal/casino/machine_test.go | 31 | ||||
| -rw-r--r-- | internal/game/bank_transport_regression_test.go | 114 | ||||
| -rw-r--r-- | internal/game/casino.go | 10 | ||||
| -rw-r--r-- | internal/game/cmd_bank.go | 76 | ||||
| -rw-r--r-- | internal/game/cmd_quit.go | 11 | ||||
| -rw-r--r-- | internal/game/cmd_trigger.go | 8 | ||||
| -rw-r--r-- | internal/game/cmd_trigger_transport.go | 14 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 29 | ||||
| -rw-r--r-- | internal/game/core_login_char.go | 23 | ||||
| -rw-r--r-- | internal/game/game.go | 10 | ||||
| -rw-r--r-- | internal/net/conn.go | 37 | ||||
| -rw-r--r-- | internal/net/conn_bound_test.go | 42 | ||||
| -rw-r--r-- | internal/net/server.go | 2 | ||||
| -rw-r--r-- | internal/net/wsconn.go | 3 |
21 files changed, 602 insertions, 61 deletions
diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go index 617908c..728997a 100644 --- a/internal/admin/api_items.go +++ b/internal/admin/api_items.go @@ -46,6 +46,14 @@ func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) { } } +// invalidateItemCache drops the in-game item cache after an editor write so +// gameplay picks up the change without a manual reload, and rebuilds the +// craft index. Also refreshes the path index in case the file was created +// or removed. +func (s *AdminServer) invalidateItemCache() { + s.invalidateEntityCaches("items") +} + func (s *AdminServer) findItemFile(id string) ([]byte, string, error) { data, path, err := readYAMLFile(s.dataDir, "items", id) if err == nil { @@ -103,6 +111,7 @@ func (s *AdminServer) updateItem(w http.ResponseWriter, r *http.Request, id stri OldContent: oldContent, NewContent: newContent, }) + s.invalidateItemCache() writeJSON(w, m) } @@ -127,6 +136,7 @@ func (s *AdminServer) deleteItem(w http.ResponseWriter, r *http.Request, id stri OldContent: oldContent, IsDelete: true, }) + s.invalidateItemCache() writeJSON(w, map[string]any{"deleted": id}) } @@ -162,6 +172,7 @@ func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) { NewContent: newContent, IsCreate: true, }) + s.invalidateItemCache() writeJSON(w, m) } diff --git a/internal/admin/api_items_undo_test.go b/internal/admin/api_items_undo_test.go new file mode 100644 index 0000000..3ccbe10 --- /dev/null +++ b/internal/admin/api_items_undo_test.go @@ -0,0 +1,82 @@ +package admin + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func newTestWorld(dir string) *world.World { + return world.New(dir) +} + +// Regression: _raw must not leak the password hash even when it is a +// multiline YAML block scalar, which line-based masking misses. +func TestAccountAPIRedactsMultilinePasswordHash(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "players", "accounts"), 0o755); err != nil { + t.Fatal(err) + } + body := "name: alice\npassword_hash: |-\n sensitive-hash-sentinel\n" + if err := os.WriteFile(filepath.Join(dir, "players", "accounts", "alice.yaml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + s := &AdminServer{accountStore: player.NewAccountStore(dir)} + + rr := httptest.NewRecorder() + s.getAccount(rr, httptest.NewRequest("GET", "/api/players/account/alice", nil), "alice") + + if strings.Contains(rr.Body.String(), "sensitive-hash-sentinel") { + t.Fatalf("password hash leaked in response: %s", rr.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("response is not valid json: %v", err) + } +} + +// Regression: undo/redo of an item edit must invalidate the runtime item +// cache (and rebuild dependent indexes), not leave gameplay on the stale +// definition. +func TestUndoRefreshesRuntimeItemCache(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "items"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "rooms"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "items", "coin.yaml"), []byte("name: Coin\nvalue: 1\n"), 0o644); err != nil { + t.Fatal(err) + } + s := &AdminServer{ + dataDir: dir, + itemStore: item.NewItemStore(dir), + world: newTestWorld(dir), + undoStack: NewUndoStack(dir), + } + + // Warm the cache with the original definition. + if _, err := s.itemStore.Load("coin"); err != nil { + t.Fatal(err) + } + s.updateItem(httptest.NewRecorder(), httptest.NewRequest("PUT", "/api/items/coin", + strings.NewReader(`{"name":"Coin","value":9}`)), "coin") + if updated, err := s.itemStore.Load("coin"); err != nil || updated.Value != 9 { + t.Fatalf("item edit did not reach the runtime cache: %v %+v", err, updated) + } + + s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil)) + + restored, err := s.itemStore.Load("coin") + if err != nil || restored.Value != 1 { + t.Fatalf("undo left stale runtime state: %v value=%+v", err, restored) + } +} diff --git a/internal/admin/api_players.go b/internal/admin/api_players.go index f7a0b87..f0bbe31 100644 --- a/internal/admin/api_players.go +++ b/internal/admin/api_players.go @@ -1,6 +1,7 @@ package admin import ( + "bytes" "net/http" "os" "path/filepath" @@ -157,6 +158,44 @@ func (s *AdminServer) getAccount(w http.ResponseWriter, r *http.Request, name st delete(m, "password_hash") m["_path"] = accPath - m["_raw"] = string(data) + // _raw must not leak the password hash back over the API. Re-encode the + // parsed document with the password_hash value replaced so multiline and + // quoted values are redacted too — substring masking only covers the key's + // own line. + m["_raw"] = redactYAMLValue(data, "password_hash") writeJSON(w, m) } + +// redactYAMLValue returns data re-encoded with every top-level value for the +// named key replaced by a fixed placeholder. Empty output falls back to the +// original data so a parse failure never silently hides the document. +func redactYAMLValue(data []byte, key string) string { + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil || len(node.Content) == 0 { + return string(data) + } + root := node.Content[0] + if root.Kind != yaml.MappingNode { + return string(data) + } + changed := false + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == key { + root.Content[i+1].Value = "[REDACTED]" + root.Content[i+1].Tag = "!!str" + root.Content[i+1].Style = 0 + changed = true + } + } + if !changed { + return string(data) + } + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&node); err != nil { + return "[REDACTED]" + } + enc.Close() + return buf.String() +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 842f640..ccad6a2 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -58,9 +58,13 @@ type AdminServer struct { // configPath is the on-disk config.yaml path, used by the color themer // to persist edited default_colors back to the file the game loaded. configPath string + // rebuildCraftIndex rebuilds the in-game craft index after item files + // change (create/update/delete/rename/move/undo/redo). Wired from the + // Game so the web editor's item edits stay consistent with gameplay. + rebuildCraftIndex func() } -func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), configPath string) (*AdminServer, error) { +func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), rebuildCraftIndex func(), configPath string) (*AdminServer, error) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("cookie secret: %w", err) @@ -92,20 +96,21 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor } s := &AdminServer{ - cfg: cfg, - useTLS: useTLS, - accountStore: accountStore, - world: w, - itemStore: is, - objectStore: os, - mobStore: ms, - dataDir: dataDir, - undoStack: NewUndoStack(dataDir), - tmpl: tmpl, - cookieSecret: secret, - reloadCourses: reloadCourses, - rewriteRoomIDs: rewriteRoomIDs, - configPath: configPath, + cfg: cfg, + useTLS: useTLS, + accountStore: accountStore, + world: w, + itemStore: is, + objectStore: os, + mobStore: ms, + dataDir: dataDir, + undoStack: NewUndoStack(dataDir), + tmpl: tmpl, + cookieSecret: secret, + reloadCourses: reloadCourses, + rewriteRoomIDs: rewriteRoomIDs, + rebuildCraftIndex: rebuildCraftIndex, + configPath: configPath, } mux := http.NewServeMux() @@ -657,11 +662,18 @@ func (s *AdminServer) invalidateEntityCaches(entityType string) { s.mobStore.ReloadDefs(s.dataDir) case "drops": behavior.ClearDropIndex() + case "items": + if s.itemStore != nil { + s.itemStore.Reload(s.dataDir) + } + if s.rebuildCraftIndex != nil { + s.rebuildCraftIndex() + } } } func (s *AdminServer) invalidateEntityCacheForPath(path string) { - for _, subdir := range []string{"mobs", "drops"} { + for _, subdir := range []string{"mobs", "drops", "items"} { rel, err := filepath.Rel(filepath.Join(s.dataDir, subdir), path) if err == nil && !strings.HasPrefix(rel, "..") && rel != "." { s.invalidateEntityCaches(subdir) diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 442d4a2..4901c31 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -214,6 +214,9 @@ func (t *blackjackTable) tick() []Event { } active.hands[t.activeHand].stood = true events := []Event{t.private(name, "Your action timed out; standing.")} + // Restart the timer so the next active hand gets a full action window + // instead of timing out on the following tick. + t.timer.start(t.Rules.ActionTicks) events = append(events, t.finishHand()...) return events default: @@ -232,7 +235,8 @@ func (t *blackjackTable) action(name, action string) []Event { } p := t.players.get(name) hand := p.hands[t.activeHand] - t.timer.start(t.Rules.ActionTicks) + // The action window only restarts when a new turn is actually prompted + // (see prompt()); a rejected command can no longer stall the table. switch action { case "hit": if fromSplitAces(hand) { @@ -421,6 +425,9 @@ func (t *blackjackTable) beginPlay() { } func (t *blackjackTable) prompt() []Event { + // Every freshly-prompted turn gets a full action window; rejected inputs + // and timeouts can never extend the deadline. + t.timer.start(t.Rules.ActionTicks) name := t.activeName() if name == "" { return t.settle() diff --git a/internal/casino/blackjack_timer_test.go b/internal/casino/blackjack_timer_test.go new file mode 100644 index 0000000..5a57682 --- /dev/null +++ b/internal/casino/blackjack_timer_test.go @@ -0,0 +1,50 @@ +package casino + +import "testing" + +// Regression: the blackjack action window may only restart when a new turn +// is actually prompted. Rejected actions must never extend the deadline, and +// each successive player must receive a full window. +func TestBlackjackActionWindowBoundaries(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, + Blackjack: BlackjackConfig{ActionTicks: 3}}, newTestRand()) + for _, name := range []string{"alice", "bob", "carol"} { + table.players.add(name, &blackjackPlayer{ + Participant: Participant{Name: name, Connected: true, HasBet: true, Wallet: &testWallet{credits: 100}}, + hands: []*blackjackHand{{bet: 10, cards: []card{{rank: "10"}, {rank: "6"}}}}, + }) + } + table.dealer.cards = []card{{rank: "10"}, {rank: "7"}} + table.beginPlay() + table.prompt() + + // Alice burns one tick, then submits an invalid action and one she cannot + // take (surrender disabled by default). Neither may reset her window. + table.tick() + table.action("alice", "invalid") + table.action("alice", "surrender") + if table.timer.clock != 1 { + t.Fatalf("rejected actions extended or reset the deadline: timer=%+v", table.timer) + } + + // Two more ticks expire her window; the timeout advances to Bob. + table.tick() + table.tick() + if table.activeName() != "bob" { + t.Fatalf("timeout did not advance to bob: active=%s", table.activeName()) + } + if table.timer.clock != 0 { + t.Fatalf("bob did not receive a fresh action window: timer=%+v", table.timer) + } + + // Bob must survive two ticks and time out on the third. + table.tick() + table.tick() + if table.activeName() != "bob" { + t.Fatalf("bob timed out early: active=%s", table.activeName()) + } + table.tick() + if table.activeName() != "carol" { + t.Fatalf("bob did not time out after his window: active=%s", table.activeName()) + } +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 2f8b753..15c612d 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -26,6 +26,7 @@ type Machine struct { reel int clock int freeSpins int + inFreeSpins bool spinBet int payoutRemainder float64 profile SlotProfile @@ -237,8 +238,13 @@ func (m *Machine) finishSpin() []Event { winEvents[0].Color = "casino_win" events = append(events, winEvents...) } - if m.spin.ScatterCount >= 3 && m.freeSpins == 0 { + // A paid feature in progress never retriggers: scatters landing during a + // free spin are ignored, matching the CalculateRTP model (the feature is + // a fixed number of automatic spins). Retriggering only on a paid spin + // keeps the machine's actual return aligned with that model. + if m.spin.ScatterCount >= 3 && m.freeSpins == 0 && !m.inFreeSpins { m.freeSpins = m.profile.FreeSpinCount + m.inFreeSpins = true events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) } if m.freeSpins > 0 { @@ -247,8 +253,11 @@ func (m *Machine) finishSpin() []Event { m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) m.reel, m.clock, m.suspense = 0, 0, false events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...) - } else if !m.autoBet { - events = append(events, m.menuEvents("")...) + } else { + m.inFreeSpins = false + if !m.autoBet { + events = append(events, m.menuEvents("")...) + } } return events } @@ -277,6 +286,7 @@ func (m *Machine) resetSeat() { m.phase = machineReady m.reel, m.clock, m.suspense = 0, 0, false m.freeSpins = 0 + m.inFreeSpins = false m.payoutRemainder = 0 m.autoBet, m.autoCountdown = false, 0 } diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go index 53fe2e6..349a452 100644 --- a/internal/casino/machine_test.go +++ b/internal/casino/machine_test.go @@ -113,3 +113,34 @@ func TestMachineDisconnectFreesSeatAndForfeitsSpin(t *testing.T) { t.Fatalf("empty machine emitted events after disconnect: %+v", events) } } + +func TestMachineFreeSpinsDoNotRetrigger(t *testing.T) { + m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7))) + m.player = &Participant{Name: "alice", Connected: true, Wallet: &testWallet{credits: 100}} + m.spinBet = 10 + m.inFreeSpins = true + m.freeSpins = 1 + m.spin = &SlotSpin{ScatterCount: 3} + events := m.finishSpin() + for _, e := range events { + if hasMessage([]Event{e}, "trigger") { + t.Fatalf("scatters during free spins retriggered: %+v", events) + } + } + // The last free spin resolved; the feature is now over. + m.spin = &SlotSpin{ScatterCount: 3} + m.finishSpin() + if m.freeSpins != 0 || m.inFreeSpins { + t.Fatalf("free-spin feature did not end: freeSpins=%d inFreeSpins=%v", m.freeSpins, m.inFreeSpins) + } + // Scatters on a paid spin trigger normally again. + m.phase = machineReady + m.spin = &SlotSpin{ScatterCount: 3} + events = m.finishSpin() + if m.freeSpins != m.profile.FreeSpinCount-1 || !m.inFreeSpins { + t.Fatalf("paid-spin trigger broken: freeSpins=%d inFreeSpins=%v", m.freeSpins, m.inFreeSpins) + } + if !hasMessage(events, "trigger") { + t.Fatalf("missing trigger message: %+v", events) + } +} diff --git a/internal/game/bank_transport_regression_test.go b/internal/game/bank_transport_regression_test.go new file mode 100644 index 0000000..529df84 --- /dev/null +++ b/internal/game/bank_transport_regression_test.go @@ -0,0 +1,114 @@ +package game + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/item" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +// regressionGame builds a minimal Game with real stores over a temp data dir. +func regressionGame(t *testing.T) (*Game, *net.Session) { + t.Helper() + dir := t.TempDir() + for _, sub := range []string{"items", "rooms"} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "items", "chips.yaml"), []byte("name: chips\nstackable: true\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "rooms", "1.yaml"), []byte("name: test\n"), 0o644); err != nil { + t.Fatal(err) + } + g := &Game{ + Deps: Deps{ + World: world.New(dir), + ItemStore: item.NewItemStore(dir), + AccountStore: player.NewAccountStore(dir), + }, + Casino: casino.NewManager(1), + Combat: combat.NewTracker(), + } + sess := &net.Session{Player: player.New("alice"), Conn: &casinoTestConn{}} + sess.Player.RoomID = 1 + return g, sess +} + +// Regression: a stackable withdrawal must succeed via stack merging even +// when the inventory has zero free slots. +func TestBankWithdrawMergesStackIntoFullInventory(t *testing.T) { + g, sess := regressionGame(t) + for i := 0; i < 28; i++ { + sess.Player.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) + } + sess.Player.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) + sess.Player.SetBankSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 5}) + + g.doBankWithdraw(sess, "1") + + inv := sess.Player.InvSlot(0) + if inv == nil || inv.Quantity != 15 { + t.Fatalf("stack withdrawal blocked by a full inventory: inventory=%+v", inv) + } + if sess.Player.BankSlot(0) != nil { + t.Fatal("bank slot was not cleared after the withdrawal") + } +} + +// Regression: stacks of the same item but different quality must not merge, +// which would silently destroy the withdrawn stack's properties. +func TestBankWithdrawKeepsIncompatibleQualitySeparate(t *testing.T) { + g, sess := regressionGame(t) + sess.Player.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10, Quality: 3, MaxQuality: 10}) + sess.Player.SetBankSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 5, Quality: 8, MaxQuality: 10}) + + g.doBankWithdraw(sess, "1") + + first, second := sess.Player.InvSlot(0), sess.Player.InvSlot(1) + if first == nil || first.Quantity != 10 || first.Quality != 3 { + t.Fatalf("existing stack was disturbed: %+v", first) + } + if second == nil || second.Quantity != 5 || second.Quality != 8 || second.MaxQuality != 10 { + t.Fatalf("incompatible stack was merged or metadata lost: first=%+v second=%+v", first, second) + } +} + +// Regression: sequenced transport modules (like transport_home) bypass +// triggerTransport entirely; the casino gate must also apply when the module +// is started. +func TestSequencedTransportBlockedWhilePlaying(t *testing.T) { + g, sess := regressionGame(t) + + oldMods, oldIndex := AllMods, modByID + t.Cleanup(func() { AllMods, modByID = oldMods, oldIndex }) + mod := &ModDef{ + ID: "transport_home", Name: "transport home", Category: ModTransport, + Level: 1, Destination: 2, + Sequence: []behavior.ModTriggerStep{{Message: "wait", Delay: 1}}, + } + AllMods = []*ModDef{mod} + modByID = map[string]*ModDef{mod.ID: mod} + + g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, sess.Player.Name) + conn := sess.Conn.(*casinoTestConn) + conn.data = nil + + g.doTrigger(sess, "transport_home") + + if sess.Player.Action != nil { + t.Fatalf("transport sequence scheduled despite casino participation: %+v", sess.Player.Action) + } + if !strings.Contains(string(conn.data), "playing a game") { + t.Fatalf("missing casino-gate message: %q", string(conn.data)) + } +} diff --git a/internal/game/casino.go b/internal/game/casino.go index 9f3b524..2e80bf3 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -47,6 +47,16 @@ func (w casinoWallet) PayCredits(amount int) { // then credits. func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) } +// inCasinoGame reports whether the player is currently seated at any casino +// table or machine. Nil-safe for test scaffolding without a Casino manager. +func (g *Game) inCasinoGame(name string) bool { + if g.Casino == nil { + return false + } + _, playing := g.Casino.Participation(name) + return playing +} + func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) { room, err := g.World.LoadRoom(roomID) if err != nil { diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 3c4cac3..86057cc 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -78,6 +78,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { type bankEntry struct { slot int + pos int name string id string qty int @@ -94,7 +95,10 @@ func (g *Game) showBankBrowse(sess *net.Session) { if def != nil { itemName = def.Name } - entries = append(entries, bankEntry{slot: i, name: itemName, id: slot.ItemID, qty: slot.Quantity}) + // pos is the 1-based display position; it is also the number the + // withdraw command accepts, so gaps in bank slot numbering can no + // longer make a displayed number select the wrong item. + entries = append(entries, bankEntry{slot: i, pos: len(entries) + 1, name: itemName, id: slot.ItemID, qty: slot.Quantity}) } if len(entries) == 0 && p.BankedCredits <= 0 { @@ -109,7 +113,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { for _, e := range entries { def, _ := g.ItemStore.Load(e.id) t.Rows = append(t.Rows, []string{ - fmt.Sprintf("%d", e.slot+1), + fmt.Sprintf("%d", e.pos), g.itemColorize(sess, def, e.name), fmt.Sprintf("%d", e.qty), }) @@ -153,9 +157,10 @@ func (g *Game) doBankDeposit(sess *net.Session, input string) { bankIdx := p.NextBankSlot() p.SetBankSlot(bankIdx, &player.InventorySlot{ - ItemID: itemID, - Quantity: qty, - Quality: slot.Quality, + ItemID: itemID, + Quantity: qty, + Quality: slot.Quality, + MaxQuality: slot.MaxQuality, }) g.AccountStore.SaveCharacter(p) @@ -183,9 +188,10 @@ func (g *Game) doBankDepositAll(sess *net.Session) { } bankIdx := p.NextBankSlot() p.SetBankSlot(bankIdx, &player.InventorySlot{ - ItemID: slot.ItemID, - Quantity: slot.Quantity, - Quality: slot.Quality, + ItemID: slot.ItemID, + Quantity: slot.Quantity, + Quality: slot.Quality, + MaxQuality: slot.MaxQuality, }) p.SetInvSlot(i, nil) deposited++ @@ -204,19 +210,18 @@ func (g *Game) doBankDepositAll(sess *net.Session) { func (g *Game) doBankWithdraw(sess *net.Session, input string) { p := sess.Player - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - return - } + // No upfront capacity check here: withdrawBankItem merges stackable items + // into existing stacks first and only needs a free slot otherwise. - var entries []struct { + type entryRef struct { slot int + pos int name string id string qty int } + var entries []entryRef for _, i := range p.BankSlots() { bs := p.BankSlot(i) if bs == nil { @@ -227,12 +232,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { if def != nil { itemName = def.Name } - entries = append(entries, struct { - slot int - name string - id string - qty int - }{slot: i, name: itemName, id: bs.ItemID, qty: bs.Quantity}) + entries = append(entries, entryRef{slot: i, pos: len(entries) + 1, name: itemName, id: bs.ItemID, qty: bs.Quantity}) } idx, err := parseChoiceIndex(input) @@ -242,12 +242,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { return } - var matches []struct { - slot int - name string - id string - qty int - } + var matches []entryRef lower := strings.ToLower(input) for _, e := range entries { if behavior.WordPrefixMatch(e.name, lower) { @@ -293,6 +288,30 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx return } + def, _ := g.ItemStore.Load(bs.ItemID) + + // Stackable withdrawals merge into a compatible stack first; a free slot + // is only required when the player holds none of the item. Compatibility + // 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++ { + if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID && + slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality { + slot.Quantity += bs.Quantity + p.SetBankSlot(bankSlotIdx, nil) + g.AccountStore.SaveCharacter(p) + displayName := bs.ItemID + if def != nil { + displayName = def.Name + } + itemColor := g.itemColorize(sess, def, displayName) + sess.WriteLine(fmt.Sprintf("You withdraw %d %ss.", bs.Quantity, itemColor)) + return + } + } + } + freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Your inventory is full.") @@ -301,12 +320,11 @@ func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx itemID := bs.ItemID qty := bs.Quantity - def, _ := g.ItemStore.Load(itemID) if def != nil && def.Stackable { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty, Quality: bs.Quality, MaxQuality: bs.MaxQuality}) } else { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1, Quality: bs.Quality, MaxQuality: bs.MaxQuality}) if qty > 1 { bs.Quantity-- g.AccountStore.SaveCharacter(p) diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index fa4038b..0eb8fb9 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -11,16 +11,19 @@ func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { func (g *Game) doQuit(sess *net.Session) { p := sess.Player - if _, playing := g.Casino.Participation(p.Name); playing { - sess.WriteLine(g.colorize(sess, "warning", "WARNING: You're in the middle of a game!")) - g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) - } + // Combat blocks quitting outright; check it before forfeiting any casino + // wager so a cancelled quit never costs the player their round. if g.Combat.Get(p.Name) != nil { sess.WriteLine("You can't rest during combat!") return } + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine(g.colorize(sess, "warning", "WARNING: You're in the middle of a game!")) + g.emitCasinoEvents(g.Casino.MarkDisconnected(p.Name)) + } + g.cancelAction(p) g.cancelRest(p.Name) diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index f74efec..404b924 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -44,6 +44,14 @@ func (g *Game) doTrigger(sess *net.Session, input string) { sess.WriteLine("You can't imagine transport would pick you up here") return } + // This also covers sequenced transport modules (e.g. transport_home), + // which never pass through triggerTransport. Transport would strand + // the player at a remote room while still seated at a table or + // machine; require them to stop playing first. + if g.inCasinoGame(p.Name) { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before transporting.") + return + } } if mod.ID == "transport_home" && p.HomeTransportCooldown > 0 { diff --git a/internal/game/cmd_trigger_transport.go b/internal/game/cmd_trigger_transport.go index e2053b0..dbb0d16 100644 --- a/internal/game/cmd_trigger_transport.go +++ b/internal/game/cmd_trigger_transport.go @@ -13,6 +13,13 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef return } + // Transport would strand the player at a remote room while still seated at + // a table or machine; require them to stop playing first. + if _, playing := g.Casino.Participation(p.Name); playing { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before transporting.") + return + } + if p.Action != nil { g.cancelAction(p) } @@ -22,6 +29,13 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef } func (g *Game) executeTransportEffect(sess *net.Session, p *player.Player, mod *ModDef) { + // Safety net: sequenced modules reach the effect from advanceTriggerModule + // without re-entering triggerTransport. Never teleport a seated gambler. + if g.inCasinoGame(p.Name) { + sess.WriteLine("You're playing a game. Type \"stop\" between rounds before transporting.") + return + } + if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess { diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 186a5c3..77b72d3 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -340,6 +340,25 @@ func (g *Game) handleRenameAccount(sess *net.Session, input string) { return } + // Persist the new name inside the account file itself; otherwise the next + // login adopts the old name again and account lookup drifts apart from + // the filename. Roll the rename back if the file cannot be updated so the + // account never ends up in a half-renamed state. + acc, err := g.AccountStore.LoadAccount(newName) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + os.Rename(newPath, oldPath) + g.showMenu(sess) + return + } + acc.Name = newName + if err := g.AccountStore.SaveAccount(acc); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + os.Rename(newPath, oldPath) + g.showMenu(sess) + return + } + sess.Account.Name = newName sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) g.showMenu(sess) @@ -354,6 +373,16 @@ func (g *Game) handlePurgeAccount(sess *net.Session, input string) { return } + // Refuse to purge while any character is connected: a live session would + // keep playing (and saving) a character whose file is about to vanish. + for _, name := range sess.Account.Characters { + if g.characterOnline(name) { + sess.WriteLine(fmt.Sprintf("%s is logged in. Log all characters out before purging.", name)) + g.showMenu(sess) + return + } + } + for _, name := range sess.Account.Characters { os.Remove(g.AccountStore.CharPath(name)) } diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index 38499e2..9d7c211 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -144,6 +144,16 @@ func (g *Game) handleRenameChar(sess *net.Session, input string) { sess.Write("New name: ") } +// characterOnline reports whether the named character is currently connected +// through a live session. Menu operations that rename or delete character +// files must refuse while the character is online, otherwise the in-memory +// player later overwrites or resurrects the moved/removed file. +func (g *Game) characterOnline(name string) bool { + g.charsMu.Lock() + defer g.charsMu.Unlock() + return g.loggedInChars[name] != nil +} + func (g *Game) handleRenameCharName(sess *net.Session, input string) { newName := strings.TrimSpace(input) oldName := sess.PendingChar @@ -166,6 +176,12 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) { sess.Write("New name: ") return } + if g.characterOnline(oldName) { + sess.WriteLine("That character is logged in elsewhere. Log them out first.") + sess.PendingChar = "" + g.showMenu(sess) + return + } oldPath := g.AccountStore.CharPath(oldName) newPath := g.AccountStore.CharPath(newName) @@ -240,6 +256,13 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) { return } + if g.characterOnline(sess.PendingChar) { + sess.WriteLine("That character is logged in elsewhere. Log them out first.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + charPath := g.AccountStore.CharPath(sess.PendingChar) if err := os.Remove(charPath); err != nil { sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) diff --git a/internal/game/game.go b/internal/game/game.go index 23e0635..822042e 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -237,6 +237,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } parts := strings.Fields(strings.ToLower(input)) + if len(parts) == 0 { + g.reprompt(sess) + return + } cmd := parts[0] if len(parts) == 1 { @@ -369,6 +373,12 @@ func (g *Game) buildCraftIndex() { g.CraftIndex.Build(items) } +// RebuildCraftIndex refreshes the craft index after item definitions change +// on disk (wired into the admin web editor's item mutation paths). +func (g *Game) RebuildCraftIndex() { + g.buildCraftIndex() +} + type pendingDepletion struct { instanceKey string objDefID string diff --git a/internal/net/conn.go b/internal/net/conn.go index e46031b..2fff0bc 100644 --- a/internal/net/conn.go +++ b/internal/net/conn.go @@ -24,21 +24,46 @@ type tcpConn struct { func newTCPConn(conn net.Conn) *tcpConn { return &tcpConn{ conn: conn, - reader: bufio.NewReader(conn), + reader: bufio.NewReaderSize(conn, maxLineLength*4), } } +// maxLineLength bounds how much of an incoming line is accepted. Longer +// lines are drained from the socket and truncated so a client cannot grow +// the read buffer without limit. +const maxLineLength = 1024 + func (t *tcpConn) ReadMessage() (string, error) { - line, err := t.reader.ReadString('\n') - if err != nil { + line, err := t.reader.ReadSlice('\n') + if err == bufio.ErrBufferFull { + // Copy the bounded prefix out of the reusable read buffer BEFORE + // draining the rest of the oversized line; further reads overwrite the + // buffer the slice points into. + prefixLen := len(line) + if prefixLen > maxLineLength { + prefixLen = maxLineLength + } + prefix := make([]byte, prefixLen) + copy(prefix, line[:prefixLen]) + for { + _, err = t.reader.ReadSlice('\n') + if err == nil { + break + } + if err != bufio.ErrBufferFull { + return "", err + } + } + line = prefix + } else if err != nil { return "", err } - line = strings.TrimRight(line, "\r\n") - line = stripIAC(line) + text := strings.TrimRight(string(line), "\r\n") + text = stripIAC(text) if t.echoOff { t.conn.Write([]byte("\r\n")) } - return line, nil + return text, nil } func (t *tcpConn) Write(b []byte) (int, error) { diff --git a/internal/net/conn_bound_test.go b/internal/net/conn_bound_test.go new file mode 100644 index 0000000..45b0f90 --- /dev/null +++ b/internal/net/conn_bound_test.go @@ -0,0 +1,42 @@ +package net + +import ( + "bufio" + "strings" + "testing" +) + +// Regression: the oversized-line handler drains the rest of the line before +// returning. ReadSlice returns a slice into a reusable buffer, so the +// truncated prefix must be copied out before draining or it gets overwritten +// by the remainder of the client's input. +func TestOversizedLineKeepsTruncatedPrefix(t *testing.T) { + oversized := strings.Repeat("A", maxLineLength*2) + strings.Repeat("Z", maxLineLength*3) + "\n" + c := &tcpConn{reader: bufio.NewReaderSize(strings.NewReader(oversized+"look\n"), maxLineLength*4)} + + got, err := c.ReadMessage() + if err != nil { + t.Fatalf("oversized line produced an error: %v", err) + } + if got != strings.Repeat("A", maxLineLength) { + t.Fatalf("truncated prefix corrupted by buffer reuse: got %q...", got[:20]) + } + + next, err := c.ReadMessage() + if err != nil { + t.Fatalf("reader did not recover after an oversized line: %v", err) + } + if next != "look" { + t.Fatalf("subsequent line lost: got %q", next) + } +} + +func TestNormalLinesUnaffected(t *testing.T) { + c := &tcpConn{reader: bufio.NewReaderSize(strings.NewReader("look\nsay hi\r\n"), maxLineLength*4)} + for _, want := range []string{"look", "say hi"} { + got, err := c.ReadMessage() + if err != nil || got != want { + t.Fatalf("ReadMessage()=(%q,%v), want (%q,nil)", got, err, want) + } + } +} diff --git a/internal/net/server.go b/internal/net/server.go index da1c01b..9b5be18 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -135,7 +135,7 @@ func (h *Hub) Add(s *Session) { func (h *Hub) Remove(s *Session) { h.mu.Lock() - if s.Player != nil && s.State == StateGame && !s.Disconnecting { + if s.Player != nil && !s.Disconnecting { s.Disconnecting = true s.DisconnectTicks = 10 onDisconnect := h.onDisconnect diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go index d6f45dc..4990c0b 100644 --- a/internal/net/wsconn.go +++ b/internal/net/wsconn.go @@ -9,6 +9,9 @@ type wsConn struct { } func newWSConn(conn *websocket.Conn) *wsConn { + // Bound incoming message size so a client cannot push arbitrarily large + // frames into memory before the session's truncation logic runs. + conn.SetReadLimit(maxLineLength * 4) return &wsConn{conn: conn} } |
