aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--building_guide/foundation_review.md209
-rw-r--r--building_guide/rooms.md23
-rw-r--r--internal/admin/api_rooms.go9
-rw-r--r--internal/admin/casino_validate.go33
-rw-r--r--internal/admin/casino_validate_test.go52
-rw-r--r--internal/casino/config_validate.go119
-rw-r--r--internal/casino/config_validate_test.go124
-rw-r--r--internal/casino/machine.go12
-rw-r--r--internal/casino/machine_ownership_test.go24
-rw-r--r--internal/casino/manager.go22
-rw-r--r--internal/casino/slots.go7
-rw-r--r--internal/engine/lifecycle_test.go40
-rw-r--r--internal/engine/tick.go16
-rw-r--r--internal/game/casino.go14
-rw-r--r--internal/persistence/atomic.go53
-rw-r--r--internal/persistence/atomic_test.go75
-rw-r--r--internal/player/store.go39
-rw-r--r--internal/player/store_test.go81
-rw-r--r--internal/validate/casino_test.go29
-rw-r--r--internal/validate/checks.go7
21 files changed, 955 insertions, 41 deletions
diff --git a/README.md b/README.md
index 9856800..d90d552 100644
--- a/README.md
+++ b/README.md
@@ -61,16 +61,16 @@ Telnet to the port configured in `config.yaml` or bring up the web client.
# Server Options (config.yaml)
-The `thoi` binary checks for a file named config.yaml in $XDG_CONFIG_HOME/thoi or the local directory. It creates one if it's not found.
+The `thoi` binary checks for a file named config.yaml in `$XDG_CONFIG_HOME/thoi` or the local directory. It creates one if it's not found.
The game is designed around 600ms ticks, but you can change the `tick_length` here. You can also set up telnet, SSL telnet, HTTP, and HTTPS on different ports here. For the secure options can provide a private key and certificate. Use something like [certbot](https://certbot.eff.org/). If you enable HTTPS/Telnet TLS without providing certs, a self-signed cert will be generated and used for you.
# AI Disclosure
-The THOI **game engine and tools** are ~~completely vibe coded~~ ~~agentically engineered~~ written with "LLM assistance".
+The THOI game engine and tools are written with LLM tools.
-**LLMs do not generate any creative content** (e.g. story, characters, rooms, quests). LLMs do generate *example* content for dev testing, so you might see that somewhere, but not in the final game. I personally prefer mediocre solo dev writing to AI filler.
+LLMs do not generate any creative content (e.g. story, characters, rooms, descriptions, quests) besides dev-test examples. I personally prefer mediocre solo dev writing to AI filler.
# License
-0BSD. Do whatever you want. Have fun.
+0BSD
diff --git a/building_guide/foundation_review.md b/building_guide/foundation_review.md
new file mode 100644
index 0000000..b0afb88
--- /dev/null
+++ b/building_guide/foundation_review.md
@@ -0,0 +1,209 @@
+# Foundation review
+
+## Verdict
+
+The package boundaries are useful foundations: casino game logic is separate
+from game presentation, multiplayer games share a small `tableGame` interface,
+and tick system order has an explicit testable contract. Keep those boundaries;
+a wholesale rewrite or a larger plugin framework is not needed yet.
+
+However, this is **not yet a safe foundation for expanding persistent casino
+activity or live world editing**. Ownership and transaction boundaries matter
+more than further extraction of command files.
+
+This review inspected session/tick entry points, account lifecycle and storage,
+casino configuration/runtime/wallet integration, shutdown, and room rename/undo.
+It is not a full audit of every combat, crafting, networking, or admin endpoint.
+
+## Fixed in this pass
+
+- **Account/character single-file saves:** replace direct truncating writes with
+ a same-directory temporary file, file sync, atomic rename, and directory sync
+ (`internal/persistence/atomic.go`, `internal/player/store.go`). Existing modes
+ are preserved; new player/account files use 0600. Account/character discovery
+ ignores non-YAML files, including leftover temporary files. This does not make
+ multi-file operations or concurrent read-modify-write cycles transactional.
+- **Save-time live mutation:** god-mode serialization uses a value snapshot
+ instead of temporarily changing the live player's skills and HP. Maps still
+ require caller ownership during serialization; this is not a deep snapshot.
+- **Casino RNG ownership:** the manager creates independent random streams for
+ each game under its own lock. Previously, different table/machine locks
+ protected the same `rand.Rand`, permitting concurrent access to its state.
+- **Slot profile isolation:** `ScaleToRTP` copies the symbols/paytables it changes,
+ so scaling a reused profile no longer retunes other machines or its source.
+- **Stale machine commands:** betting, spinning, or enabling autobet after a
+ disconnect rejects the requesting player without dereferencing a missing
+ occupant or messaging a replacement occupant.
+- **Casino configuration:** validate raw values before defaulting; reject
+ invalid game/variant/ID/range/finite-value configurations; detect room-local
+ duplicate IDs. The room create/update API checks before writes, startup
+ validation reports errors, and runtime lookup rejects invalid definitions.
+ Legacy slots table entries remain supported. See `rooms.md` for bounds.
+- **Tick lifecycle:** repeated `Stop` calls no longer close a closed channel;
+ ticker goroutines retain their own run's resources across quick restarts;
+ concurrent tick executions are serialized. `Stop` still does not wait for an
+ in-flight callback and is explicitly not a final-save barrier.
+
+## Remaining work, in recommended order
+
+### 1. High: make live game/session state single-owner
+
+Evidence: `internal/net/server.go` invokes `Game.HandleSession` from each
+connection goroutine; `internal/engine/tick.go` invokes game callbacks from the
+ticker goroutine. Disconnect callbacks also mutate players/game maps, while
+admin callbacks such as `Game.RewriteRoomIDs` mutate online players directly.
+
+`Hub.AllSessions` copies a list of pointers, not immutable session snapshots.
+Store/map locks similarly do not grant ownership over all pointed-to state.
+The casino wallet mutates player inventory and credits under a casino lock,
+which does not exclude other player commands or saves. Serializing tick
+callbacks alone does not solve these cross-entry-point races.
+
+Recommended design:
+
+1. One owner loop executes session input, connect/disconnect transitions, all
+ tick callbacks (including dynamically registered ones), and admin mutations.
+2. Network/admin goroutines submit bounded requests; they do not mutate live
+ player/session/world objects. Admin reads consume snapshots.
+3. Preserve the documented tick order. Do not wrap only `RunTickSystems` and
+ assume dynamic callbacks or instant commands are covered.
+4. Add bounded outbound queues/write deadlines before concentrating execution
+ in one owner: current session writes perform synchronous network I/O, so one
+ slow client could otherwise stall the entire world.
+5. Put saves behind an explicit owned snapshot boundary. Avoid holding Hub
+ membership locks across filesystem work; `hardRemoveLocked` currently calls
+ the removal callback while holding the Hub mutex.
+
+Acceptance: concurrent login/input/disconnect/admin-mutation integration tests
+under `-race`, plus a slow-client test proving other players keep progressing.
+
+### 2. High: transactional account lifecycle and authoritative membership
+
+The two outstanding account issues are still present:
+
+- `characterOnline` releases `charsMu` before rename/delete/purge touches files;
+ `connectCharacter` can load/register the character during that gap.
+- Account rename changes only the initiating `sess.Account.Name`. Other
+ authenticated sessions retain old account objects. Character lists and
+ pending menu selections can also become stale after rename/delete/create.
+
+This is not just a cosmetic broadcast issue. A stale character name can be
+reused by another account; cached membership must not authorize a later
+connect/rename/delete against that reassigned name.
+
+Use the owner/lifecycle boundary for lookup, canonical-name resolution,
+current membership validation, online check, mutation, and session publication.
+Refresh or invalidate all affected authenticated sessions, including pending
+menu selections. Do not simply broadcast pointer mutations from a connection
+ goroutine. Revalidate membership at operation commit, not only menu selection.
+
+Account creation, character creation, character rename/delete, and purge also
+span multiple files and contain ignored save/remove errors in
+`core_login_account.go` and `core_login_char.go`. Single-file atomic replacement
+cannot prevent orphaned characters, stale ownership lists, or partial purges.
+Choose a journaled lifecycle transaction or transactional player database.
+Stable account/character IDs would decouple identity from renameable filenames.
+
+Acceptance: simultaneous same-account operations, stale menu selections, and
+injected failure at each filesystem step must preserve identity/membership.
+
+### 3. High: durable money and graceful shutdown
+
+Evidence:
+
+- `internal/game/casino.go`: `casinoWallet.Wager` and `PayCredits` mutate balances
+ and ignore `SaveCharacter` errors. A successful wager can therefore exist only
+ in memory, and a reported win can be lost on restart.
+- `internal/casino/types.go`: `Wallet.PayCredits` cannot report failure.
+- Round/wager/free-spin state exists only in memory. A crash after a saved wager
+ debit but before settlement loses the player's stake without recovery.
+- Currency uses machine-sized `int`, unchecked additions/multiplications, and
+ floating-point payout conversion. Unlimited bets remain supported. Large
+ balances or payout multipliers can overflow; finite config validation is not
+ sufficient protection.
+- `cmd/thoi/main.go` exits on signals after requesting tick stop;
+ `internal/game/cmd_shutdown.go` calls `os.Exit` directly. Neither path drains
+ work or saves all online characters. Tick stop does not join running callbacks.
+ The countdown also reads a replaceable `g.shutdownCancel` field from its
+ goroutine; cancellation/restart needs a captured per-run channel.
+
+Recommended design:
+
+- Give each round/wager/settlement a stable transaction ID; debit plus wager
+ record, and payout plus settlement record, must be durable and idempotent.
+ A small transactional player/economy database is worth considering while
+ retaining YAML for authored world content.
+- Define checked currency arithmetic and explicit rounding/balance limits.
+ Distinguish insufficient funds from persistence failure. A failed write must
+ not silently count as successful gameplay.
+- Unify signals and the shutdown command into one shutdown request handled by
+ main. Stop admissions/admin mutations; quiesce input and autobet; drain the
+ owner and callbacks; resolve or persist outstanding wagers; save owned state;
+ then close transports and return. Surface save failures to the operator.
+- Decide server-shutdown/crash policy separately from deliberate player
+ disconnect forfeiture. Do not blindly run disconnect-forfeit callbacks for a
+ server-initiated shutdown.
+
+Acceptance: fail saves during wager/payout; restart at each debit/settlement
+boundary; test overflow boundaries; SIGTERM and scheduled shutdown must produce
+identical recoverable state, without duplicate credits or lost settlements.
+
+### 4. High for live building: complete room-ID migration and undo
+
+`handleRoomRename` in `internal/admin/api_rooms.go` rewrites referencing rooms
+and invokes player migration, but records only the renamed room in `ChangeDesc`.
+Its `NewContent` is the original bytes, not necessarily the rewritten self-loop
+content. Undo/redo therefore cannot restore all references or player state.
+The reference loop also ignores some load/write failures.
+
+`Game.RewriteRoomIDs` changes player fields but does not remap Hub room buckets
+or casino manager room keys. Live room-ID changes can therefore leave gameplay
+and broadcasts attached to old locations. Other runtime room-keyed state must
+be inventoried before declaring migration complete.
+
+`UndoStack` also ignores extra-file operation errors, so it can report success
+with only part of a multi-file change restored. Restoring file snapshots alone
+cannot update live players or runtime registries.
+
+Implement one mutation plan containing all file before/after images and explicit
+runtime mappings, executed under the owner boundary. Undo/redo must use that
+same transaction and propagate failures. A naive inverse ID map is not enough
+when the destination had pre-existing stale references. Avoid live renumbering
+until this is complete; back up player and world data together.
+
+Acceptance: rename/self-loop/external exits/triggers/offline and online player
+state, Hub membership, active casino games, undo, redo, and injected partial
+write failures all agree after execution and restart.
+
+### 5. Medium: explicit casino reconfiguration and profile contracts
+
+Manager `EnsureTable`/`EnsureMachine` returns the existing object for a room/ID.
+Changing valid room config does not replace its live rules. Deleted/renumbered
+rooms can leave cached games behind. Define whether changes take effect only
+when idle, after draining rounds, or on restart; never replace an active game
+and silently discard its wagers.
+
+Before custom slot profiles are exposed, validate their geometry and weights.
+The current implementation has a fixed 3x5 grid despite configurable profile
+fields. Public profile math/validation is not a general slot engine. Fractional
+slot credits are discarded on leaving a seat, and crash recovery does not
+preserve them. Keep these limits explicit rather than promising arbitrary
+profiles or exact realized RTP for short sessions.
+
+## Verification and limits
+
+Added regression coverage for complete-file visibility, failed-rename cleanup,
+permissions, read-only god-mode saves, file discovery, config rejection and API
+non-mutation, independent concurrent casino activity, profile isolation, stale
+machine commands, and repeated/concurrent tick lifecycle operations.
+
+Commands run successfully after the fixes:
+
+- `go test ./...`
+- `go test -race ./...`
+- `go vet ./...`
+
+The original suite also passed race mode before this work. Passing it is not
+proof of safe live state ownership: it did not exercise the competing runtime
+entry points described above. No production login/load test, process-kill
+recovery test, or filesystem power-loss simulation was performed.
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
index 4da4284..4eaa843 100644
--- a/building_guide/rooms.md
+++ b/building_guide/rooms.md
@@ -322,6 +322,29 @@ casino_machines:
payout: 0.97
```
+#### Casino configuration validation
+
+Room create/update requests validate casino definitions before writing; startup
+validation reports invalid definitions, and game lookup refuses invalid room
+casino configurations. Raw file editing is not a validated save endpoint.
+
+- IDs must be nonempty, have no surrounding whitespace, and be unique across
+ the room's tables and machines (including legacy slots table entries).
+- Supported table games are `blackjack` and `baccarat`; machines use `slots`
+ with the optional variant `huff_and_even_more_puff`.
+- Omitted/zero numeric values keep their existing defaults. Negative bets,
+ timers, seat counts, split counts, decks, and payouts are rejected.
+- An explicit `max_bet` must be at least `min_bet`; zero still means no cap.
+- `decks` is capped at 64 to bound shoe allocation. An explicit `shuffle_at`
+ must be greater than zero and less than one.
+- Payouts and shuffle fractions must be finite (no NaN/infinity). Slots `payout`
+ is an RTP fraction greater than zero and at most one; zero defaults to 0.97.
+- Unknown casino fields are rejected by the room create/update API.
+
+Existing instantiated games retain their rules until restart; editing a room
+does not hot-reconfigure a live table or machine. Use explicit, modest wager
+limits: validation does not yet make currency arithmetic overflow-safe.
+
### Objects — interactive fixtures
Simple placement:
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 84c3348..2c251bb 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -45,6 +45,11 @@ func (s *AdminServer) handleRooms(w http.ResponseWriter, r *http.Request) {
m = map[string]any{}
}
+ if err := validateRoomCasinoMap(m); err != nil {
+ writeJSONError(w, "invalid casino configuration: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
var linkFrom *int
var linkDir *string
var linkOneWay bool
@@ -272,6 +277,10 @@ func (s *AdminServer) handleRoomByID(w http.ResponseWriter, r *http.Request) {
m = map[string]any{}
}
m["id"] = id
+ if err := validateRoomCasinoMap(m); err != nil {
+ writeJSONError(w, "invalid casino configuration: "+err.Error(), http.StatusBadRequest)
+ return
+ }
newContent, writeErr := writeMapAsYAML(path, m)
if writeErr != nil {
diff --git a/internal/admin/casino_validate.go b/internal/admin/casino_validate.go
new file mode 100644
index 0000000..5b6902c
--- /dev/null
+++ b/internal/admin/casino_validate.go
@@ -0,0 +1,33 @@
+package admin
+
+import (
+ "bytes"
+
+ "gopkg.in/yaml.v3"
+ "thehouseoficarus/internal/casino"
+)
+
+// Validate before any room/link writes. Only casino fields are decoded here;
+// the room editor's unrelated fields retain their existing representation.
+func validateRoomCasinoMap(m map[string]any) error {
+ fields := map[string]any{}
+ for _, key := range []string{"casino_tables", "casino_machines"} {
+ if value, ok := m[key]; ok {
+ fields[key] = value
+ }
+ }
+ data, err := yaml.Marshal(fields)
+ if err != nil {
+ return err
+ }
+ var cfg struct {
+ Tables []casino.TableConfig `yaml:"casino_tables"`
+ Machines []casino.MachineConfig `yaml:"casino_machines"`
+ }
+ decoder := yaml.NewDecoder(bytes.NewReader(data))
+ decoder.KnownFields(true)
+ if err := decoder.Decode(&cfg); err != nil {
+ return err
+ }
+ return casino.ValidateRoomConfigs(cfg.Tables, cfg.Machines)
+}
diff --git a/internal/admin/casino_validate_test.go b/internal/admin/casino_validate_test.go
new file mode 100644
index 0000000..a0f4451
--- /dev/null
+++ b/internal/admin/casino_validate_test.go
@@ -0,0 +1,52 @@
+package admin
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/world"
+)
+
+func TestRoomUpdateRejectsInvalidCasinoBeforeWriting(t *testing.T) {
+ dir := t.TempDir()
+ rooms := filepath.Join(dir, "rooms")
+ if err := os.MkdirAll(rooms, 0755); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(rooms, "1.yaml")
+ original := "name: Original\n"
+ if err := os.WriteFile(path, []byte(original), 0644); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{dataDir: dir, world: world.New(dir), undoStack: NewUndoStack(dir)}
+ for _, cfg := range []string{
+ `{"id":"slots","game":"slots","payout":-1}`,
+ `{"id":"slots","game":"slots","spin_tickz":2}`,
+ } {
+ r := httptest.NewRequest(http.MethodPut, "/api/rooms/1", strings.NewReader(`{"casino_machines":[`+cfg+`]}`))
+ w := httptest.NewRecorder()
+ s.handleRoomByID(w, r)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ data, err := os.ReadFile(path)
+ if err != nil || string(data) != original || s.undoStack.Info().CanUndo {
+ t.Fatal("invalid update changed room or undo history")
+ }
+ }
+}
+
+func TestRoomCreateValidatesBeforeAllocatingOrLinking(t *testing.T) {
+ // No world/config needed: rejection must precede all mutation work.
+ s := &AdminServer{}
+ r := httptest.NewRequest(http.MethodPost, "/api/rooms", strings.NewReader(`{"casino_tables":[{"id":"cards","game":"typo"}],"link_from":1,"link_dir":"n"}`))
+ w := httptest.NewRecorder()
+ s.handleRooms(w, r)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/casino/config_validate.go b/internal/casino/config_validate.go
new file mode 100644
index 0000000..13229bc
--- /dev/null
+++ b/internal/casino/config_validate.go
@@ -0,0 +1,119 @@
+package casino
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+)
+
+// Validate checks raw configuration before Normalize supplies omitted (zero)
+// defaults. Invalid explicit values must not silently become house rules.
+func (c MachineConfig) Validate() error {
+ if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil {
+ return err
+ }
+ if c.Game != GameSlots {
+ return fmt.Errorf("unsupported machine game %q", c.Game)
+ }
+ if c.Variant != "" && c.Variant != "huff_and_even_more_puff" {
+ return fmt.Errorf("unsupported slots variant %q", c.Variant)
+ }
+ if c.SpinTicks < 0 {
+ return fmt.Errorf("spin_ticks must not be negative")
+ }
+ if !finiteNonnegative(c.Payout) || c.Payout > 1 {
+ return fmt.Errorf("payout must be finite and between 0 and 1 (0 uses the default)")
+ }
+ return nil
+}
+
+func (c TableConfig) Validate() error {
+ if err := validateIdentityAndBets(c.ID, c.MinBet, c.MaxBet); err != nil {
+ return err
+ }
+ if c.MaxPlayers < 0 || c.BettingTicks < 0 {
+ return fmt.Errorf("max_players and betting_ticks must not be negative")
+ }
+ switch c.Game {
+ case GameBlackjack:
+ b := c.Blackjack
+ if err := validateShoe(b.Decks, b.ShuffleAt); err != nil {
+ return err
+ }
+ if b.MaxSplitHands < 0 || b.ActionTicks < 0 {
+ return fmt.Errorf("max_split_hands and action_ticks must not be negative")
+ }
+ if !finiteNonnegative(b.BlackjackPayout) || !finiteNonnegative(b.InsurancePayout) {
+ return fmt.Errorf("blackjack_payout and insurance_payout must be finite and nonnegative")
+ }
+ case GameBaccarat:
+ b := c.Baccarat
+ if err := validateShoe(b.Decks, b.ShuffleAt); err != nil {
+ return err
+ }
+ if !finiteNonnegative(b.TiePayout) || !finiteNonnegative(b.BankerPayout) {
+ return fmt.Errorf("tie_payout and banker_payout must be finite and nonnegative")
+ }
+ default:
+ return fmt.Errorf("unsupported table game %q", c.Game)
+ }
+ return nil
+}
+
+func validateIdentityAndBets(id string, minBet, maxBet int) error {
+ if id == "" || strings.TrimSpace(id) != id {
+ return fmt.Errorf("id must be nonempty with no surrounding whitespace")
+ }
+ if minBet < 0 || maxBet < 0 {
+ return fmt.Errorf("min_bet and max_bet must not be negative")
+ }
+ if maxBet > 0 && maxBet < max(1, minBet) {
+ return fmt.Errorf("max_bet must be at least min_bet")
+ }
+ return nil
+}
+
+func finiteNonnegative(n float64) bool {
+ return !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 0
+}
+
+func validateShoe(decks int, shuffleAt float64) error {
+ // A generous resource limit prevents malformed content from allocating an
+ // unbounded shoe. Zero retains the existing eight-deck default.
+ if decks < 0 || decks > 64 {
+ return fmt.Errorf("decks must be between 0 and 64 (0 uses the default)")
+ }
+ if !finiteNonnegative(shuffleAt) || shuffleAt >= 1 {
+ return fmt.Errorf("shuffle_at must be finite and in [0, 1)")
+ }
+ return nil
+}
+
+// ValidateRoomConfigs checks the shared room-local ID namespace as well as
+// individual definitions. Legacy slots under casino_tables remain supported.
+func ValidateRoomConfigs(tables []TableConfig, machines []MachineConfig) error {
+ seen := make(map[string]bool)
+ var errs []error
+ check := func(kind, id string, err error) {
+ if seen[id] {
+ errs = append(errs, fmt.Errorf("duplicate casino id %q", id))
+ }
+ seen[id] = true
+ if err != nil {
+ errs = append(errs, fmt.Errorf("%s %q: %w", kind, id, err))
+ }
+ }
+ for _, c := range tables {
+ if c.Game == GameSlots {
+ legacy := MachineConfig{ID: c.ID, Game: GameSlots, MinBet: c.MinBet, MaxBet: c.MaxBet}
+ check("legacy slots", c.ID, legacy.Validate())
+ } else {
+ check("table", c.ID, c.Validate())
+ }
+ }
+ for _, c := range machines {
+ check("machine", c.ID, c.Validate())
+ }
+ return errors.Join(errs...)
+}
diff --git a/internal/casino/config_validate_test.go b/internal/casino/config_validate_test.go
new file mode 100644
index 0000000..f0f87f5
--- /dev/null
+++ b/internal/casino/config_validate_test.go
@@ -0,0 +1,124 @@
+package casino
+
+import (
+ "fmt"
+ "math"
+ "reflect"
+ "sync"
+ "testing"
+)
+
+func TestMachineConfigValidation(t *testing.T) {
+ valid := MachineConfig{ID: "slots", Game: GameSlots}
+ if err := valid.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ for name, change := range map[string]func(*MachineConfig){
+ "missing ID": func(c *MachineConfig) { c.ID = "" },
+ "ID whitespace": func(c *MachineConfig) { c.ID = " slots " },
+ "unknown game": func(c *MachineConfig) { c.Game = "roulette" },
+ "unknown variant": func(c *MachineConfig) { c.Variant = "typo" },
+ "negative bet": func(c *MachineConfig) { c.MinBet = -1 },
+ "negative max": func(c *MachineConfig) { c.MaxBet = -1 },
+ "inverted bets": func(c *MachineConfig) { c.MinBet, c.MaxBet = 10, 5 },
+ "negative timer": func(c *MachineConfig) { c.SpinTicks = -1 },
+ "negative payout": func(c *MachineConfig) { c.Payout = -1 },
+ "excess payout": func(c *MachineConfig) { c.Payout = 1.01 },
+ "NaN payout": func(c *MachineConfig) { c.Payout = math.NaN() },
+ "infinite payout": func(c *MachineConfig) { c.Payout = math.Inf(1) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ c := valid
+ change(&c)
+ if err := c.Validate(); err == nil {
+ t.Fatalf("accepted invalid config: %+v", c)
+ }
+ m := NewManager(1)
+ if m.EnsureMachine(1, c) != nil {
+ t.Fatal("invalid machine instantiated")
+ }
+ if events := m.JoinMachine(1, c, "alice"); len(events) != 1 || !contains(events[0].Message, "not available") {
+ t.Fatalf("invalid machine join: %+v", events)
+ }
+ })
+ }
+}
+
+func TestTableConfigValidation(t *testing.T) {
+ for _, game := range []GameName{GameBlackjack, GameBaccarat} {
+ if err := (TableConfig{ID: "table", Game: game}).Validate(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for name, c := range map[string]TableConfig{
+ "unknown game": {ID: "table", Game: "roulette"},
+ "missing ID": {Game: GameBlackjack},
+ "negative timer": {ID: "table", Game: GameBlackjack, BettingTicks: -1},
+ "negative seats": {ID: "table", Game: GameBlackjack, MaxPlayers: -1},
+ "inverted bets": {ID: "table", Game: GameBlackjack, MinBet: 10, MaxBet: 5},
+ "negative decks": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{Decks: -1}},
+ "huge shoe": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{Decks: 1000000000}},
+ "NaN shuffle": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{ShuffleAt: math.NaN()}},
+ "shuffle endpoint": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{ShuffleAt: 1}},
+ "infinite payout": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{BlackjackPayout: math.Inf(1)}},
+ "negative insurance": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{InsurancePayout: -1}},
+ "NaN tie": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{TiePayout: math.NaN()}},
+ "negative banker": {ID: "table", Game: GameBaccarat, Baccarat: BaccaratConfig{BankerPayout: -1}},
+ "negative splits": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{MaxSplitHands: -1}},
+ "negative action timer": {ID: "table", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: -1}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if c.Validate() == nil || NewManager(1).EnsureTable(1, c) != nil {
+ t.Fatalf("accepted invalid table: %+v", c)
+ }
+ })
+ }
+}
+
+func TestRoomCasinoIDsAndLegacySlots(t *testing.T) {
+ tables := []TableConfig{{ID: "slots", Game: GameSlots}}
+ machines := []MachineConfig{{ID: "slots", Game: GameSlots}}
+ if err := ValidateRoomConfigs(tables, nil); err != nil {
+ t.Fatalf("legacy slots rejected: %v", err)
+ }
+ if ValidateRoomConfigs(tables, machines) == nil || ValidateRoomConfigs(nil, append(machines, machines...)) == nil {
+ t.Fatal("duplicate casino IDs accepted")
+ }
+}
+
+func TestManagerGamesHaveIndependentRandomStreams(t *testing.T) {
+ m := NewManager(123)
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ // Creation/shuffling can overlap spins on already-created games.
+ id := fmt.Sprintf("slots%d", i)
+ machine := m.EnsureMachine(1, MachineConfig{ID: id, Game: GameSlots})
+ m.EnsureTable(1, TableConfig{ID: fmt.Sprintf("cards%d", i), Game: GameBlackjack})
+ machine.join(id)
+ wallet := &testWallet{credits: 100}
+ for j := 0; j < 50; j++ {
+ machine.setBet(id, 1, wallet)
+ for machine.isBusy(id) {
+ machine.tick()
+ }
+ }
+ }(i)
+ }
+ wg.Wait()
+}
+
+func TestScaleToRTPDoesNotChangeSourceProfile(t *testing.T) {
+ source := DefaultHuffAndPuffProfile()
+ want := DefaultHuffAndPuffProfile()
+ first := ScaleToRTP(source, .9)
+ second := ScaleToRTP(source, .97)
+ if !reflect.DeepEqual(source, want) {
+ t.Fatal("scaling mutated the source profile")
+ }
+ if math.Abs(CalculateRTP(first)-.9) > 1e-10 || math.Abs(CalculateRTP(second)-.97) > 1e-10 {
+ t.Fatal("profiles share mutable paytables")
+ }
+}
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 15c612d..64dcfd4 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -63,7 +63,7 @@ func (m *Machine) setBet(name string, amount int, wallet Wallet) []Event {
m.mu.Lock()
defer m.mu.Unlock()
if !m.owns(name) {
- return m.private("You aren't playing at a slot machine.")
+ return m.privateTo(name, "You aren't playing at a slot machine.")
}
if m.autoBet {
return m.private("Autobet is active. Type \"stop\" to stop playing.")
@@ -83,7 +83,7 @@ func (m *Machine) start(name string, wallet Wallet) []Event {
m.mu.Lock()
defer m.mu.Unlock()
if !m.owns(name) {
- return m.private("You aren't playing at a slot machine.")
+ return m.privateTo(name, "You aren't playing at a slot machine.")
}
if m.autoBet {
return m.private("Autobet is active. Type \"stop\" to stop playing.")
@@ -116,7 +116,7 @@ func (m *Machine) enableAutoBet(name string, wallet Wallet) []Event {
m.mu.Lock()
defer m.mu.Unlock()
if !m.owns(name) {
- return m.private("You aren't playing at a slot machine.")
+ return m.privateTo(name, "You aren't playing at a slot machine.")
}
if m.autoBet {
return m.private("Autobet is already active.")
@@ -319,7 +319,11 @@ func (m *Machine) isBusy(name string) bool {
func (m *Machine) owns(name string) bool { return m.player != nil && m.player.Name == name }
func (m *Machine) private(message string) []Event {
- return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: m.player.Name, Message: message, Color: "casino_action"}}
+ return m.privateTo(m.player.Name, message)
+}
+
+func (m *Machine) privateTo(name, message string) []Event {
+ return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Message: message, Color: "casino_action"}}
}
func (m *Machine) public(message string) []Event {
diff --git a/internal/casino/machine_ownership_test.go b/internal/casino/machine_ownership_test.go
new file mode 100644
index 0000000..47e068a
--- /dev/null
+++ b/internal/casino/machine_ownership_test.go
@@ -0,0 +1,24 @@
+package casino
+
+import "testing"
+
+func TestMachineRejectsStaleAndNonOwnerCommands(t *testing.T) {
+ m := NewManager(1)
+ cfg := MachineConfig{ID: "slots", Game: GameSlots}
+ m.JoinMachine(1, cfg, "alice")
+ m.MarkDisconnected("alice")
+ for _, occupied := range []bool{false, true} {
+ if occupied {
+ m.JoinMachine(1, cfg, "bob")
+ }
+ for _, events := range [][]Event{
+ m.SetMachineBet(1, "slots", "alice", 10, nil),
+ m.StartMachine(1, "slots", "alice", nil),
+ m.EnableMachineAutoBet(1, "slots", "alice", nil),
+ } {
+ if len(events) != 1 || events[0].PrivateTo != "alice" || events[0].Public {
+ t.Fatalf("rejection misrouted (occupied=%v): %+v", occupied, events)
+ }
+ }
+ }
+}
diff --git a/internal/casino/manager.go b/internal/casino/manager.go
index a9bab92..38b754f 100644
--- a/internal/casino/manager.go
+++ b/internal/casino/manager.go
@@ -26,7 +26,11 @@ func tableKey(roomID int, tableID string) string {
return fmt.Sprintf("%d:%s", roomID, tableID)
}
+// EnsureMachine returns nil for an invalid or unsupported configuration.
func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
+ if cfg.Validate() != nil {
+ return nil
+ }
cfg = cfg.Normalize()
key := tableKey(roomID, cfg.ID)
m.mu.Lock()
@@ -34,14 +38,18 @@ func (m *Manager) EnsureMachine(roomID int, cfg MachineConfig) *Machine {
if machine := m.machines[key]; machine != nil {
return machine
}
- machine := newMachine(roomID, cfg, m.rng)
+ // Only the manager consumes its RNG, under m.mu. Each game owns an
+ // independent stream protected by that game's lock.
+ machine := newMachine(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63())))
m.machines[key] = machine
return machine
}
-// EnsureTable returns the table for cfg, or nil when cfg.Game has no
-// multiplayer table implementation.
+// EnsureTable returns nil for an invalid or unsupported configuration.
func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table {
+ if cfg.Validate() != nil {
+ return nil
+ }
cfg = cfg.Normalize()
key := tableKey(roomID, cfg.ID)
m.mu.Lock()
@@ -49,7 +57,7 @@ func (m *Manager) EnsureTable(roomID int, cfg TableConfig) *Table {
if table := m.tables[key]; table != nil {
return table
}
- table := newTable(roomID, cfg, m.rng)
+ table := newTable(roomID, cfg, rand.New(rand.NewSource(m.rng.Int63())))
if table == nil {
return nil
}
@@ -122,7 +130,11 @@ func (m *Manager) withMachine(roomID int, machineID string, fn func(*Machine) []
}
func (m *Manager) JoinMachine(roomID int, cfg MachineConfig, name string) []Event {
- return m.EnsureMachine(roomID, cfg).join(name)
+ machine := m.EnsureMachine(roomID, cfg)
+ if machine == nil {
+ return []Event{{RoomID: roomID, TableID: cfg.ID, Type: EventResult, PrivateTo: name, Message: "That game is not available here."}}
+ }
+ return machine.join(name)
}
func (m *Manager) SetMachineBet(roomID int, machineID, name string, amount int, wallet Wallet) []Event {
diff --git a/internal/casino/slots.go b/internal/casino/slots.go
index aba8f91..c519293 100644
--- a/internal/casino/slots.go
+++ b/internal/casino/slots.go
@@ -234,10 +234,15 @@ func ScaleToRTP(profile SlotProfile, target float64) SlotProfile {
return profile
}
scale := target / actual
+ // SlotProfile is a value API, but its slice and paytable maps are shared
+ // references. Clone both so tuning one machine cannot retune another.
+ profile.Symbols = append([]SlotSymbol(nil), profile.Symbols...)
for i := range profile.Symbols {
+ payouts := make(map[int]float64, len(profile.Symbols[i].Payout))
for length, payout := range profile.Symbols[i].Payout {
- profile.Symbols[i].Payout[length] = payout * scale
+ payouts[length] = payout * scale
}
+ profile.Symbols[i].Payout = payouts
}
profile.TargetRTP = target
return profile
diff --git a/internal/engine/lifecycle_test.go b/internal/engine/lifecycle_test.go
new file mode 100644
index 0000000..8cea97f
--- /dev/null
+++ b/internal/engine/lifecycle_test.go
@@ -0,0 +1,40 @@
+package engine
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestConcurrentTicksAreSerialized(t *testing.T) {
+ e := New()
+ count := 0
+ e.Subscribe(1, func() bool {
+ count++ // safe only when callbacks have a single owner
+ return true
+ })
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ e.Tick()
+ }
+ }()
+ }
+ wg.Wait()
+ if count != 800 {
+ t.Fatalf("lost ticks: got %d, want 800", count)
+ }
+}
+
+func TestStopIsIdempotentAndRestartable(t *testing.T) {
+ e := New()
+ e.Stop()
+ defer e.Stop()
+ for i := 0; i < 100; i++ {
+ e.Start(60000)
+ e.Stop()
+ e.Stop()
+ }
+}
diff --git a/internal/engine/tick.go b/internal/engine/tick.go
index 7b9666b..00a9c60 100644
--- a/internal/engine/tick.go
+++ b/internal/engine/tick.go
@@ -17,6 +17,7 @@ type subscriber struct {
}
type Engine struct {
+ tickMu sync.Mutex // serializes manual ticks and successive ticker runs
mu sync.Mutex
subscribers map[uint64]*subscriber
nextID uint64
@@ -61,19 +62,24 @@ func (e *Engine) Start(tickLengthMs int) {
}
e.ticker = time.NewTicker(time.Duration(tickLengthMs) * time.Millisecond)
e.stopCh = make(chan struct{})
+ // Capture this run's resources. A quick Stop/Start must not make the
+ // old goroutine consume the replacement ticker or stop channel.
+ ticker, stopCh := e.ticker, e.stopCh
go func() {
for {
select {
- case <-e.ticker.C:
+ case <-ticker.C:
e.processTick()
- case <-e.stopCh:
+ case <-stopCh:
return
}
}
}()
}
+// Stop requests that the ticker loop exit. It is idempotent, but does not
+// wait for an in-flight callback; it is not a graceful-shutdown save barrier.
func (e *Engine) Stop() {
e.mu.Lock()
defer e.mu.Unlock()
@@ -83,14 +89,18 @@ func (e *Engine) Stop() {
e.running = false
if e.stopCh != nil {
close(e.stopCh)
+ e.stopCh = nil
}
}
// Tick fires every due subscriber synchronously and returns. Subscribers
-// are invoked in ascending subscription-ID (registration) order.
+// are invoked in ascending subscription-ID (registration) order. Tick calls
+// are serialized; a callback must not call Tick recursively.
func (e *Engine) Tick() { e.processTick() }
func (e *Engine) processTick() {
+ e.tickMu.Lock()
+ defer e.tickMu.Unlock()
e.mu.Lock()
snapshot := make(map[uint64]*subscriber, len(e.subscribers))
for id, sub := range e.subscribers {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 2e80bf3..eb6fa4a 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -62,6 +62,9 @@ func (g *Game) casinoTable(roomID int, gameName string) (*casino.Table, bool) {
if err != nil {
return nil, false
}
+ if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil {
+ return nil, false
+ }
for _, cfg := range room.CasinoTables {
if strings.EqualFold(string(cfg.Game), gameName) {
table := g.Casino.EnsureTable(roomID, cfg)
@@ -79,9 +82,13 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool
if err != nil {
return nil, false
}
+ if casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines) != nil {
+ return nil, false
+ }
for _, cfg := range room.CasinoMachines {
if strings.EqualFold(string(cfg.Game), gameName) || strings.EqualFold(cfg.Variant, gameName) {
- return g.Casino.EnsureMachine(roomID, cfg), true
+ machine := g.Casino.EnsureMachine(roomID, cfg)
+ return machine, machine != nil
}
}
// Migrate older room definitions that declared slots under casino_tables;
@@ -89,9 +96,10 @@ func (g *Game) casinoMachine(roomID int, gameName string) (*casino.Machine, bool
if strings.EqualFold(gameName, "slots") {
for _, cfg := range room.CasinoTables {
if strings.EqualFold(string(cfg.Game), "slots") {
- return g.Casino.EnsureMachine(roomID, casino.MachineConfig{
+ machine := g.Casino.EnsureMachine(roomID, casino.MachineConfig{
ID: cfg.ID, Game: casino.GameSlots, MinBet: cfg.MinBet, MaxBet: cfg.MaxBet,
- }), true
+ })
+ return machine, machine != nil
}
}
}
diff --git a/internal/persistence/atomic.go b/internal/persistence/atomic.go
new file mode 100644
index 0000000..a3cc06a
--- /dev/null
+++ b/internal/persistence/atomic.go
@@ -0,0 +1,53 @@
+// Package persistence provides crash-resistant single-file replacement.
+// It does not serialize callers or make multi-file operations transactional.
+package persistence
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// WriteFile writes and syncs a temporary file in the destination directory,
+// atomically renames it over path, then syncs the directory. Existing file
+// permissions are preserved; perm is used for new files. Callers must own the
+// data being serialized and coordinate competing read-modify-write operations.
+// A directory-sync error means replacement occurred but durability is uncertain.
+func WriteFile(path string, data []byte, perm os.FileMode) error {
+ if info, err := os.Stat(path); err == nil {
+ perm = info.Mode().Perm()
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ dir := filepath.Dir(path)
+ f, err := os.CreateTemp(dir, ".save-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(f.Name())
+ defer f.Close()
+ if _, err := f.Write(data); err != nil {
+ return err
+ }
+ if err := f.Chmod(perm); err != nil {
+ return err
+ }
+ if err := f.Sync(); err != nil {
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(f.Name(), path); err != nil {
+ return err
+ }
+ d, err := os.Open(dir)
+ if err != nil {
+ return fmt.Errorf("replacement completed; open directory for sync: %w", err)
+ }
+ defer d.Close()
+ if err := d.Sync(); err != nil {
+ return fmt.Errorf("replacement completed; sync directory: %w", err)
+ }
+ return nil
+}
diff --git a/internal/persistence/atomic_test.go b/internal/persistence/atomic_test.go
new file mode 100644
index 0000000..05710f1
--- /dev/null
+++ b/internal/persistence/atomic_test.go
@@ -0,0 +1,75 @@
+package persistence
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func TestWriteFileReplacesAndPreservesPermissions(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "save.yaml")
+ if err := WriteFile(path, []byte("old"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteFile(path, []byte("new"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil || string(got) != "new" {
+ t.Fatalf("read: %q, %v", got, err)
+ }
+ info, err := os.Stat(path)
+ if err != nil || info.Mode().Perm() != 0600 {
+ t.Fatalf("permissions: %v, %v", info, err)
+ }
+ entries, err := os.ReadDir(filepath.Dir(path))
+ if err != nil || len(entries) != 1 {
+ t.Fatalf("temporary files left behind: %v, %v", entries, err)
+ }
+}
+
+func TestFailedRenameCleansTempFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "destination")
+ if err := os.Mkdir(path, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteFile(path, []byte("new"), 0600); err == nil {
+ t.Fatal("expected rename over directory to fail")
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 1 || !entries[0].IsDir() {
+ t.Fatalf("destination damaged or temporary file leaked: %v, %v", entries, err)
+ }
+}
+
+func TestReadersSeeOnlyCompleteSaves(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "save.yaml")
+ a, b := bytes.Repeat([]byte("a"), 8192), bytes.Repeat([]byte("b"), 16384)
+ if err := WriteFile(path, a, 0600); err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ for _, data := range [][]byte{a, b} {
+ wg.Add(1)
+ go func(data []byte) {
+ defer wg.Done()
+ for i := 0; i < 30; i++ {
+ if err := WriteFile(path, data, 0600); err != nil {
+ t.Error(err)
+ return
+ }
+ }
+ }(data)
+ }
+ for i := 0; i < 300; i++ {
+ got, err := os.ReadFile(path)
+ if err != nil || (!bytes.Equal(got, a) && !bytes.Equal(got, b)) {
+ t.Errorf("reader saw partial or missing save: length=%d, err=%v", len(got), err)
+ break
+ }
+ }
+ wg.Wait()
+}
diff --git a/internal/player/store.go b/internal/player/store.go
index 9bfdf40..d0dbd0f 100644
--- a/internal/player/store.go
+++ b/internal/player/store.go
@@ -8,6 +8,7 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/persistence"
)
type AccountStore struct {
@@ -41,7 +42,7 @@ func (s *AccountStore) SaveAccount(acc *Account) error {
if err != nil {
return err
}
- return os.WriteFile(path, data, 0644)
+ return persistence.WriteFile(path, data, 0600)
}
func (s *AccountStore) AccountExists(name string) bool {
@@ -61,7 +62,7 @@ func (s *AccountStore) FindAccount(name string) (string, error) {
}
lower := strings.ToLower(name)
for _, e := range entries {
- if e.IsDir() {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
base := strings.TrimSuffix(e.Name(), ".yaml")
@@ -103,23 +104,20 @@ func (s *AccountStore) LoadCharacter(name string) (*Player, error) {
func (s *AccountStore) SaveCharacter(p *Player) error {
path := s.CharPath(p.Name)
- if p.GodMode && p.GodBackup != nil {
- buffedSkills := p.Skills
- buffedHP := p.HP
- p.Skills = p.GodBackup
- if p.HP > p.MaxHP() {
- p.HP = p.MaxHP()
+ // Serialize a value copy so saving god-mode stats never temporarily
+ // changes the live player. The caller still owns all referenced maps.
+ snapshot := *p
+ if snapshot.GodMode && snapshot.GodBackup != nil {
+ snapshot.Skills = snapshot.GodBackup
+ if snapshot.HP > snapshot.MaxHP() {
+ snapshot.HP = snapshot.MaxHP()
}
- defer func() {
- p.Skills = buffedSkills
- p.HP = buffedHP
- }()
}
- data, err := yaml.Marshal(p)
+ data, err := yaml.Marshal(&snapshot)
if err != nil {
return err
}
- return os.WriteFile(path, data, 0644)
+ return persistence.WriteFile(path, data, 0600)
}
func (s *AccountStore) CharacterExists(name string) bool {
@@ -139,7 +137,7 @@ func (s *AccountStore) FindCharacter(name string) (string, error) {
}
lower := strings.ToLower(name)
for _, e := range entries {
- if e.IsDir() {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
base := strings.TrimSuffix(e.Name(), ".yaml")
@@ -150,10 +148,7 @@ func (s *AccountStore) FindCharacter(name string) (string, error) {
return "", fmt.Errorf("character not found: %s", name)
}
-// ListCharacterNames returns the stem (character name) of every
-// data/players/characters/*.yaml file. Used by room-ID migration to walk and
-// rewrite offline characters. Online characters are excluded by the caller
-// (their in-memory state is updated directly).
+// ListAccountNames returns the stem of each account YAML file.
func (s *AccountStore) ListAccountNames() ([]string, error) {
dir := filepath.Join(s.dataDir, "players", "accounts")
entries, err := os.ReadDir(dir)
@@ -162,7 +157,7 @@ func (s *AccountStore) ListAccountNames() ([]string, error) {
}
var names []string
for _, e := range entries {
- if e.IsDir() {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
name := strings.TrimSuffix(e.Name(), ".yaml")
@@ -174,6 +169,8 @@ func (s *AccountStore) ListAccountNames() ([]string, error) {
return names, nil
}
+// ListCharacterNames returns character YAML stems for offline migrations.
+// Callers exclude online characters and update their in-memory state instead.
func (s *AccountStore) ListCharacterNames() ([]string, error) {
dir := filepath.Join(s.dataDir, "players", "characters")
entries, err := os.ReadDir(dir)
@@ -182,7 +179,7 @@ func (s *AccountStore) ListCharacterNames() ([]string, error) {
}
var names []string
for _, e := range entries {
- if e.IsDir() {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
name := strings.TrimSuffix(e.Name(), ".yaml")
diff --git a/internal/player/store_test.go b/internal/player/store_test.go
new file mode 100644
index 0000000..593e9d1
--- /dev/null
+++ b/internal/player/store_test.go
@@ -0,0 +1,81 @@
+package player
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "sync"
+ "testing"
+)
+
+func testAccountStore(t *testing.T) *AccountStore {
+ t.Helper()
+ dir := t.TempDir()
+ for _, kind := range []string{"accounts", "characters"} {
+ if err := os.MkdirAll(filepath.Join(dir, "players", kind), 0755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return NewAccountStore(dir)
+}
+
+func TestSaveGodPlayerDoesNotMutateLiveState(t *testing.T) {
+ s := testAccountStore(t)
+ p := New("Alice")
+ p.GodBackup = p.Skills
+ p.Skills = map[SkillName]int{Hitpoints: 1000000}
+ p.GodMode = true
+ p.HP = 999
+ expected := *p
+ expected.Skills = p.GodBackup
+ wantHP := expected.MaxHP()
+
+ // Concurrent saves of an otherwise immutable player used to race while
+ // temporarily swapping Skills/HP. Saving must be observationally read-only.
+ var wg sync.WaitGroup
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := s.SaveCharacter(p); err != nil {
+ t.Error(err)
+ }
+ }()
+ }
+ wg.Wait()
+ if p.HP != 999 || p.Skills[Hitpoints] != 1000000 {
+ t.Fatal("save changed live god-mode state")
+ }
+ saved, err := s.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved.HP != wantHP || !reflect.DeepEqual(saved.Skills, p.GodBackup) {
+ t.Fatalf("saved god-mode stats instead of underlying character: %+v", saved)
+ }
+}
+
+func TestAccountStoreIgnoresNonYAMLFiles(t *testing.T) {
+ s := testAccountStore(t)
+ for _, path := range []string{s.AccountPath("Alice"), s.CharPath("Alice")} {
+ for _, name := range []string{"Alice.yaml", ".save-stale", "Backup.yaml.bak"} {
+ if err := os.WriteFile(filepath.Join(filepath.Dir(path), name), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ for _, list := range []func() ([]string, error){s.ListAccountNames, s.ListCharacterNames} {
+ got, err := list()
+ if err != nil || !reflect.DeepEqual(got, []string{"Alice"}) {
+ t.Fatalf("list = %v, %v", got, err)
+ }
+ }
+ for _, find := range []func(string) (string, error){s.FindAccount, s.FindCharacter} {
+ if got, err := find("alice"); err != nil || got != "Alice" {
+ t.Fatalf("case-insensitive lookup = %q, %v", got, err)
+ }
+ if _, err := find(".save-stale"); err == nil {
+ t.Fatal("temporary file treated as an account/character")
+ }
+ }
+}
diff --git a/internal/validate/casino_test.go b/internal/validate/casino_test.go
new file mode 100644
index 0000000..9eb94db
--- /dev/null
+++ b/internal/validate/casino_test.go
@@ -0,0 +1,29 @@
+package validate
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestRoomCasinoValidation(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "rooms", "1.yaml"), `name: Casino
+casino_tables:
+ - id: cards
+ game: blackjack
+ min_bet: 10
+ max_bet: 5
+casino_machines:
+ - id: slots
+ game: slots
+ payout: .nan
+ - id: slots
+ game: slots
+`)
+ issues := validateRooms(newSource(dir))
+ for _, message := range []string{"Room 1", "max_bet", "payout", "duplicate casino id"} {
+ if !containsMsg(issues, message) {
+ t.Errorf("missing %q in %+v", message, issues)
+ }
+ }
+}
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 527b558..344df3a 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -8,6 +8,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/casino"
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/world"
@@ -31,6 +32,12 @@ func validateRooms(s Source) []Issue {
})
continue
}
+ if err := casino.ValidateRoomConfigs(room.CasinoTables, room.CasinoMachines); err != nil {
+ issues = append(issues, Issue{
+ Level: "ERROR", Type: "casino",
+ Message: fmt.Sprintf("Room %d: %v", id, err),
+ })
+ }
if room.Name == "" {
issues = append(issues, Issue{
Level: "WARN",