aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 13:56:10 -0400
committerhistoria <[not public]>2026-09-15 13:56:48 -0400
commitb3568f03e4103dd46db2ccc680fbab0a6454d604 (patch)
treedb3f6bb4f3d7c1deefbcd50de0f5022756d6b9ae /internal
parentf4ea40c4b3589104dfaced3481ba19c5735889ae (diff)
downloadthehouseoficarus-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.go11
-rw-r--r--internal/admin/api_items_undo_test.go82
-rw-r--r--internal/admin/api_players.go41
-rw-r--r--internal/admin/server.go44
-rw-r--r--internal/casino/blackjack.go9
-rw-r--r--internal/casino/blackjack_timer_test.go50
-rw-r--r--internal/casino/machine.go16
-rw-r--r--internal/casino/machine_test.go31
-rw-r--r--internal/game/bank_transport_regression_test.go114
-rw-r--r--internal/game/casino.go10
-rw-r--r--internal/game/cmd_bank.go76
-rw-r--r--internal/game/cmd_quit.go11
-rw-r--r--internal/game/cmd_trigger.go8
-rw-r--r--internal/game/cmd_trigger_transport.go14
-rw-r--r--internal/game/core_login_account.go29
-rw-r--r--internal/game/core_login_char.go23
-rw-r--r--internal/game/game.go10
-rw-r--r--internal/net/conn.go37
-rw-r--r--internal/net/conn_bound_test.go42
-rw-r--r--internal/net/server.go2
-rw-r--r--internal/net/wsconn.go3
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}
}