aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 17:31:50 -0400
committerhistoria <[not public]>2026-10-05 17:31:50 -0400
commit516fd29da423432ef6dfded21a984f1d15dcab1a (patch)
treeeb2dabe325a1335dedac28a93453df643ef986f2
parente42952469dadfb3535633c66c881050ac128703c (diff)
downloadthehouseoficarus-516fd29da423432ef6dfded21a984f1d15dcab1a.tar.gz
fix: slop casino and economy fixes
-rw-r--r--FIXES.md38
-rw-r--r--internal/admin/api_entity.go4
-rw-r--r--internal/admin/api_files.go30
-rw-r--r--internal/admin/api_helpers.go7
-rw-r--r--internal/admin/api_room_courses.go25
-rw-r--r--internal/admin/correct_audit_fixes_test.go31
-rw-r--r--internal/admin/live_owner_test.go1
-rw-r--r--internal/behavior/store.go27
-rw-r--r--internal/behavior/yml_test.go29
-rw-r--r--internal/casino/correct_audit_fixes_test.go23
-rw-r--r--internal/casino/craps.go14
-rw-r--r--internal/casino/machine.go31
-rw-r--r--internal/casino/machine_test.go14
-rw-r--r--internal/casino/video_poker.go4
-rw-r--r--internal/game/casino_test.go1
-rw-r--r--internal/game/cmd_bank.go13
-rw-r--r--internal/game/cmd_get.go14
-rw-r--r--internal/game/cmd_room_remove_test.go1
-rw-r--r--internal/game/correct_audit_fixes_test.go17
-rw-r--r--internal/game/credits_pickup_test.go2
-rw-r--r--internal/game/hardening_credit_cap_test.go4
-rw-r--r--internal/game/harness_test.go1
-rw-r--r--internal/game/sys_escape_test.go1
-rw-r--r--internal/net/conn.go8
-rw-r--r--internal/net/remote_host_test.go1
-rw-r--r--internal/net/server.go29
-rw-r--r--internal/net/server_test.go1
-rw-r--r--internal/net/wsconn.go5
-rw-r--r--internal/persistence/txn.go15
-rw-r--r--internal/validate/checks.go11
-rw-r--r--internal/validate/correct_audit_fixes_test.go33
31 files changed, 366 insertions, 69 deletions
diff --git a/FIXES.md b/FIXES.md
index e5d5fc6..80dc7ae 100644
--- a/FIXES.md
+++ b/FIXES.md
@@ -57,22 +57,30 @@ Nothing was staged or committed. The audit artifacts (`AUDIT.md`, `reports/`) ar
### Tooling
31. **`make lint` gate restored** — fixed the 15 reported issues (error-string casing, doc-comment form, unused params, ineffassign) without weakening `.golangci.yml`.
-## Deliberately deferred (still open)
-
-These are documented in `AUDIT.md`; the next tranche should cover them.
-
-- **Undo failure reporting** (`Undo()`/`Redo()` still return nil on failure → "Nothing to undo"). Needs a signature change.
-- **Explicit snapshot-existence flag** to allow undoing an intentionally empty file (current fix is a safe refusal).
-- **`quit` queue/busy interaction**; **bank withdraw duplicate ambiguity list**; **`get <n> credits` ignores `<n>`**.
-- **Production `FirstCraft()` vs multi-recipe availability**; **"how many" batches vs units**.
-- **Room object reference entries silently drop behavior fields**; **`.yml` files invisible to validation**; **craft `type`/`subtype` validator rule**; **talk `start` node check**; **safespot validation**; **drop weight validation**.
-- **Persistence `Recover` mid-game misuse**, journal versioning, `.save-*` temp sweep.
-- **Casino**: craps Fire/ATS deferral, shooter-change reset, video-poker raw error text, slots free-spin off-by-one, plinko RTP wording, machine leave silently dropping a banked feature, cabinet ID collisions.
-- **Admin**: `restoreAccountSecret` fail-open, `entityCreate` persisting `_path`/`_raw`, trigger POST/PUT divergence, `validEntityID`, `resolve-duplicate`, course detach, symlink handling.
-- **Net**: telnet echo CRLF deadline, shutdown flush starvation, `armReadDeadline` concrete-type switch, hub bucket pruning.
-- **Structural refactors** from `AUDIT.md` §3–4 (Table shrink consolidation, `countExtraInbound`, cert gen, YAML encoders, `tableCore` for pai gow, typed `GridConflict.Kind`, etc.).
+## Tranche 2 (this pass)
+
+- **Security**: `restoreAccountSecret` no longer fails open on a non-mapping body (the admin raw editor can no longer blank `password_hash`); `validEntityID` rejects control characters and leading dots.
+- **Info disclosure**: video-poker bank/wager errors are mapped instead of printing raw `os`/YAML errors and server paths.
+- **Casino**: slots free-spin counter no longer prints "0 free spins remain" before the last spin; `leave` settles banked free-spin winnings instead of silently dropping them; craps repeated-odds returns the correct error; a craps shooter change resets the hand (point/made/seen) so the next shooter gets a fresh come-out.
+- **Economy**: `get <n> credits` honors the requested quantity; bank withdraw ambiguity list no longer duplicates entries.
+- **Admin**: `entityCreate` drops editor-only `_path`/`_raw`; `detachRoomFromCourse` checks `os.Remove` and detaches from every matching course.
+- **Validation**: `.yml` files are discovered/validated (matching the runtime trigger loader); talk configs with nodes but no `start` node are an error.
+- **Persistence**: `Recover` sweeps crash-created `.save-*` temp files.
+- **Net**: `Conn` now requires `SetReadDeadline`, so every transport gets an idle read deadline (removed the concrete-type switch); hub room buckets are pruned and `RekeyRooms` merges instead of clobbering.
+- Tests added: `TestRestoreAccountSecretRefusesNonMappingBody`, `TestWalkYAMLDirIncludesYml`, `TestValidateTalkConfigRequiresStartNode`, `TestPickupCreditsHonorsRequestedQuantity`, `TestMachineLeavePaysBankedFeature`, `TestCrapsShooterChangeResetsHand`.
+
+## Still open
+
+- **Undo failure reporting** (`Undo()`/`Redo()` still return nil on failure → "Nothing to undo") and an explicit snapshot-existence flag.
+- **`quit` queue/busy interaction**; **"how many" batches vs units**; **production `FirstCraft()` vs multi-recipe availability**.
+- **Room object reference entries silently drop behavior fields**; **craft `type`/`subtype` validator rule**; **safespot validation**; **drop weight validation**.
+- **Persistence** `Recover` mid-game misuse; journal versioning.
+- **Casino**: craps Fire/ATS deferral, plinko RTP wording, video-poker cabinet ID collisions.
+- **Admin**: trigger POST/PUT divergence, `resolve-duplicate` precondition, symlink handling.
+- **Net**: telnet echo CRLF deadline, shutdown flush starvation.
+- **Structural refactors** from `AUDIT.md` §3–4.
- **Test infrastructure**: `make test-race`/CI, harness owner-goroutine leak, master-tick harness option, and coverage for steal/gather/burn/talk and the WebSocket transport.
## Suggested next step
-Continue with a second tranche focused on the remaining P1/P2 correctness items and the admin/persistence/validation gaps, then a structural-refactor tranche. Each change should keep `make lint`, `go test ./...`, and `go test -race` green.
+Tranche 3: the remaining admin/persistence/casino P2s above, then the structural refactors. Keep `make lint`, `go test ./...`, and `go test -race` green.
diff --git a/internal/admin/api_entity.go b/internal/admin/api_entity.go
index 204c59e..04b9dca 100644
--- a/internal/admin/api_entity.go
+++ b/internal/admin/api_entity.go
@@ -230,6 +230,10 @@ func (s *AdminServer) entityCreate(w http.ResponseWriter, r *http.Request, c ent
c.fail(w, http.StatusBadRequest, c.invalidID())
return
}
+ // Editor-only keys never belong in the file (the update path drops them
+ // too); without this a GET->POST round trip poisons the YAML.
+ delete(m, "_path")
+ delete(m, "_raw")
if _, _, err := c.find(s, id); err == nil {
c.fail(w, http.StatusConflict, c.noun+" already exists")
return
diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go
index a19cb3b..b1b3fff 100644
--- a/internal/admin/api_files.go
+++ b/internal/admin/api_files.go
@@ -234,8 +234,13 @@ func (s *AdminServer) handleFiles(w http.ResponseWriter, r *http.Request) {
return
}
// A redacted account hash is restored from the file on disk before
- // writing, so the raw editor can never disclose or overwrite it.
- body = restoreAccountSecret(relPath, fullPath, body)
+ // writing, so the raw editor can never disclose or overwrite it. A
+ // non-mapping body is refused rather than blanking the hash.
+ body, err = restoreAccountSecret(relPath, fullPath, body)
+ if err != nil {
+ failJSON(w, err.Error())
+ return
+ }
oldContent, existed, err := snapshotBefore(fullPath)
if err != nil {
@@ -375,31 +380,34 @@ func redactAccountContent(relPath string, data []byte) []byte {
// restoreAccountSecret copies the on-disk password hash back into a submitted
// account file, so a redacted value (or any client-supplied value) can never
-// change or blank an existing hash through the raw file editor.
-func restoreAccountSecret(relPath, fullPath string, body []byte) []byte {
+// change or blank an existing hash through the raw file editor. It returns an
+// error when the existing hash cannot be preserved (for example a non-mapping
+// body), so the caller refuses the write instead of blanking the hash.
+func restoreAccountSecret(relPath, fullPath string, body []byte) ([]byte, error) {
if !isAccountFilePath(relPath) {
- return body
+ return body, nil
}
existing, err := os.ReadFile(fullPath)
if err != nil {
- return body
+ // A missing account file is a create; nothing to preserve.
+ return body, nil
}
em, err := yamlToMap(existing)
if err != nil {
- return body
+ return nil, fmt.Errorf("existing account file is not a YAML mapping: %w", err)
}
hash, ok := em["password_hash"]
if !ok {
- return body
+ return body, nil
}
bm, err := yamlToMap(body)
if err != nil {
- return body
+ return nil, fmt.Errorf("submitted account file is not a YAML mapping; refusing to blank the password hash")
}
bm["password_hash"] = hash
out, err := marshalMapYAML(bm)
if err != nil {
- return body
+ return nil, fmt.Errorf("marshal account file: %w", err)
}
- return out
+ return out, nil
}
diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go
index 37cc4ae..d5e23c3 100644
--- a/internal/admin/api_helpers.go
+++ b/internal/admin/api_helpers.go
@@ -17,12 +17,17 @@ import (
// JSON bodies, so an unchecked join could otherwise escape the data
// directory ("../") or touch dot files.
func validEntityID(id string) bool {
- if id == "" || id == "." || id == ".." {
+ if id == "" || id == "." || id == ".." || strings.HasPrefix(id, ".") {
return false
}
if strings.ContainsAny(id, `/\`) || strings.ContainsRune(id, 0) {
return false
}
+ for _, r := range id {
+ if r < 32 || r == 127 {
+ return false
+ }
+ }
return true
}
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index db74adf..099ebef 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -420,8 +420,9 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
// Detach a room from its course; delete the course file if it becomes empty
-// detachRoomFromCourse removes roomID from the course it belongs to, if any.
-// Returns the course ID that was modified, or "" if none match.
+// detachRoomFromCourse removes roomID from every course it belongs to, if any.
+// Returns the first affected course ID (or "" if none match) and whether any
+// course was deleted.
func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted bool) {
ids, err := s.listCourseIDs()
if err != nil {
@@ -448,15 +449,23 @@ func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted
remaining = append(remaining, obstacles[idx+1:]...)
if len(remaining) == 0 {
- os.Remove(path)
+ // A failed unlink must not be reported as a detach: the course
+ // would keep listing a deleted room.
+ if err := os.Remove(path); err != nil {
+ continue
+ }
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("delete course %s (last obstacle removed)", id),
FilePath: path,
OldContent: oldContent,
IsDelete: true,
})
+ if courseID == "" {
+ courseID = id
+ }
+ deleted = true
s.reloadCoursesIfWired()
- return id, true
+ continue
}
anyList := make([]any, len(remaining))
@@ -466,7 +475,7 @@ func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted
m["obstacles"] = anyList
newContent, err := writeMapAsYAML(path, m)
if err != nil {
- return "", false
+ continue
}
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("detach room %d from course %s", roomID, id),
@@ -474,10 +483,12 @@ func (s *AdminServer) detachRoomFromCourse(roomID int) (courseID string, deleted
OldContent: oldContent,
NewContent: newContent,
})
+ if courseID == "" {
+ courseID = id
+ }
s.reloadCoursesIfWired()
- return id, false
}
- return "", false
+ return courseID, deleted
}
func (s *AdminServer) handleRoomDetachCourse(w http.ResponseWriter, r *http.Request, roomID int) {
diff --git a/internal/admin/correct_audit_fixes_test.go b/internal/admin/correct_audit_fixes_test.go
index 381fb5c..60073f7 100644
--- a/internal/admin/correct_audit_fixes_test.go
+++ b/internal/admin/correct_audit_fixes_test.go
@@ -1,6 +1,11 @@
package admin
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
// Regression: the raw file API must canonicalize repeated "/" and "data/"
// prefixes, or "//players/..." reaches the real file while the classifiers
@@ -20,3 +25,27 @@ func TestCanonicalDataRelPathStripsRepeatedPrefixes(t *testing.T) {
}
}
}
+
+// Regression: a valid-but-non-mapping body used to pass through unchanged and
+// blank password_hash. It must now be refused, and a mapping body must keep
+// the on-disk hash.
+func TestRestoreAccountSecretRefusesNonMappingBody(t *testing.T) {
+ dir := t.TempDir()
+ full := filepath.Join(dir, "players", "accounts", "alice.yaml")
+ if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte("name: alice\npassword_hash: secret\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := restoreAccountSecret("players/accounts/alice.yaml", full, []byte("# comment only\n")); err == nil {
+ t.Fatal("non-mapping body accepted; the password hash would be blanked")
+ }
+ out, err := restoreAccountSecret("players/accounts/alice.yaml", full, []byte("name: alice\npassword_hash: \"[redacted]\"\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(out), "secret") {
+ t.Fatalf("password hash not preserved: %s", out)
+ }
+}
diff --git a/internal/admin/live_owner_test.go b/internal/admin/live_owner_test.go
index ae53606..de3ade8 100644
--- a/internal/admin/live_owner_test.go
+++ b/internal/admin/live_owner_test.go
@@ -178,6 +178,7 @@ func (discardConn) ReadMessage() (string, error) { return "", io.EOF }
func (discardConn) Write(p []byte) (int, error) { return len(p), nil }
func (discardConn) Close() error { return nil }
func (discardConn) SetWriteDeadline(time.Time) error { return nil }
+func (discardConn) SetReadDeadline(time.Time) error { return nil }
func (discardConn) SetEcho(bool) error { return nil }
func newEditorHarness(t *testing.T) (*editorHarness, *gamenet.Session) {
diff --git a/internal/behavior/store.go b/internal/behavior/store.go
index f2e9350..d31fe84 100644
--- a/internal/behavior/store.go
+++ b/internal/behavior/store.go
@@ -84,7 +84,11 @@ func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error
}
return err
}
- if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
+ if d.IsDir() {
+ return nil
+ }
+ id, ok := yamlID(d.Name())
+ if !ok {
return nil
}
if excludedContentPath(dir, path) {
@@ -97,11 +101,23 @@ func WalkYAMLDir(dir string, fn func(path, id string, data []byte) error) error
}
return readErr
}
- id := strings.TrimSuffix(d.Name(), ".yaml")
return fn(path, id, data)
})
}
+// yamlID returns the id (filename without extension) for a .yaml or .yml file.
+// The runtime trigger loader accepts .yml, so validation discovery must too,
+// otherwise such a file loads and fires while every check ignores it.
+func yamlID(name string) (string, bool) {
+ if strings.HasSuffix(name, ".yaml") {
+ return strings.TrimSuffix(name, ".yaml"), true
+ }
+ if strings.HasSuffix(name, ".yml") {
+ return strings.TrimSuffix(name, ".yml"), true
+ }
+ return "", false
+}
+
type Duplicate struct {
ID string
Paths []string
@@ -110,13 +126,16 @@ type Duplicate struct {
func CheckDuplicateIDs(dir string) []Duplicate {
seen := make(map[string][]string)
filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ id, ok := yamlID(d.Name())
+ if !ok {
return nil
}
if excludedContentPath(dir, path) {
return nil
}
- id := strings.TrimSuffix(d.Name(), ".yaml")
seen[id] = append(seen[id], path)
return nil
})
diff --git a/internal/behavior/yml_test.go b/internal/behavior/yml_test.go
new file mode 100644
index 0000000..9452e9e
--- /dev/null
+++ b/internal/behavior/yml_test.go
@@ -0,0 +1,29 @@
+package behavior
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// Regression: WalkYAMLDir only matched .yaml, so a .yml trigger file loaded
+// and fired at runtime while every validation pass ignored it.
+func TestWalkYAMLDirIncludesYml(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "a.yaml"), []byte("x: 1"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "b.yml"), []byte("x: 2"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ ids := map[string]bool{}
+ if err := WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ ids[id] = true
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if !ids["a"] || !ids["b"] {
+ t.Fatalf("WalkYAMLDir ids = %v, want both a and b", ids)
+ }
+}
diff --git a/internal/casino/correct_audit_fixes_test.go b/internal/casino/correct_audit_fixes_test.go
new file mode 100644
index 0000000..e5e3bd3
--- /dev/null
+++ b/internal/casino/correct_audit_fixes_test.go
@@ -0,0 +1,23 @@
+package casino
+
+import (
+ "math/rand"
+ "testing"
+)
+
+// Regression: when the shooter leaves and others remain, the next shooter must
+// start a fresh come-out rather than inheriting the previous point and
+// made/seen bonus progress (which blocked line and bonus bets).
+func TestCrapsShooterChangeResetsHand(t *testing.T) {
+ tb := newCrapsTable(1, TableConfig{ID: "craps", Game: GameCraps, BettingTicks: 2}, rand.New(rand.NewSource(7)))
+ tb.join("alice")
+ tb.join("bob")
+ tb.point = 6
+ tb.rolled = true
+ tb.made = map[int]bool{4: true}
+ tb.seen = map[int]bool{4: true}
+ tb.remove("alice")
+ if tb.point != 0 || tb.rolled || len(tb.made) != 0 || len(tb.seen) != 0 {
+ t.Fatalf("new shooter inherited hand: point=%d rolled=%v made=%v seen=%v", tb.point, tb.rolled, tb.made, tb.seen)
+ }
+}
diff --git a/internal/casino/craps.go b/internal/casino/craps.go
index 73c67c4..0abf24b 100644
--- a/internal/casino/craps.go
+++ b/internal/casino/craps.go
@@ -133,7 +133,8 @@ func (t *crapsTable) join(name string) []Event {
return append([]Event{e, t.menu(name)}, timing...)
}
func (t *crapsTable) remove(name string) {
- if len(t.queue) > 0 && t.queue[0] == name {
+ wasShooter := len(t.queue) > 0 && t.queue[0] == name
+ if wasShooter {
// The queued roll belongs to the shooter; a departing shooter cancels it.
t.pendingRollTicks = 0
}
@@ -152,6 +153,15 @@ func (t *crapsTable) remove(name string) {
t.seen = map[int]bool{}
t.phase = phaseWaiting
t.window.close()
+ } else if wasShooter {
+ // The next shooter starts a fresh come-out. Without this he inherits
+ // the previous shooter's point (blocking pass/dontpass and bonus
+ // bets) and made/seen bonus progress. The shared countdown is left
+ // alone: it belongs to the table, not the shooter.
+ t.point = 0
+ t.rolled = false
+ t.made = map[int]bool{}
+ t.seen = map[int]bool{}
}
}
func (t *crapsTable) leave(name string) []Event {
@@ -245,7 +255,7 @@ func (t *crapsTable) bet(name, spot string, amount int, w Wallet) []Event {
}
limit := t.Config.Craps.oddsLimit(b.number, against)
if b.odds.Total > 0 {
- continue
+ return reject("Odds already placed on that bet.")
}
requested := amount
amount = roundUpTo(amount, payoutDen)
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 0733384..93ab8aa 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -357,7 +357,9 @@ func (m *Machine) finishSpin() []Event {
m.phase = machineSpinning
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))...)
+ if m.freeSpins > 0 {
+ events = append(events, m.public(fmt.Sprintf("%d free spins remain.", m.freeSpins))...)
+ }
} else {
m.inFreeSpins = false
if !payFailed && m.player.Wallet != nil {
@@ -382,9 +384,34 @@ func (m *Machine) leave(name string) []Event {
if m.phase != machineReady {
return m.private("You're in the middle of a spin!")
}
+ bank := int(m.featureBank)
+ wallet := m.player.Wallet
+ stakeFailed := m.stakeFailed
m.resetSeat()
- return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name,
+ events := []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventLeft, Player: name,
Message: fmt.Sprintf("%s stands up from the slots machine.", name), PrivateMessage: "You stand up from the slot machine.", PrivateTo: name, Color: "casino_action", Public: true}}
+ // Banked free-spin winnings must not vanish when the player stands up:
+ // pay them like markDisconnected/refundAll do, or report the forfeiture.
+ if bank > 0 && wallet != nil {
+ switch {
+ case stakeFailed:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: fmt.Sprintf("Your %d credits of banked free-spin winnings were forfeited because the round could not be persisted.", bank),
+ Color: "casino_action", Public: false})
+ case wallet.PayCredits(bank) != nil:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank),
+ Color: "casino_action", Public: false})
+ default:
+ events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult,
+ PrivateTo: name, Player: name,
+ PrivateMessage: fmt.Sprintf("The house settles your %d banked free-spin winnings.", bank),
+ Color: "casino_action", Public: false})
+ }
+ }
+ return events
}
// resetSeat frees the machine, discarding any in-flight spin and autobet
diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go
index 1e8cdad..8942936 100644
--- a/internal/casino/machine_test.go
+++ b/internal/casino/machine_test.go
@@ -14,6 +14,20 @@ func TestBetRangeTextFormatsUnlimitedMax(t *testing.T) {
}
}
+// Regression: standing up while a feature was banked silently dropped the
+// credits; leave must settle them like markDisconnected does.
+func TestMachineLeavePaysBankedFeature(t *testing.T) {
+ m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
+ w := &testWallet{}
+ m.player = &Participant{Name: "alice", Connected: true, Wallet: w}
+ m.featureBank = 25
+ m.phase = machineReady
+ _ = m.leave("alice")
+ if w.paid != 25 {
+ t.Fatalf("banked feature not paid on leave: paid = %d, want 25", w.paid)
+ }
+}
+
func TestMachineSetBetUnsetDefaultMaxMessage(t *testing.T) {
m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, MinBet: 1}, rand.New(rand.NewSource(7)))
m.player = &Participant{Name: "alice", Connected: true}
diff --git a/internal/casino/video_poker.go b/internal/casino/video_poker.go
index 4cb9883..98bfda1 100644
--- a/internal/casino/video_poker.go
+++ b/internal/casino/video_poker.go
@@ -279,7 +279,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event {
if wallet, ok := w.(PokerWallet); ok {
banks, err := wallet.LoadPokerBanks(t.RoomID, t.Config.ID)
if err != nil {
- return []Event{t.private(name, "Machine bank unavailable; try again later: "+err.Error())}
+ return []Event{t.private(name, "Machine bank unavailable; try again later.")}
}
if banks != nil {
t.banks = banks
@@ -308,7 +308,7 @@ func (t *videoPokerTable) bet(name, spot string, amount int, w Wallet) []Event {
}
stake, err := w.Wager(p.cost())
if err != nil {
- return []Event{t.private(name, "The wager could not be placed: "+err.Error())}
+ return []Event{t.private(name, walletErrorMessage(err, "Not enough chips and credits for that wager."))}
}
p.wallet, p.wager, p.lastBet = w, stake, p.loaded
p.bankKey = t.multiplierKey(p)
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index 0b82945..a3c3d18 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -22,6 +22,7 @@ func (c *casinoTestConn) Write(b []byte) (int, error) {
return len(b), nil
}
func (c *casinoTestConn) SetWriteDeadline(time.Time) error { return nil }
+func (c *casinoTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *casinoTestConn) Close() error { return nil }
func (c *casinoTestConn) SetEcho(bool) error { return nil }
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index bb03869..76fefb7 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -265,10 +265,15 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) {
return
}
- for _, e := range entries {
- def, _ := g.ItemStore.Load(e.id)
- if def != nil && def.MatchesName(input) {
- matches = append(matches, e)
+ // Only fall back to fuzzy MatchesName when the prefix pass found nothing;
+ // running it unconditionally appended the same entries twice and printed a
+ // duplicated ambiguity list.
+ if len(matches) == 0 {
+ for _, e := range entries {
+ def, _ := g.ItemStore.Load(e.id)
+ if def != nil && def.MatchesName(input) {
+ matches = append(matches, e)
+ }
}
}
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index a806ffa..d4742a5 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -40,7 +40,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
itemID := matches[0].ID
if itemID == "credits" {
- g.pickupCredits(sess, p, itemID)
+ g.pickupCredits(sess, p, itemID, qty)
return
}
@@ -162,7 +162,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
itemID := matches[0].ID
if itemID == "credits" {
- g.pickupCredits(sess, p, itemID)
+ g.pickupCredits(sess, p, itemID, 0)
return
}
@@ -391,7 +391,7 @@ func (g *Game) doGetAll(sess *net.Session) {
}
}
-func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) {
+func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string, want int) {
// The balance cap is a hard limit: credits the purse cannot absorb must
// stay on the ground instead of being removed and then clamped away by
// grantCredits. Only the headroom below the cap is taken.
@@ -400,7 +400,13 @@ func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string)
sess.WriteLine(g.colorize(sess, "currency_pickup", "Your purse can't hold any more credits."))
return
}
- qty := g.World.RemoveAvailableItem(p.RoomID, itemID, room, p.Name)
+ // Honor an explicit quantity (get 5 credits) instead of always sweeping
+ // the whole purse headroom.
+ take := room
+ if want > 0 && want < take {
+ take = want
+ }
+ qty := g.World.RemoveAvailableItem(p.RoomID, itemID, take, p.Name)
if qty <= 0 {
return
}
diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go
index be2d917..dc5a397 100644
--- a/internal/game/cmd_room_remove_test.go
+++ b/internal/game/cmd_room_remove_test.go
@@ -23,6 +23,7 @@ func (c *removeTestConn) Write(b []byte) (int, error) {
return len(b), nil
}
func (c *removeTestConn) SetWriteDeadline(time.Time) error { return nil }
+func (c *removeTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *removeTestConn) Close() error { return nil }
func (c *removeTestConn) SetEcho(bool) error { return nil }
diff --git a/internal/game/correct_audit_fixes_test.go b/internal/game/correct_audit_fixes_test.go
index 90fdc53..681843c 100644
--- a/internal/game/correct_audit_fixes_test.go
+++ b/internal/game/correct_audit_fixes_test.go
@@ -6,6 +6,7 @@ import (
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
"thehouseoficarus/internal/prediction"
)
@@ -87,6 +88,22 @@ func TestGlobalCommandPreservesArgumentCase(t *testing.T) {
}
}
+// Regression: `get 5 credits` used to sweep the whole purse headroom.
+func TestPickupCreditsHonorsRequestedQuantity(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 0
+ h.g.World.AddGroundItem(p.RoomID, "credits", 10)
+ sess := &net.Session{Player: p, Conn: newChanConn()}
+ h.g.pickupCredits(sess, p, "credits", 5)
+ if p.Credits != 5 {
+ t.Errorf("credits = %d, want 5 (output: %s)", p.Credits, sess.Conn.(*chanConn).Bytes())
+ }
+ })
+}
+
// Regression: the player-file classifier must canonicalize repeated "/" and
// "data/" prefixes so a raw-editor path cannot dodge the online check.
func TestPlayerDataFileNameCanonicalizesPrefixes(t *testing.T) {
diff --git a/internal/game/credits_pickup_test.go b/internal/game/credits_pickup_test.go
index 721c550..4342ea8 100644
--- a/internal/game/credits_pickup_test.go
+++ b/internal/game/credits_pickup_test.go
@@ -19,7 +19,7 @@ func TestPickupCreditsSentinelQuantity(t *testing.T) {
p.Credits = 100
h.g.World.AddGroundItem(p.RoomID, "credits", 10)
sess := &net.Session{Player: p, Conn: newChanConn()}
- h.g.pickupCredits(sess, p, "credits")
+ h.g.pickupCredits(sess, p, "credits", 0)
if p.Credits != 110 {
t.Errorf("credits after pickup = %d, want 110 (output: %s)", p.Credits, sess.Conn.(*chanConn).Bytes())
}
diff --git a/internal/game/hardening_credit_cap_test.go b/internal/game/hardening_credit_cap_test.go
index 872c746..05b0aeb 100644
--- a/internal/game/hardening_credit_cap_test.go
+++ b/internal/game/hardening_credit_cap_test.go
@@ -23,7 +23,7 @@ func TestPickupCreditsAtCapLeavesRemainderOnGround(t *testing.T) {
sess := &net.Session{Conn: newChanConn(), Player: p}
sess.SetState(net.StateGame)
- h.g.pickupCredits(sess, p, "credits")
+ h.g.pickupCredits(sess, p, "credits", 0)
if p.Credits != casino.MaxCredits {
t.Errorf("balance = %d, want the cap %d", p.Credits, casino.MaxCredits)
@@ -44,7 +44,7 @@ func TestPickupCreditsAtCapDestroysNothing(t *testing.T) {
sess := &net.Session{Conn: newChanConn(), Player: p}
sess.SetState(net.StateGame)
- h.g.pickupCredits(sess, p, "credits")
+ h.g.pickupCredits(sess, p, "credits", 0)
if p.Credits != casino.MaxCredits {
t.Errorf("balance = %d, want the cap %d", p.Credits, casino.MaxCredits)
diff --git a/internal/game/harness_test.go b/internal/game/harness_test.go
index 1d3df10..ff5ac32 100644
--- a/internal/game/harness_test.go
+++ b/internal/game/harness_test.go
@@ -62,6 +62,7 @@ func (c *chanConn) Close() error {
}
func (c *chanConn) SetWriteDeadline(time.Time) error { return nil }
+func (c *chanConn) SetReadDeadline(time.Time) error { return nil }
func (c *chanConn) SetEcho(bool) error { return nil }
diff --git a/internal/game/sys_escape_test.go b/internal/game/sys_escape_test.go
index 61b50cd..fd4c9ab 100644
--- a/internal/game/sys_escape_test.go
+++ b/internal/game/sys_escape_test.go
@@ -24,6 +24,7 @@ func (c *escapeTestConn) Write(b []byte) (int, error) {
return len(b), nil
}
func (c *escapeTestConn) SetWriteDeadline(time.Time) error { return nil }
+func (c *escapeTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *escapeTestConn) Close() error { return nil }
func (c *escapeTestConn) SetEcho(bool) error { return nil }
diff --git a/internal/net/conn.go b/internal/net/conn.go
index ca40354..a4be83a 100644
--- a/internal/net/conn.go
+++ b/internal/net/conn.go
@@ -23,6 +23,10 @@ type Conn interface {
// instead of a hung writer goroutine. Implementations without deadline
// support return nil (deadlines are best-effort).
SetWriteDeadline(t time.Time) error
+ // SetReadDeadline is the read-side counterpart: transports implement it so
+ // a silent peer cannot pin a goroutine and a connection slot forever.
+ // Implementations without deadline support return nil.
+ SetReadDeadline(t time.Time) error
SetEcho(bool) error
}
@@ -114,6 +118,10 @@ func (t *tcpConn) SetWriteDeadline(ts time.Time) error {
return t.conn.SetWriteDeadline(ts)
}
+func (t *tcpConn) SetReadDeadline(ts time.Time) error {
+ return t.conn.SetReadDeadline(ts)
+}
+
func (t *tcpConn) Close() error {
return t.conn.Close()
}
diff --git a/internal/net/remote_host_test.go b/internal/net/remote_host_test.go
index 690e758..bfd10f5 100644
--- a/internal/net/remote_host_test.go
+++ b/internal/net/remote_host_test.go
@@ -13,6 +13,7 @@ func (c remoteAddrConn) ReadMessage() (string, error) { return "", nil }
func (c remoteAddrConn) Write(b []byte) (int, error) { return len(b), nil }
func (c remoteAddrConn) Close() error { return nil }
func (c remoteAddrConn) SetWriteDeadline(time.Time) error { return nil }
+func (c remoteAddrConn) SetReadDeadline(time.Time) error { return nil }
func (c remoteAddrConn) SetEcho(bool) error { return nil }
func (c remoteAddrConn) RemoteAddr() string { return c.addr }
diff --git a/internal/net/server.go b/internal/net/server.go
index 6367419..71e34c8 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -331,17 +331,11 @@ func sessionIdleTimeout(sess *Session) time.Duration {
}
}
-// armReadDeadline sets an idle read deadline on the session's transport when
-// it supports one. tcpConn and wsConn (both in this package) expose their
-// underlying connection, so telnet, telnet-TLS, and websocket are all covered.
+// armReadDeadline sets an idle read deadline on the session's transport. The
+// Conn interface requires SetReadDeadline, so every transport (telnet,
+// telnet-TLS, websocket) is covered and a new one cannot silently miss it.
func armReadDeadline(sess *Session, d time.Duration) {
- deadline := time.Now().Add(d)
- switch c := sess.Conn.(type) {
- case *tcpConn:
- _ = c.conn.SetReadDeadline(deadline)
- case *wsConn:
- _ = c.conn.SetReadDeadline(deadline)
- }
+ _ = sess.Conn.SetReadDeadline(time.Now().Add(d))
}
// StartWriter puts the session into queued mode: writes are enqueued and a
@@ -559,8 +553,11 @@ func (h *Hub) LeaveRoom(s *Session) {
}
func (h *Hub) leaveRoomLocked(s *Session) {
- for _, room := range h.rooms {
+ for id, room := range h.rooms {
delete(room, s)
+ if len(room) == 0 {
+ delete(h.rooms, id)
+ }
}
}
@@ -582,7 +579,15 @@ func (h *Hub) RekeyRooms(idMap map[int]int) {
}
}
for newID, set := range moved {
- h.rooms[newID] = set
+ if existing := h.rooms[newID]; existing != nil {
+ // Merge instead of clobbering a target bucket that exists but is
+ // not itself in idMap; assigning would drop its sessions.
+ for s := range set {
+ existing[s] = true
+ }
+ } else {
+ h.rooms[newID] = set
+ }
}
}
diff --git a/internal/net/server_test.go b/internal/net/server_test.go
index 6a3b376..b540704 100644
--- a/internal/net/server_test.go
+++ b/internal/net/server_test.go
@@ -47,6 +47,7 @@ type testConn struct{ data []byte }
func (c *testConn) ReadMessage() (string, error) { return "", nil }
func (c *testConn) Write(b []byte) (int, error) { c.data = append(c.data, b...); return len(b), nil }
func (c *testConn) SetWriteDeadline(time.Time) error { return nil }
+func (c *testConn) SetReadDeadline(time.Time) error { return nil }
func (c *testConn) Close() error { return nil }
func (c *testConn) SetEcho(bool) error { return nil }
diff --git a/internal/net/wsconn.go b/internal/net/wsconn.go
index 95f714c..e7763aa 100644
--- a/internal/net/wsconn.go
+++ b/internal/net/wsconn.go
@@ -41,6 +41,11 @@ func (w *wsConn) SetWriteDeadline(ts time.Time) error {
return w.conn.SetWriteDeadline(ts)
}
+// SetReadDeadline delegates to the underlying websocket connection.
+func (w *wsConn) SetReadDeadline(ts time.Time) error {
+ return w.conn.SetReadDeadline(ts)
+}
+
func (w *wsConn) Close() error {
return w.conn.Close()
}
diff --git a/internal/persistence/txn.go b/internal/persistence/txn.go
index 1049077..a12e227 100644
--- a/internal/persistence/txn.go
+++ b/internal/persistence/txn.go
@@ -406,7 +406,16 @@ func Recover(journalDir string) error {
return err
}
for _, e := range entries {
- if e.IsDir() || !hasSuffix(e.Name(), ".json") {
+ if e.IsDir() {
+ continue
+ }
+ if hasPrefix(e.Name(), ".save-") {
+ // A crash during a journal write leaves an atomic-writer temp
+ // behind; it is never a valid journal, so sweep it.
+ os.Remove(filepath.Join(journalDir, e.Name()))
+ continue
+ }
+ if !hasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(journalDir, e.Name())
@@ -445,6 +454,10 @@ func hasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
+func hasPrefix(s, prefix string) bool {
+ return len(s) >= len(prefix) && s[:len(prefix)] == prefix
+}
+
// ListJournalFiles is a debugging helper: returns sorted journal file names.
func ListJournalFiles(journalDir string) ([]string, error) {
entries, err := os.ReadDir(journalDir)
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 82f746f..d3fa111 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -1643,6 +1643,17 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
if cfg == nil {
return issues
}
+ // A talk config with nodes but no "start" node is dead content: the
+ // runtime prints "This person has nothing to say."
+ if len(cfg.Nodes) > 0 {
+ if _, ok := cfg.Nodes["start"]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: talk config has nodes but no \"start\" node", prefix),
+ })
+ }
+ }
for nodeID, node := range cfg.Nodes {
nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID)
if node.Action != nil {
diff --git a/internal/validate/correct_audit_fixes_test.go b/internal/validate/correct_audit_fixes_test.go
new file mode 100644
index 0000000..8ed6f52
--- /dev/null
+++ b/internal/validate/correct_audit_fixes_test.go
@@ -0,0 +1,33 @@
+package validate
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+)
+
+// Regression: a talk config with nodes but no "start" node is dead content at
+// runtime; validation must report it.
+func TestValidateTalkConfigRequiresStartNode(t *testing.T) {
+ cfg := &behavior.TalkConfig{Nodes: map[string]behavior.TalkNode{
+ "greet": {Messages: []string{"hi"}},
+ }}
+ issues := validateTalkConfig("test", cfg, nil, nil, nil, nil)
+ found := false
+ for _, iss := range issues {
+ if strings.Contains(iss.Message, "start") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("missing start node not reported: %+v", issues)
+ }
+
+ clean := &behavior.TalkConfig{Nodes: map[string]behavior.TalkNode{"start": {}}}
+ for _, iss := range validateTalkConfig("test", clean, nil, nil, nil, nil) {
+ if strings.Contains(iss.Message, "start") {
+ t.Fatalf("start node wrongly reported: %+v", iss)
+ }
+ }
+}