From 6f3c43aca62a7d33a19f14f6ad7c16891952673a Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 15 Sep 2026 19:55:21 -0400 Subject: fix: outstanding-stake ledger, owner backpressure, editor state preservation --- building_guide/concurrency.md | 56 +++-- go.mod | 3 + go.sum | 4 + internal/admin/api_rooms.go | 12 +- internal/admin/api_rooms_bulk.go | 146 ++++++------- internal/admin/bulk_edit_regression_test.go | 125 +++++++++++ internal/admin/room_rename_state_test.go | 68 ++++++ internal/admin/server.go | 29 ++- internal/casino/baccarat.go | 64 ++++-- internal/casino/blackjack.go | 61 ++++-- internal/casino/cards.go | 16 +- internal/casino/credits.go | 7 + internal/casino/machine.go | 56 +++-- internal/casino/manager_test.go | 25 ++- internal/casino/money_regression_test.go | 177 ++++++++++++++++ internal/casino/types.go | 11 + internal/game/casino.go | 60 +++++- internal/game/casino_forfeit_regression_test.go | 61 ++++++ internal/game/core_login_account.go | 14 ++ internal/game/economy.go | 241 ++++++++++++++++++---- internal/game/economy_recovery_regression_test.go | 169 +++++++++++++++ internal/game/economy_shutdown_test.go | 16 +- internal/game/owner.go | 36 +++- internal/game/owner_regression_test.go | 68 ++++++ internal/player/password.go | 77 ++++++- internal/player/password_test.go | 60 ++++++ 26 files changed, 1435 insertions(+), 227 deletions(-) create mode 100644 internal/admin/bulk_edit_regression_test.go create mode 100644 internal/admin/room_rename_state_test.go create mode 100644 internal/casino/money_regression_test.go create mode 100644 internal/game/casino_forfeit_regression_test.go create mode 100644 internal/game/economy_recovery_regression_test.go create mode 100644 internal/game/owner_regression_test.go create mode 100644 internal/player/password_test.go diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md index 1a61670..ff8c049 100644 --- a/building_guide/concurrency.md +++ b/building_guide/concurrency.md @@ -19,6 +19,11 @@ Rules: block the caller until the closure has executed on the owner. Connection goroutines submit one task per input line, giving per-client backpressure without ever blocking another client. +- A FULL task queue applies backpressure: submitters block until the owner + drains a task, and a full queue never bypasses the owner — running a task + on the caller's goroutine while the owner keeps executing would break the + single-owner invariant. Only a STOPPED owner (graceful shutdown, after + admissions have closed) lets stragglers execute inline. - Code already running on the owner must NOT call `Game.RunOnOwner` — it would deadlock waiting for itself. Owner-executed code calls functions directly. @@ -43,29 +48,44 @@ synthetic tests but production Games always have the owner (started by Every wager and payout is journaled before the balance mutation (`internal/game/economy.go`, `data/economy/journal.jsonl`): -1. A durable journal record with balance checkpoints (before/after) is - appended and fsynced. +1. A durable journal record with balance checkpoints (before/after credits + and chips) is appended and fsynced. 2. The in-memory balance mutates. 3. The character save makes the balance durable. -4. A settlement marker closes the record. + +Two record kinds exist: + +- **wager** — a committed stake. The record stays OPEN while the stake is + outstanding (round in flight) and is resolved when the round settles: + won, lost, pushed, forfeited (disconnect), or refunded (shutdown). Games + resolve stakes through `casino.Wallet.ResolveStakes` after settlement; + `casino.Wallet.RefundOutstanding` returns every outstanding stake + (chips as chips, credits durably) at shutdown/eviction, exactly once. +- **payout** — money movement (winnings, pushes, refunds). The record + closes when the balance mutation is durable. A failed save reverts the in-memory mutation and voids the record — the game must not proceed on a stake that could not be made durable, and the -player is told the house could not process the transaction (distinct from an -insufficient-funds message). Payouts keep their record open when the save -fails; startup recovery re-pays the owed settlement. - -At startup (`game.New`), open records are reduced (latest line per record ID -wins) and compensated: - -- open **wager** with the file at the pre-debit balance → void (nothing was - charged); -- open **wager** with the file at the post-debit balance → the stake is - **refunded** (the round state was lost in the crash); -- open **payout** with the file at the pre-payout balance → the payout is - **re-applied** (the player is owed it); -- otherwise the record settles, or is surfaced for operator inspection when - the balance is ambiguous. +player is told the house could not process the transaction (distinct from +an insufficient-funds message). A failed payout keeps its record open (the +reported win is never announced as paid) and startup recovery re-pays the +owed settlement. + +At startup (`game.New`), open records are compensated in two phases: + +1. Open **payout** records (append order, evolving balance): file at the + pre-payout balance → re-apply (the player is owed it); at the post-payout + balance → already durable, settle; anything else stays open and is + surfaced for operator inspection — a liability is never silently + dropped. +2. Open **wager** records (newest first, so each refund restores the + balance the previous record's checkpoints refer to): file at the + pre-debit state (credits AND chips) → void (nothing was charged); at + the post-debit state → the stake is **refunded** (the round state was + lost in the crash) — unless a later payout record proves the round + resolved (the win/push/refund already returned the stake inside that + payout), in which case only the lost resolution marker is re-applied. + Ambiguous records stay open for operator inspection. Currency arithmetic is checked (`internal/casino/credits.go`): explicit `MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited diff --git a/go.mod b/go.mod index e6144f8..81daee4 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.25.0 require ( github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.36.0 gopkg.in/yaml.v3 v3.0.1 ) + +require golang.org/x/sys v0.31.0 // indirect diff --git a/go.sum b/go.sum index 6a2c68d..955a0c0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 4cfb467..c93e7c6 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -970,13 +970,13 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) { } s.world.RebuildRoomIndex(s.dataDir) - s.world.ClearRoomState(body.ID) - // Migrate room-ID-embedded state across all characters (discovered-exit - // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) plus - // Hub membership buckets, casino room keys, world room-keyed state, and - // live mob instances, so a rename leaves no gameplay, broadcast, or - // player attached to the old location. + // Runtime room state moves with the room: ground items, seeded spawn + // markers, and object states are rekeyed to the new ID — never cleared. + // Clearing here would destroy live room contents. The wired game + // callback performs the same world rekey plus players, Hub membership, + // casino room keys, and mob instances. + s.world.RekeyRooms(idMap) if s.rewriteRoomIDs != nil { s.rewriteRoomIDs(idMap) } diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go index 6283067..a7fcee7 100644 --- a/internal/admin/api_rooms_bulk.go +++ b/internal/admin/api_rooms_bulk.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strconv" + + "thehouseoficarus/internal/persistence" ) type bulkEditPatch struct { @@ -22,6 +24,17 @@ type bulkEditRequest struct { IDs []int `json:"ids"` } +// handleBulkEdit applies one patch to many rooms as a single mutation +// plan: +// +// 1. Preflight reads, parses, patches, and marshals every selected room in +// memory — including destination-directory and conflict checks for +// moves. Any failure aborts BEFORE any file is touched: a failed bulk +// edit never leaves partial changes behind. +// 2. The writes apply as one journal transaction (in-place replaces, and +// move+replace for real directory moves) that rolls back on failure. +// 3. One undo record captures every before/after image, so undo restores +// the whole plan. func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -53,43 +66,27 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { patch := req.Patch - roomPaths := make([]string, 0, len(req.IDs)) - for _, id := range req.IDs { - path, ok := s.world.GetRoomPath(id) - if !ok { - writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) - return - } - roomPaths = append(roomPaths, path) - } - type roomWrite struct { - oldContent []byte - newContent []byte - oldPath string - newPath string - wasMoved bool + id int + oldPath, newPath string + oldContent, newContent []byte } + // ---- Preflight: everything that can fail happens here, before any + // file is modified. ---- writes := make([]roomWrite, 0, len(req.IDs)) - dirMovePaths := make(map[string]struct{}) - - for i, id := range req.IDs { - path := roomPaths[i] - - oldContent, snapErr := snapshotFile(path) - if snapErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room %d: %v", id, snapErr)}) + for _, id := range req.IDs { + path, ok := s.world.GetRoomPath(id) + if !ok { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) return } - - data, readErr := os.ReadFile(path) - if readErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, readErr)}) + oldContent, err := os.ReadFile(path) + if err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, err)}) return } - - m, yamlErr := yamlToMap(data) + m, yamlErr := yamlToMap(oldContent) if yamlErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("parse room %d: %v", id, yamlErr)}) return @@ -109,63 +106,50 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { } } + newPath := path if patch.Dir != nil && *patch.Dir != "" { destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir) - if _, err := os.Stat(destDir); os.IsNotExist(err) { + if _, statErr := os.Stat(destDir); os.IsNotExist(statErr) { writeJSON(w, map[string]any{"error": fmt.Sprintf("directory does not exist: %s", *patch.Dir)}) return } - - fileName := strconv.Itoa(id) + ".yaml" - newPath := filepath.Join(destDir, fileName) + newPath = filepath.Join(destDir, strconv.Itoa(id)+".yaml") if newPath != path { - if _, err := os.Stat(newPath); err == nil { + if _, statErr := os.Stat(newPath); statErr == nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists in target directory", id)}) return } } + // newPath == path is an in-place edit: the room already lives + // in the target directory and must NOT be removed after its + // write (that deleted existing rooms). + } - newContent, writeErr := writeMapAsYAML(newPath, m) - if writeErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)}) - return - } - - writes = append(writes, roomWrite{ - oldContent: oldContent, - newContent: newContent, - oldPath: path, - newPath: newPath, - wasMoved: true, - }) - dirMovePaths[path] = struct{}{} - } else { - newContent, writeErr := writeMapAsYAML(path, m) - if writeErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)}) - return - } - - writes = append(writes, roomWrite{ - oldContent: oldContent, - newContent: newContent, - oldPath: path, - newPath: path, - wasMoved: false, - }) + newContent, marshalErr := marshalMapYAML(m) + if marshalErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("marshal room %d: %v", id, marshalErr)}) + return } + writes = append(writes, roomWrite{id: id, oldPath: path, newPath: newPath, oldContent: oldContent, newContent: newContent}) } - for _, rw := range writes { - if rw.wasMoved { - if _, alreadyRemoved := dirMovePaths[rw.oldPath]; alreadyRemoved { - delete(dirMovePaths, rw.oldPath) - if err := os.Remove(rw.oldPath); err != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("remove old room: %v", err)}) - return - } + // ---- Apply as one journaled transaction. ---- + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + for _, wr := range writes { + if wr.newPath != wr.oldPath { + if err := txn.Move(wr.oldPath, wr.newPath); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("move room %d: %v", wr.id, err)}) + return } } + if err := txn.Replace(wr.newPath, wr.newContent, 0644); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", wr.id, err)}) + return + } + } + if err := txn.Commit(); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("commit bulk edit: %v", err)}) + return } s.world.RebuildRoomIndex(s.dataDir) @@ -179,24 +163,24 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { var extraFiles []ExtraFile var firstDesc ChangeDesc - for i, rw := range writes { + for i, wr := range writes { ef := ExtraFile{ - FilePath: rw.oldPath, - OldContent: rw.oldContent, - NewContent: rw.newContent, + FilePath: wr.oldPath, + OldContent: wr.oldContent, + NewContent: wr.newContent, } - if rw.wasMoved { - ef.NewFilePath = rw.newPath + if wr.newPath != wr.oldPath { + ef.NewFilePath = wr.newPath } if i == 0 { firstDesc = ChangeDesc{ Description: desc, - FilePath: rw.oldPath, - OldContent: rw.oldContent, - NewContent: rw.newContent, + FilePath: wr.oldPath, + OldContent: wr.oldContent, + NewContent: wr.newContent, } - if rw.wasMoved { - firstDesc.NewFilePath = rw.newPath + if wr.newPath != wr.oldPath { + firstDesc.NewFilePath = wr.newPath } } else { extraFiles = append(extraFiles, ef) diff --git a/internal/admin/bulk_edit_regression_test.go b/internal/admin/bulk_edit_regression_test.go new file mode 100644 index 0000000..4828c93 --- /dev/null +++ b/internal/admin/bulk_edit_regression_test.go @@ -0,0 +1,125 @@ +package admin + +// Bulk-edit mutation-plan regressions: a same-directory "move" must never +// delete the room, a failure anywhere must leave every room untouched (no +// partial changes without an undo record), and a successful plan applies as +// one unit with a single undo record. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func postBulkEdit(t *testing.T, s *AdminServer, body string) (map[string]any, int) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/rooms/bulk-edit", strings.NewReader(body)) + rec := httptest.NewRecorder() + s.handleBulkEdit(rec, req) + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + return resp, rec.Code +} + +// TestBulkEditSameDirectoryMovePreservesRoom: moving a room to the +// directory it already lives in is an in-place edit, not a delete. The old +// implementation removed the file and reported success. +func TestBulkEditSameDirectoryMovePreservesRoom(t *testing.T) { + s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"}) + oldPath, _ := s.world.GetRoomPath(1) + dir := filepath.Join(s.dataDir, "rooms", "area") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + dest := filepath.Join(dir, "1.yaml") + if err := os.Rename(oldPath, dest); err != nil { + t.Fatal(err) + } + s.world.RebuildRoomIndex(s.dataDir) + + resp, code := postBulkEdit(t, s, `{"ids":[1],"patch":{"dir":"area"}}`) + if code != http.StatusOK { + t.Fatalf("bulk edit failed: %d %v", code, resp) + } + if _, err := os.Stat(dest); err != nil { + t.Fatalf("same-directory move deleted the room: %v", err) + } + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Fatal("old path should not reappear for an in-place edit") + } +} + +// TestBulkEditFailureLeavesNoPartialChanges: a parse failure on any selected +// room aborts the whole plan before any file changes, so no room is mutated +// without an undo record. +func TestBulkEditFailureLeavesNoPartialChanges(t *testing.T) { + s := newTestAdminServer(t, map[int]string{ + 1: "id: 1\nname: Room\n", + 2: "invalid: [\n", + }) + path, _ := s.world.GetRoomPath(1) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + resp, code := postBulkEdit(t, s, `{"ids":[1,2],"patch":{"color":"red"}}`) + if code == http.StatusOK && resp["error"] == nil { + t.Fatalf("bulk edit unexpectedly succeeded: %v", resp) + } + if resp["error"] == nil { + t.Fatalf("expected an error response, got: %v", resp) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("failed bulk edit mutated room 1: before=%q after=%q", before, after) + } + if s.undoStack.Info().StackSize != 0 { + t.Fatal("failed bulk edit pushed an undo record") + } +} + +// TestBulkEditSuccessSingleUndoRecord: a valid plan applies every patch and +// pushes exactly one undo record whose undo restores every file. +func TestBulkEditSuccessSingleUndoRecord(t *testing.T) { + s := newTestAdminServer(t, map[int]string{ + 1: "id: 1\nname: Room\n", + 2: "id: 2\nname: Other\n", + }) + + resp, code := postBulkEdit(t, s, `{"ids":[1,2],"patch":{"color":"red"}}`) + if code != http.StatusOK { + t.Fatalf("bulk edit failed: %d %v", code, resp) + } + for _, id := range []int{1, 2} { + path, _ := s.world.GetRoomPath(id) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "color: red") { + t.Fatalf("room %d not patched: %q", id, data) + } + } + if info := s.undoStack.Info(); info.StackSize != 1 { + t.Fatalf("undo stack size = %d, want exactly 1 plan", info.StackSize) + } + if s.undoStack.Undo() == nil { + t.Fatal("undo failed") + } + path, _ := s.world.GetRoomPath(1) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "color: red") { + t.Fatalf("undo did not restore room 1: %q", data) + } +} diff --git a/internal/admin/room_rename_state_test.go b/internal/admin/room_rename_state_test.go new file mode 100644 index 0000000..8c5be93 --- /dev/null +++ b/internal/admin/room_rename_state_test.go @@ -0,0 +1,68 @@ +package admin + +// Room-rename runtime-state regressions: renaming a room must move its live +// contents (ground items, seeded spawn markers, object state) to the new ID +// — never clear them — and undo must bring them back. + +import ( + "testing" +) + +// TestRoomRenameMovesRuntimeState: renaming a room with ground items moves +// them to the new room ID. The old implementation cleared the state before +// the migration callback could rekey it, destroying the room's contents. +func TestRoomRenameMovesRuntimeState(t *testing.T) { + s := newTestAdminServer(t, map[int]string{ + 1: "id: 1\nname: Room\nexits:\n east:\n room: 2\n", + 2: "id: 2\nname: Neighbor\nexits:\n west:\n room: 1\n", + }) + s.world.AddGroundItem(1, "chips", 50) + s.world.AddGroundItem(1, "chips", 10) // second entry: rekey must move the whole list + s.rewriteRoomIDs = func(idMap map[int]int) { s.world.RekeyRooms(idMap) } + s.undoStack.SetMigrateRoomIDs(s.rewriteRoomIDs) + + if rec := renameRoom(t, s, 1, 3); rec.Code != 200 { + t.Fatalf("rename failed: %d %s", rec.Code, rec.Body.String()) + } + if got := s.world.GroundItems(3)["chips"]; got != 60 { + t.Fatalf("ground items after rename = %d, want 60 in room 3", got) + } + if got := s.world.GroundItems(1)["chips"]; got != 0 { + t.Fatalf("ground items left behind in the old room: %d", got) + } + + // Undo restores the files AND the runtime state through the inverse map. + if s.undoStack.Undo() == nil { + t.Fatal("undo failed") + } + if got := s.world.GroundItems(1)["chips"]; got != 60 { + t.Fatalf("ground items after undo = %d, want 60 back in room 1", got) + } +} + +// TestRoomRenameUndoDoesNotClearSurvivingRoomState: undoing a rename must +// not clear runtime state of rooms whose files still exist afterwards +// (only undo-of-create / redo-of-delete drops state). +func TestRoomRenameUndoDoesNotClearSurvivingRoomState(t *testing.T) { + s := newTestAdminServer(t, map[int]string{ + 1: "id: 1\nname: Room\n", + 2: "id: 2\nname: Unrelated\n", + }) + s.world.AddGroundItem(2, "chips", 5) + s.rewriteRoomIDs = func(idMap map[int]int) { s.world.RekeyRooms(idMap) } + s.undoStack.SetMigrateRoomIDs(s.rewriteRoomIDs) + + if rec := renameRoom(t, s, 1, 3); rec.Code != 200 { + t.Fatalf("rename failed: %d %s", rec.Code, rec.Body.String()) + } + // The unrelated room's state must survive the rename and its undo. + if got := s.world.GroundItems(2)["chips"]; got != 5 { + t.Fatalf("unrelated room state lost during rename: %d", got) + } + if s.undoStack.Undo() == nil { + t.Fatal("undo failed") + } + if got := s.world.GroundItems(2)["chips"]; got != 5 { + t.Fatalf("unrelated room state lost during undo: %d", got) + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 41c1e0f..81f0cce 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -364,6 +364,17 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { return } + // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to + // Argon2id after a successful verification. + if player.NeedsRehash(acc.PasswordHash) { + if hash, hashErr := player.HashPassword(password); hashErr == nil { + acc.PasswordHash = hash + if err := s.accountStore.SaveAccount(acc); err != nil { + log.Printf("admin login: password rehash save for %s: %v", account, err) + } + } + } + if !acc.Admin { s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."}) return @@ -649,12 +660,18 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) { name := filepath.Base(p) if idStr := strings.TrimSuffix(name, ".yaml"); idStr != name { if id, err := strconv.Atoi(idStr); err == nil && id > 0 { - s.world.ClearRoomState(id) - // If the room file is now gone (true after undo-of-create and - // redo-of-delete), drop any in-memory mob instances for it so - // they don't keep ticking against a room that no longer exists. - if _, statErr := os.Stat(p); os.IsNotExist(statErr) && s.mobStore != nil { - s.mobStore.RemoveMobsInRoom(id) + // Only drop runtime room state when the room file is now gone + // (undo-of-create, redo-of-delete, directory deletion): a + // rename's undo restores the file and the inverse migration + // rekeys its runtime state back — clearing it here would + // destroy the room's live contents. + if _, statErr := os.Stat(p); os.IsNotExist(statErr) { + s.world.ClearRoomState(id) + // Drop any in-memory mob instances too so they don't keep + // ticking against a room that no longer exists. + if s.mobStore != nil { + s.mobStore.RemoveMobsInRoom(id) + } } } } diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index d415901..fa54ac8 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -179,6 +179,8 @@ func (t *baccaratTable) rebet(name string) (string, int) { // deal plays out the hand by the punto banco tableau, settles every wager, // and resets the table for the next round. func (t *baccaratTable) deal() []Event { + // The cut-card reshuffle happens between rounds, never mid-hand. + t.shoe.shuffleIfDue() player, banker := playTableau(t.shoe) playerTotal, bankerTotal := baccaratTotal(player), baccaratTotal(banker) round := t.event("New baccarat hand!", true) @@ -189,19 +191,38 @@ func (t *baccaratTable) deal() []Event { continue } result, payout := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules) + payoutFailed := false if payout > 0 && p.Wallet != nil { - p.Wallet.PayCredits(payout) + if err := p.Wallet.PayCredits(payout); err != nil { + // The payout could not be made durable; recovery will + // re-apply the owed amount from the open journal record. + // Do not announce the win as paid and leave the stakes + // open for recovery to settle. + payoutFailed = true + publicResult, _ := baccaratResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) + if result == "win" { + settlement.Color = "casino_big_win" + } + events = append(events, settlement) + } } - publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) - settlement := t.playerAction(p.Name, publicResult, privateResult) - if result == "win" { - if p.lastSpot == baccaratSpotTie { - settlement.Color = "casino_jackpot_win" - } else { - settlement.Color = "casino_big_win" + if !payoutFailed { + publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, privateResult) + if result == "win" { + if p.lastSpot == baccaratSpotTie { + settlement.Color = "casino_jackpot_win" + } else { + settlement.Color = "casino_big_win" + } } + events = append(events, settlement) + } + if p.Wallet != nil && !payoutFailed { + // The round resolved; the stakes are no longer outstanding. + p.Wallet.ResolveStakes() } - events = append(events, settlement) } t.resetRound() for _, p := range t.players.ordered() { @@ -247,6 +268,11 @@ func (t *baccaratTable) markDisconnected(name string) []Event { if !t.players.has(name) { return nil } + // A disconnected player forfeits the round in flight: resolve the + // stakes now so startup recovery does not refund them later. + if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { + p.Wallet.ResolveStakes() + } t.players.remove(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} if t.players.count() == 0 { @@ -364,7 +390,8 @@ func baccaratResultMessages(name, result string, payout int) (string, string) { } // refundAll resolves every committed wager at this table (graceful -// shutdown): stakes return to their players through the wallet. +// shutdown/eviction): the wallet returns every outstanding stake exactly +// once. func (t *baccaratTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() @@ -373,17 +400,18 @@ func (t *baccaratTable) refundAll() []Event { if !p.HasBet || p.Wallet == nil { continue } - name, refund := p.Name, p.Wager.Total - if refund <= 0 { - continue - } - if err := p.Wallet.PayCredits(refund); err != nil { - events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + name := p.Name + chips, credits, err := p.Wallet.RefundOutstanding() + refund := chips + credits + if err != nil || refund <= 0 { + if err != nil { + events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + } continue } events = append(events, t.playerAction(name, - fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund), - fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund))) + fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), + fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) } return events } diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 7c01c34..d3b26af 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -380,6 +380,8 @@ func splitCompatible(a, b card, unlikeTens bool) bool { } func (t *blackjackTable) startRound() []Event { + // The cut-card reshuffle happens between rounds, never mid-hand. + t.shoe.shuffleIfDue() t.dealer = blackjackHand{cards: []card{t.shoe.draw(), t.shoe.draw()}} for _, p := range t.players.ordered() { if !p.HasBet { @@ -542,10 +544,27 @@ func (t *blackjackTable) settle() []Event { if !p.HasBet { continue } + payoutFailed := false for _, hand := range p.hands { result, payout := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) if payout > 0 && p.Wallet != nil { - p.Wallet.PayCredits(payout) + if err := p.Wallet.PayCredits(payout); err != nil { + // The payout could not be made durable. The journal + // record stays open: recovery re-applies the owed + // amount. The win is not announced as paid, and the + // stakes stay open so recovery settles them against + // the payout record. + payoutFailed = true + publicResult, _ := blackjackResultMessages(p.Name, result, payout) + settlement := t.playerAction(p.Name, publicResult, payoutFailedMessage()) + if result == "blackjack" { + settlement.Color = "casino_jackpot_win" + } else if result == "win" { + settlement.Color = "casino_big_win" + } + events = append(events, settlement) + continue + } } publicResult, privateResult := blackjackResultMessages(p.Name, result, payout) settlement := t.playerAction(p.Name, publicResult, privateResult) @@ -562,9 +581,20 @@ func (t *blackjackTable) settle() []Event { insurancePayout, _ = MulPayout(p.insurance, 1+t.Rules.InsurancePayout) } if insurancePayout > 0 { - p.Wallet.PayCredits(insurancePayout) + if err := p.Wallet.PayCredits(insurancePayout); err != nil { + payoutFailed = true + events = append(events, t.private(p.Name, payoutFailedMessage())) + } else { + events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) + } + } else { + events = append(events, t.private(p.Name, "Insurance pays 0 credits.")) } - events = append(events, t.private(p.Name, fmt.Sprintf("Insurance pays %d credits.", insurancePayout))) + } + if p.Wallet != nil && !payoutFailed { + // The round resolved; the stakes are no longer outstanding and + // startup recovery must not refund them. + p.Wallet.ResolveStakes() } } t.resetRound() @@ -701,6 +731,11 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { if index < 0 { return nil } + // A disconnected player forfeits the round in flight: resolve the + // stakes now so startup recovery does not refund them later. + if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { + p.Wallet.ResolveStakes() + } t.players.remove(name) events := []Event{t.event(fmt.Sprintf("%s stands up.", name), true)} if t.players.count() == 0 { @@ -730,7 +765,8 @@ func (t *blackjackTable) hasParticipant(name string) bool { } // refundAll resolves every committed wager at this table (graceful -// shutdown): stakes return to their players through the wallet. +// shutdown/eviction): the wallet returns every outstanding stake — opening +// bet, doubles, splits, and insurance — exactly once. func (t *blackjackTable) refundAll() []Event { t.mu.Lock() defer t.mu.Unlock() @@ -739,17 +775,18 @@ func (t *blackjackTable) refundAll() []Event { if !p.HasBet || p.Wallet == nil { continue } - name, refund := p.Name, p.Wager.Total - if refund <= 0 { - continue - } - if err := p.Wallet.PayCredits(refund); err != nil { - events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + name := p.Name + chips, credits, err := p.Wallet.RefundOutstanding() + refund := chips + credits + if err != nil || refund <= 0 { + if err != nil { + events = append(events, t.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + } continue } events = append(events, t.playerAction(name, - fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund), - fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund))) + fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), + fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund))) } return events } diff --git a/internal/casino/cards.go b/internal/casino/cards.go index f263eb6..b1c2cbb 100644 --- a/internal/casino/cards.go +++ b/internal/casino/cards.go @@ -69,10 +69,24 @@ func (s *shoe) shuffle() { } func (s *shoe) draw() card { - if s.shuffleAt > 0 && len(s.cards) <= int(float64(s.decks*52)*s.shuffleAt) { + if len(s.cards) == 0 { + // The shoe ran dry mid-round (only possible when the configured + // deck count is very small relative to the table): rebuild it + // rather than panic. The cut-card reshuffle itself happens + // between rounds via shuffleIfDue, never per draw. s.shuffle() } card := s.cards[len(s.cards)-1] s.cards = s.cards[:len(s.cards)-1] return card } + +// shuffleIfDue reshuffles the shoe once its remaining cards have fallen +// to the shuffleAt fraction (the cut card). It must be called at round +// boundaries only — shuffling between individual draws would let cards +// already dealt to hands reappear from the new shoe. +func (s *shoe) shuffleIfDue() { + if s.shuffleAt > 0 && len(s.cards) <= int(float64(s.decks*52)*s.shuffleAt) { + s.shuffle() + } +} diff --git a/internal/casino/credits.go b/internal/casino/credits.go index ce47d41..2b4e2d7 100644 --- a/internal/casino/credits.go +++ b/internal/casino/credits.go @@ -82,3 +82,10 @@ func walletErrorMessage(err error, context ...string) string { } return "You don't have enough chips and credits for that bet." } + +// payoutFailedMessage explains a settlement the wallet could not persist: +// the journal record for the payout stays open, so startup recovery will +// re-apply the owed amount. Games must NOT announce such a payout as paid. +func payoutFailedMessage() string { + return "The house could not process your payout (persistence failure). Your settlement is recorded and will be restored when you return." +} diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 6a7b7aa..caff1bf 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -228,10 +228,26 @@ func (m *Machine) finishSpin() []Event { } credits := int(math.Floor(result)) m.payoutRemainder = result - float64(credits) + payFailed := false if credits > 0 && m.player.Wallet != nil { - m.player.Wallet.PayCredits(credits) + if err := m.player.Wallet.PayCredits(credits); err != nil { + // The payout could not be made durable; recovery will re-apply + // the owed amount from the open journal record. Do not announce + // the win as paid, stop any autobet chain, and leave the stake + // open so recovery settles it against the payout record. + payFailed = true + m.autoBet, m.autoCountdown = false, 0 + failedEvents := m.private(payoutFailedMessage()) + failedEvents[0].Color = "casino_payout" + events = append(events, failedEvents...) + } + } + if !payFailed && m.player.Wallet != nil { + // The paid spin resolved (free spins that follow wager nothing): + // the stake is no longer outstanding. + m.player.Wallet.ResolveStakes() } - if credits > 0 { + if credits > 0 && !payFailed { winEvents := m.private(fmt.Sprintf("\nYou win %d credits.", credits)) winEvents[0].Color = "casino_payout" if credits > m.spinBet { @@ -283,8 +299,12 @@ func (m *Machine) leave(name string) []Event { } // resetSeat frees the machine, discarding any in-flight spin and autobet -// state. A wager already taken for a spin in progress is forfeited. +// state. A wager already taken for a spin in progress is forfeited: its +// stake is resolved so startup recovery does not refund it. func (m *Machine) resetSeat() { + if m.player != nil && m.player.Wallet != nil { + m.player.Wallet.ResolveStakes() + } m.player = nil m.bet = 0 m.spin = nil @@ -387,27 +407,31 @@ func (m *Machine) menuEvents(prefix string) []Event { return m.privateMenu(prefix) } -// refundAll refunds any committed stake at graceful shutdown. Locked. +// refundAll refunds any outstanding stake at graceful shutdown/eviction. +// The wallet resolves every outstanding stake exactly once; a completed +// spin has no outstanding stake and is not refunded again. Locked. func (m *Machine) refundAll() []Event { m.mu.Lock() defer m.mu.Unlock() - if m.player == nil || m.player.Wallet == nil || m.spinBet <= 0 { + if m.player == nil || m.player.Wallet == nil { return nil } - name, refund := m.player.Name, m.spinBet - wallet := m.player.Wallet - m.spinBet = 0 - m.bet = 0 - if err := wallet.PayCredits(refund); err != nil { - return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, - PrivateTo: name, Player: name, - PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", - Color: "casino_action", Public: false}} + name, wallet := m.player.Name, m.player.Wallet + chips, credits, err := wallet.RefundOutstanding() + refund := chips + credits + if err != nil || refund <= 0 { + if err != nil { + return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, + PrivateTo: name, Player: name, + PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", + Color: "casino_action", Public: false}} + } + return nil } return []Event{{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, - Message: fmt.Sprintf("%s is refunded %d credits as the casino closes.", name, refund), - PrivateMessage: fmt.Sprintf("The house refunds your %d credit stake for the shutdown.", refund), + Message: fmt.Sprintf("%s is refunded %d as the casino closes.", name, refund), + PrivateMessage: fmt.Sprintf("The house refunds your %d stake for the shutdown.", refund), Color: "casino_action", Public: true}} } diff --git a/internal/casino/manager_test.go b/internal/casino/manager_test.go index 50cf114..fa26aa1 100644 --- a/internal/casino/manager_test.go +++ b/internal/casino/manager_test.go @@ -4,6 +4,7 @@ import "testing" type testWallet struct { chips, credits, paid int + open []Wager } func (w *testWallet) Wager(amount int) (Wager, error) { @@ -17,7 +18,9 @@ func (w *testWallet) Wager(amount int) (Wager, error) { w.chips -= chips credits := amount - chips w.credits -= credits - return Wager{Total: amount, Chips: chips, Credits: credits}, nil + wager := Wager{Total: amount, Chips: chips, Credits: credits} + w.open = append(w.open, wager) + return wager, nil } func (w *testWallet) PayCredits(amount int) error { @@ -25,6 +28,26 @@ func (w *testWallet) PayCredits(amount int) error { return nil } +// ResolveStakes mirrors the production wallet: every outstanding stake is +// settled (no longer refundable). +func (w *testWallet) ResolveStakes() { w.open = nil } + +// RefundOutstanding returns every outstanding stake to the balances and +// resolves the stakes, mirroring the production refund path: credits are +// paid through PayCredits (recorded in paid), chips return as chips. +func (w *testWallet) RefundOutstanding() (chips, credits int, err error) { + for _, wager := range w.open { + chips += wager.Chips + credits += wager.Credits + } + w.open = nil + w.chips += chips + if credits > 0 { + w.paid += credits + } + return chips, credits, nil +} + func TestMixedWagerUsesChipsFirst(t *testing.T) { w := &testWallet{chips: 7, credits: 50} wager, err := w.Wager(12) diff --git a/internal/casino/money_regression_test.go b/internal/casino/money_regression_test.go new file mode 100644 index 0000000..43bf505 --- /dev/null +++ b/internal/casino/money_regression_test.go @@ -0,0 +1,177 @@ +package casino + +// Casino money-movement regressions: shutdown/eviction refunds cover every +// committed stake (bet, double, split, insurance) exactly once, completed +// spins are not refunded, failed payouts are never announced as paid, and +// the card shoe reshuffles between rounds only. + +import ( + "strings" + "testing" +) + +// failingPayoutWallet rejects every payout with a persistence failure. +type failingPayoutWallet struct { + testWallet +} + +func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence } + +// TestBlackjackRefundCoversAllStakes: splitting an eligible pair commits a +// second stake; the shutdown refund must return both, and a completed round +// (stakes resolved) must not be refunded at all. +func TestBlackjackRefundCoversAllStakes(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer + card{rank: "8"}, card{rank: "8"}, // alice's splittable pair + card{rank: "2"}, card{rank: "3"}, // split draws + ) + wallet := &testWallet{credits: 100} + table.join("alice") + table.bet("alice", "", 10, wallet) + table.action("alice", "split") + if wallet.credits != 80 { + t.Fatalf("setup: split did not commit a second stake (credits=%d)", wallet.credits) + } + + // The round is in flight: the refund must return every committed stake. + table.refundAll() + if total := wallet.credits + wallet.paid; total != 100 { + t.Fatalf("split-then-refund returned %d of 100 committed credits", total) + } + + // A refund must be idempotent: the stakes are resolved after the first. + table.refundAll() + if total := wallet.credits + wallet.paid; total != 100 { + t.Fatalf("second refund double-paid: %d of 100", total) + } +} + +// TestBlackjackDoubleRefundCoversStake: doubling commits a second stake that +// the shutdown refund must return while the round is still in flight. +func TestBlackjackDoubleRefundCoversStake(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer: 17 + card{rank: "5"}, card{rank: "6"}, // alice: 11, doubles to 15 + card{rank: "10"}, card{rank: "6"}, // bob: 16, still to act + card{rank: "4"}, // alice's double draw: 15, stands + ) + walletA := &testWallet{credits: 100} + walletB := &testWallet{credits: 100} + table.join("alice") + table.join("bob") + table.bet("alice", "", 10, walletA) + table.bet("bob", "", 10, walletB) + table.action("alice", "double") + if walletA.credits != 80 { + t.Fatalf("setup: double did not commit a second stake (credits=%d)", walletA.credits) + } + // The round is still in flight (bob has not acted). + table.refundAll() + if got := walletA.credits + walletA.paid; got != 100 { + t.Fatalf("alice's double-then-refund returned %d of 100", got) + } + if got := walletB.credits + walletB.paid; got != 100 { + t.Fatalf("bob's refund returned %d of 100", got) + } +} + +// TestSlotsRefundOnlyOutstandingStake: a completed losing spin has no +// outstanding stake; refunding a seated player must not pay it again. An +// in-flight spin's stake is refunded exactly once. +func TestSlotsRefundOnlyOutstandingStake(t *testing.T) { + machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand()) + wallet := &testWallet{credits: 100} + machine.join("alice") + machine.setBet("alice", 10, wallet) + machine.spin = &SlotSpin{} // deterministic losing spin + for i := 0; i < 20 && machine.phase != machineReady; i++ { + machine.tick() + } + if machine.phase != machineReady || wallet.paid != 0 { + t.Fatal("setup: the spin must have ended without payout") + } + + // The completed spin's stake was resolved with the round: no refund. + machine.refundAll() + if wallet.paid != 0 { + t.Fatalf("completed losing spin refunded %d credits at shutdown; want 0", wallet.paid) + } + + // An in-flight spin's stake IS refunded. + wallet2 := &testWallet{credits: 100} + machine2 := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50}, newTestRand()) + machine2.join("alice") + machine2.setBet("alice", 10, wallet2) + machine2.refundAll() + if got := wallet2.credits + wallet2.paid; got != 100 { + t.Fatalf("in-flight spin refund returned %d of 100", got) + } +} + +// TestBlackjackFailedPayoutNotAnnouncedAsPaid: when the wallet cannot make +// the payout durable, the table must not tell the player they were paid. +func TestBlackjackFailedPayoutNotAnnouncedAsPaid(t *testing.T) { + table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand()) + table.shoe = tableauShoe( + card{rank: "10"}, card{rank: "7"}, // dealer: 17 + card{rank: "10"}, card{rank: "8"}, // alice: 18, stands, wins + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + table.bet("alice", "", 10, wallet) + events := table.action("alice", "stand") + for _, e := range events { + if strings.Contains(e.PrivateMessage, "You win! +") { + t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage) + } + if strings.Contains(e.PrivateMessage, "persistence failure") { + return // the failure was explained to the player + } + } + t.Fatal("failed payout was neither announced as paid (good) nor explained (bad)") +} + +// TestBaccaratFailedPayoutNotAnnouncedAsPaid: same rule for baccarat. +func TestBaccaratFailedPayoutNotAnnouncedAsPaid(t *testing.T) { + table := newBaccaratTable(1, TableConfig{ID: "bac", Game: GameBaccarat}, newTestRand()) + // Player 9 (natural), banker 0: no tableau draws, player wins. + table.shoe = tableauShoe( + card{rank: "9"}, card{rank: "K"}, // player: 9 + card{rank: "8"}, card{rank: "2"}, // banker: 0 + ) + wallet := &failingPayoutWallet{testWallet{credits: 100}} + table.join("alice") + // With a lone player, the bet starts and deals the round immediately. + events := table.bet("alice", "player", 10, wallet) + for _, e := range events { + if strings.Contains(e.PrivateMessage, "You win! +") { + t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage) + } + } + if got := wallet.credits + wallet.paid; got != 90 { + t.Fatalf("failed payout changed the balance: %d, want 90", got) + } +} + +// TestShoeReshufflesOnlyAtRoundBoundaries: crossing the shuffle threshold +// between individual draws must not replace the shoe mid-hand. +func TestShoeReshufflesOnlyAtRoundBoundaries(t *testing.T) { + shoe := newShoe(1, .25, newTestRand()) + for len(shoe.cards) > 14 { + shoe.draw() + } + shoe.draw() + shoe.draw() + if got := len(shoe.cards); got != 12 { + t.Fatalf("mid-hand draws reshuffled the shoe: %d cards remain, want 12", got) + } + + // At a round boundary the cut-card threshold applies. + shoe.shuffleIfDue() + if got := len(shoe.cards); got <= 12 { + t.Fatalf("shuffleIfDue did not reshuffle at the boundary (%d cards)", got) + } +} diff --git a/internal/casino/types.go b/internal/casino/types.go index 75d041b..d000375 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -171,6 +171,17 @@ type Wallet interface { // credit could not be persisted (the game must not report a win that // is not durable). PayCredits(amount int) error + // ResolveStakes marks every outstanding stake for this player as + // settled: the round resolved (won, lost, pushed, or forfeited), so + // startup recovery must no longer refund it. A stake whose wager + // record remains open at restart is refunded by recovery. + ResolveStakes() + // RefundOutstanding returns every outstanding stake to the player + // (chips as chips, credits through the durable payout path) and + // resolves the stakes. It reports the amounts returned. An error + // means the refund could not be made durable: the stakes remain open + // and startup recovery completes the refund. + RefundOutstanding() (chips, credits int, err error) } type EventType string diff --git a/internal/game/casino.go b/internal/game/casino.go index 3edb26b..8ba9097 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -20,10 +20,15 @@ type casinoWallet struct { // Wager debits the stake durably. Order of operations: durable journal // record FIRST (with balance checkpoints), then the in-memory mutation, -// then the character save, then the settlement marker. A failed save -// reverts the in-memory mutation and voids the record: no charge is kept -// in memory, and the error distinguishes persistence failure from -// insufficient funds. +// then the character save, then the record is registered as an OUTSTANDING +// STAKE. It is NOT settled here: it stays open until the round resolves +// (casino.Wallet.ResolveStakes) or is refunded (RefundOutstanding). An open +// record at startup recovery means the round state was lost in a crash and +// the stake is refunded — the documented refund-on-crash policy. +// +// A failed save reverts the in-memory mutation and voids the record: no +// charge is kept in memory, and the error distinguishes persistence failure +// from insufficient funds. func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if amount <= 0 { return casino.Wager{}, casino.ErrInsufficientFunds @@ -57,7 +62,9 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { w.g.Economy.void(rec) return casino.Wager{}, casino.ErrWalletPersistence } - w.g.Economy.settle(rec) + // The record stays open as an outstanding stake until the round + // resolves; recovery refunds open stakes after a crash. + w.g.Economy.registerStake(rec) return casino.Wager{Total: amount, Chips: chips, Credits: credits}, nil } @@ -90,6 +97,49 @@ func (w casinoWallet) PayCredits(amount int) error { return nil } +// ResolveStakes marks every outstanding stake for the player settled: the +// round resolved (won, lost, pushed, or forfeited), so startup recovery +// must no longer refund it. +func (w casinoWallet) ResolveStakes() { + w.g.Economy.ResolveStakes(w.p.Name) +} + +// RefundOutstanding returns every outstanding stake to the player: chips +// come back as chips, credits through the durable payout path. The stakes +// are resolved only once the return is durable; on failure the chip return +// is rolled back out of memory and the open records drive recovery instead. +func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { + for _, rec := range w.g.Economy.openStakes(w.p.Name) { + chips += rec.Chips + credits += rec.Credit + } + if chips <= 0 && credits <= 0 { + w.ResolveStakes() + return 0, 0, nil + } + if chips > 0 { + w.g.giveChips(w.p, chips) + } + if credits > 0 { + if err := w.PayCredits(credits); err != nil { + if chips > 0 { + // Roll the chip return back out of memory: the durable + // refund will re-apply both amounts from the open records. + w.p.RemoveItem("chips", chips) + } + return chips, credits, err + } + } else if chips > 0 { + // A chips-only refund needs its own durable save. + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + w.p.RemoveItem("chips", chips) + return chips, 0, casino.ErrWalletPersistence + } + } + w.ResolveStakes() + return chips, credits, nil +} + // casinoAffordable is the most a player can wager: chips are spent first, // then credits. func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) } diff --git a/internal/game/casino_forfeit_regression_test.go b/internal/game/casino_forfeit_regression_test.go new file mode 100644 index 0000000..9ad8016 --- /dev/null +++ b/internal/game/casino_forfeit_regression_test.go @@ -0,0 +1,61 @@ +package game + +// Disconnect-forfeit regressions: a player who disconnects mid-round +// forfeits the committed stake, so startup recovery must NOT refund it; a +// stake left outstanding by a genuine crash still is. + +import ( + "testing" + + "thehouseoficarus/internal/casino" +) + +// TestDisconnectForfeitsMachineStake: disconnecting mid-spin forfeits the +// stake (the machine frees the seat immediately); recovery must not refund +// it on the next startup. +func TestDisconnectForfeitsMachineStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, SpinTicks: 50}, "alice") + h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p}) + // The disconnect path forfeits the in-flight spin. + h.g.Casino.MarkDisconnected("alice") + }) + h.barrier() + + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 90 { + t.Fatalf("credits after forfeited spin = %d, want 90 (disconnect forfeits)", loaded.Credits) + } +} + +// TestDisconnectForfeitsBlackjackStake: same policy at a table: the seat +// frees up and the stake is not refunded by recovery. +func TestDisconnectForfeitsBlackjackStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + h.g.Casino.Join(1, casino.TableConfig{ID: "bj", Game: casino.GameBlackjack, MaxPlayers: 2}, "alice") + h.g.Casino.Bet(1, "bj", "alice", "", 10, casinoWallet{g: h.g, p: p}) + h.g.Casino.MarkDisconnected("alice") + }) + h.barrier() + + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 90 { + t.Fatalf("credits after forfeited hand = %d, want 90 (disconnect forfeits)", loaded.Credits) + } +} diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index a846f4d..fbd9e3a 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -2,6 +2,7 @@ package game import ( "fmt" + "log" "strings" "thehouseoficarus/internal/color" @@ -62,6 +63,19 @@ func (g *Game) handlePassword(sess *net.Session, input string) { sess.Write("Account name: ") return } + // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to + // Argon2id after a successful verification. A failed save keeps the + // legacy hash and retries on the next login. + if player.NeedsRehash(acc.PasswordHash) { + if hash, hashErr := player.HashPassword(input); hashErr == nil { + acc.PasswordHash = hash + if err := g.AccountStore.SaveAccount(acc); err != nil { + log.Printf("login: password rehash save for %s: %v", acc.Name, err) + } + } else { + log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) + } + } sess.Conn.SetEcho(true) sess.Account = &player.Account{ Name: acc.Name, diff --git a/internal/game/economy.go b/internal/game/economy.go index 90c24ed..fcc0010 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -2,20 +2,37 @@ package game // The economy journal: durable, idempotent money movement. Every casino // wager and payout writes a journal record BEFORE the balance mutation, -// then the character save makes the balance durable, then the record is -// settled. A crash at any point leaves either: +// then the character save makes the balance durable. +// +// Two kinds of records exist: +// +// - "wager": a committed stake. The record stays OPEN while the stake is +// outstanding (the round is in flight); the game resolves it with a +// settled marker when the round resolves (won, lost, pushed, or +// forfeited). At startup, open wager records are refunded — the round +// state was lost in the crash, so the stake returns to the player. +// - "payout": money movement (winnings, pushes, refunds). The record +// closes when the balance mutation is durable; an open payout at +// startup is owed money and is re-applied. +// +// A crash at any point therefore leaves either: // - an open record with the character file at its pre-transaction state // (crash before save) — recovery settles it as void (debit) or applies // it (payout, which the player is owed); or -// - an open record with the character file at its post-transaction state -// (crash after save) — recovery refunds open debit records (the round -// state was lost in the crash, so the stake returns) and voids payout -// records that are already durable. +// - an open wager with the file at its post-transaction state and no +// later payout evidence (the round state was lost in the crash) — the +// stake is refunded; or +// - an open payout (crash after the journal write, before the save) — +// the owed amount is re-applied. +// +// Records whose checkpoints match neither balance state after compensating +// earlier records are ambiguous: they are kept open and surfaced for +// operator inspection rather than silently settled or voided. // // The reducer folds journal lines by record ID (later lines win), so // settlement markers are idempotent appends. Recovery runs once at startup // before any session can join; compaction rewrites the journal keeping only -// open records (normally none). +// open records (outstanding stakes and unresolved payouts). import ( "bufio" @@ -48,12 +65,16 @@ type economyRecord struct { type economyJournal struct { path string + // stakes indexes outstanding wager records per player name. All + // journal mutations happen on the game owner (or during startup + // recovery before any session connects), so no lock is needed. + stakes map[string][]*economyRecord } func newEconomyJournal(dataDir string) *economyJournal { dir := filepath.Join(dataDir, "economy") _ = os.MkdirAll(dir, 0700) - return &economyJournal{path: filepath.Join(dir, "journal.jsonl")} + return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)} } func newEconomyRecordID() string { @@ -119,17 +140,72 @@ func (j *economyJournal) void(rec *economyRecord) { _ = j.append(*rec) } +// registerStake tracks an outstanding wager record for its player. Wager +// records stay open (refundable) until the round resolves. +func (j *economyJournal) registerStake(rec *economyRecord) { + j.stakes[rec.Player] = append(j.stakes[rec.Player], rec) +} + +// openStakes returns the outstanding stake records for a player. +func (j *economyJournal) openStakes(player string) []*economyRecord { + return j.stakes[player] +} + +// ResolveStakes closes every outstanding stake for a player with a settled +// marker per record: the round resolved, so recovery must not refund them. +func (j *economyJournal) ResolveStakes(player string) { + for _, rec := range j.stakes[player] { + rec.Status = "settled" + _ = j.append(*rec) + } + delete(j.stakes, player) +} + +// reloadStakes rebuilds the outstanding-stake index from the journal file: +// open wager records remain tracked (and refundable/resolvable) after +// recovery compaction. +func (j *economyJournal) reloadStakes() { + j.stakes = make(map[string][]*economyRecord) + for _, fr := range j.loadFolded() { + if fr.rec.Kind == "wager" && fr.rec.Status == "open" { + rec := fr.rec + j.registerStake(&rec) + } + } +} + // loadOpen folds the journal and returns the still-open records. func (j *economyJournal) loadOpen() []*economyRecord { + var open []*economyRecord + for _, fr := range j.loadFolded() { + if fr.rec.Status == "open" { + rec := fr.rec + open = append(open, &rec) + } + } + return open +} + +// foldedRecord is the latest state of one record ID together with the +// order (line position of its latest write) it occupied in the journal. +type foldedRecord struct { + rec economyRecord + seq int +} + +// loadFolded folds the journal (latest line per record ID wins) and +// returns the surviving records in journal append order. +func (j *economyJournal) loadFolded() []foldedRecord { f, err := os.Open(j.path) if err != nil { return nil } defer f.Close() - latest := map[string]*economyRecord{} + latest := map[string]*foldedRecord{} + var order []*foldedRecord scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { + for seq := 0; scanner.Scan(); seq++ { line := scanner.Bytes() if len(line) == 0 { continue @@ -141,15 +217,20 @@ func (j *economyJournal) loadOpen() []*economyRecord { // line can be torn. continue } - latest[rec.ID] = &rec - } - var open []*economyRecord - for _, rec := range latest { - if rec.Status == "open" { - open = append(open, rec) + if existing, ok := latest[rec.ID]; ok { + existing.rec = rec + existing.seq = seq + } else { + fr := &foldedRecord{rec: rec, seq: seq} + latest[rec.ID] = fr + order = append(order, fr) } } - return open + folded := make([]foldedRecord, 0, len(order)) + for _, fr := range order { + folded = append(folded, *fr) + } + return folded } // compact rewrites the journal keeping only still-open records. @@ -230,32 +311,112 @@ func addCreditsChecked(a, b int) (int, error) { // recoverEconomy compensates open records from the last crash. It is called // once from New, before any session connects. +// +// Recovery runs in two phases so checkpoints stay meaningful: +// +// 1. Open PAYOUT records are processed in append order against the evolving +// durable balance: file at the pre-payout balance → the payout is +// re-applied (the player is owed it); at the post-payout balance → it is +// already durable and settles; anything else stays open and is surfaced +// for operator inspection (a liability is never silently dropped). +// 2. Open WAGER records are processed NEWEST FIRST (each refund restores +// the balance an earlier record's checkpoints refer to). A record at +// its pre-debit state (credits AND chips) never became durable → void; +// at its post-debit state → the stake was charged but the round state +// was lost in the crash → the stake is refunded, unless a payout record +// written later proves the round resolved (a win/push/refund already +// returned the stake inside that payout) → settled with no refund. +// Anything else stays open for operator inspection. func (g *Game) recoverEconomy() { if g.Economy == nil { return } - records := g.Economy.loadOpen() - compensated := 0 - for _, rec := range records { - p, err := g.AccountStore.LoadCharacter(rec.Player) + folded := g.Economy.loadFolded() + players := make(map[string]*player.Player) + loadPlayer := func(name string) *player.Player { + if p, ok := players[name]; ok { + return p + } + p, err := g.AccountStore.LoadCharacter(name) if err != nil { // Character file unreadable: leave the record open so a later // attempt (or operator inspection) can resolve it; do not // silently drop money. - fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", rec.Player, err) + fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", name, err) + return nil + } + players[name] = p + return p + } + hasLaterPayout := func(name string, seq int) bool { + for _, fr := range folded { + if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { + return true + } + } + return false + } + compensated := 0 + + // Phase 1 — payouts, in append order. + for i := range folded { + rec := &folded[i].rec + if rec.Kind != "payout" || rec.Status != "open" { + continue + } + p := loadPlayer(rec.Player) + if p == nil { + continue + } + switch { + case p.Credits == rec.CreditsBefore: + // The payout was lost in the crash: the player is owed it. + if err := g.payCreditsDurable(p, rec.Credit); err != nil { + fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + continue + } + g.Economy.settle(rec) + compensated++ + case p.Credits == rec.CreditsAfter: + g.Economy.settle(rec) + default: + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", + rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) + } + } + + // Phase 2 — outstanding wagers, newest first. + var openWagers []int + for i := range folded { + if folded[i].rec.Kind == "wager" && folded[i].rec.Status == "open" { + openWagers = append(openWagers, i) + } + } + for k := len(openWagers) - 1; k >= 0; k-- { + rec := &folded[openWagers[k]].rec + resolvedByPayout := hasLaterPayout(rec.Player, folded[openWagers[k]].seq) + p := loadPlayer(rec.Player) + if p == nil { continue } + creditsNow, chipsNow := p.Credits, countChips(p) switch { - case rec.Kind == "wager" && p.Credits == rec.CreditsBefore: + case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore: // The debit never became durable: void it. g.Economy.void(rec) - case rec.Kind == "wager" && p.Credits == rec.CreditsAfter: + case creditsNow == rec.CreditsAfter && chipsNow == rec.ChipsAfter: + if resolvedByPayout { + // A later payout record proves the round resolved (the win, + // push, or refund already returned this stake inside that + // payout). Only the resolution marker was lost. + g.Economy.settle(rec) + continue + } // The debit is durable but the round was lost in the crash: // refund the stake (approved refund-on-recovery policy). - chips, credits := rec.Chips, rec.Credit - p.Credits += credits - if chips > 0 { - g.giveChips(p, chips) + p.Credits += rec.Credit + if rec.Chips > 0 { + g.giveChips(p, rec.Chips) } if err := g.AccountStore.SaveCharacter(p); err != nil { fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) @@ -263,27 +424,17 @@ func (g *Game) recoverEconomy() { } g.Economy.settle(rec) compensated++ - case rec.Kind == "wager": - // Balance moved past the recorded checkpoint: cannot compensate - // reliably; surface for operator inspection. - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d)\n", - rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - g.Economy.void(rec) - case rec.Kind == "payout" && p.Credits == rec.CreditsBefore: - // The payout was lost in the crash: the player is owed it. - if err := g.payCreditsDurable(p, rec.Credit); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) + default: + if resolvedByPayout { + g.Economy.settle(rec) continue } - g.Economy.settle(rec) - compensated++ - case rec.Kind == "payout" && p.Credits == rec.CreditsAfter: - g.Economy.settle(rec) - case rec.Kind == "payout": - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d)\n", + fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - g.Economy.settle(rec) } } g.Economy.compact() + // Rebuild the outstanding-stake index from whatever open wager records + // remain (normally none; ambiguous ones stay refundable/resolvable). + g.Economy.reloadStakes() } diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go new file mode 100644 index 0000000..e085e67 --- /dev/null +++ b/internal/game/economy_recovery_regression_test.go @@ -0,0 +1,169 @@ +package game + +// Economy recovery regressions for the outstanding-stake ledger: a crash +// with a round in flight refunds the stake, chip stakes recover in chips, +// a failed payout is re-applied or preserved for operator inspection, and +// a resolved round (payout evidence) is never refunded twice. + +import ( + "testing" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +// TestRecoveryRefundsInFlightMachineWager: a wager debited durably with the +// round still in flight (no graceful shutdown, no resolution) is refunded by +// startup recovery — the documented refund-on-crash policy. +func TestRecoveryRefundsInFlightMachineWager(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice") + h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p}) + }) + h.barrier() + + // A fresh Game over the saved files simulates losing all live round + // state in a crash. The stake record is open, so recovery refunds it. + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("crash with an active 10-credit spin: recovered %d credits, want 100", loaded.Credits) + } + if open := restarted.g.Economy.openStakes("alice"); len(open) != 0 { + t.Fatalf("refunded stake left open: %+v", open) + } +} + +// TestRecoveryRefundsChipOnlyWager: a chip-only debit must be recognized by +// comparing chip balances, not credits alone (the credit checkpoints are +// identical before and after a chip-only wager). +func TestRecoveryRefundsChipOnlyWager(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 20}) + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + // Simulate the crash window: the chip debit is durable in the + // character file but the stake record never resolved. + p.RemoveItem("chips", 10) + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 20, 10); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if got := countChips(loaded); got != 20 { + t.Fatalf("chip stake after recovery = %d, want 20 (refunded in chips)", got) + } +} + +// TestRecoveryReappliesLostPayoutWithEvidence: a won hand whose payout +// failed to save leaves an open payout record AND an open stake record. +// Recovery re-applies the payout (which includes the stake return) and +// settles the stake from that evidence — exactly once, no double refund. +func TestRecoveryReappliesLostPayoutWithEvidence(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 90 // post-debit balance: the 10-credit stake was charged + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + // Open stake for the lost round (before=100, after=90). + if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { + t.Error(err) + } + // Open payout for the won hand (2x bet = 20, before=90, after=110). + if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 110 { + t.Fatalf("balance after recovery = %d, want 110 (payout re-applied once, no double stake refund)", loaded.Credits) + } + if open := h.g.Economy.loadOpen(); len(open) != 0 { + t.Fatalf("records left open after recovery: %+v", open) + } +} + +// TestRecoveryRefundsSequentialStakes: a bet plus a double (two open stake +// records, newest processed first) both refund after a crash. +func TestRecoveryRefundsSequentialStakes(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 80 // bet 10 then doubled: 100 -> 90 -> 80 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("wager", p, 0, 10, 90, 80, 0, 0); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("balance after recovery = %d, want 100 (both stakes refunded)", loaded.Credits) + } +} + +// TestRecoveryPreservesAmbiguousPayout: a payout whose checkpoints match +// neither balance state stays open and is surfaced for operator inspection — +// a liability is never silently dropped. +func TestRecoveryPreservesAmbiguousPayout(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + // A payout owed at balance 100 failed to save; the player then + // spent 5 credits elsewhere before the crash. + p.Credits = 95 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + } + if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { + t.Error(err) + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 95 { + t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits) + } + if open := h.g.Economy.loadOpen(); len(open) != 1 { + t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open) + } +} diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go index bd6c253..a3e6adb 100644 --- a/internal/game/economy_shutdown_test.go +++ b/internal/game/economy_shutdown_test.go @@ -101,9 +101,19 @@ func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) { if p.Credits != 90 { t.Fatalf("credits after wager = %d, want 90", p.Credits) } - // The journal must show the record settled (debit is durable). - if open := h.g.Economy.loadOpen(); len(open) != 0 { - t.Fatalf("open records remain after a successful wager: %v", open) + // The debit is durable, and the record stays OPEN: the stake is + // outstanding until the round resolves (recovery refunds open stakes + // after a crash). + if open := h.g.Economy.openStakes("alice"); len(open) != 1 || open[0].Credit != 10 { + t.Fatalf("outstanding stake after a successful wager: %+v", open) + } + // Resolution (round resolved or refunded) closes it. + h.g.RunOnOwner(func() { + w.ResolveStakes() + }) + h.barrier() + if open := h.g.Economy.openStakes("alice"); len(open) != 0 { + t.Fatalf("open stakes remain after resolution: %v", open) } } diff --git a/internal/game/owner.go b/internal/game/owner.go index df87653..2fb4777 100644 --- a/internal/game/owner.go +++ b/internal/game/owner.go @@ -23,6 +23,7 @@ import "sync" // order relative to subscriber registration IDs. type owner struct { mu sync.Mutex + cond *sync.Cond closed bool tasks chan func() done chan struct{} @@ -35,6 +36,7 @@ func newOwner() *owner { tasks: make(chan func(), ownerTaskQueue), done: make(chan struct{}), } + o.cond = sync.NewCond(&o.mu) go o.loop() return o } @@ -43,12 +45,15 @@ func (o *owner) loop() { defer close(o.done) for task := range o.tasks { task() + // A queue slot freed up; wake one blocked submitter waiting for + // room (see enqueue). + o.cond.Signal() } } -// submit enqueues a task; it returns false once the owner has stopped. -// The closed flag is guarded by the mutex so a submit racing with stop -// cannot send on a closed channel. +// submit enqueues without blocking. It returns false when the owner has +// stopped or the queue is full; callers that need guaranteed delivery use +// enqueue instead. func (o *owner) submit(fn func()) bool { o.mu.Lock() defer o.mu.Unlock() @@ -63,11 +68,31 @@ func (o *owner) submit(fn func()) bool { } } +// enqueue submits with backpressure: it blocks while the queue is full and +// returns false only when the owner has stopped. A full queue must NEVER +// bypass the owner: running a task on the caller's goroutine while the +// owner keeps executing other tasks breaks the single-owner invariant that +// serializes all live state mutation. +func (o *owner) enqueue(fn func()) bool { + o.mu.Lock() + defer o.mu.Unlock() + for !o.closed { + select { + case o.tasks <- fn: + return true + default: + o.cond.Wait() + } + } + return false +} + func (o *owner) stop() { o.mu.Lock() if !o.closed { o.closed = true close(o.tasks) + o.cond.Broadcast() } o.mu.Unlock() <-o.done @@ -83,13 +108,14 @@ func (g *Game) run(fn func()) { return } task := make(chan struct{}) - if !o.submit(func() { + if !o.enqueue(func() { fn() close(task) }) { // The owner loop has stopped (graceful shutdown in progress). // Admissions are already closed, so only stragglers reach this: - // execute inline rather than block forever. + // execute inline rather than block forever. A full queue never + // takes this path: enqueue applies backpressure instead. fn() return } diff --git a/internal/game/owner_regression_test.go b/internal/game/owner_regression_test.go new file mode 100644 index 0000000..1ccd25f --- /dev/null +++ b/internal/game/owner_regression_test.go @@ -0,0 +1,68 @@ +package game + +// Single-owner serialization regressions: a full task queue applies +// backpressure instead of bypassing the owner, and the owner-stop path +// keeps its documented inline fallback for shutdown stragglers only. + +import ( + "sync" + "testing" + "time" +) + +// TestFullOwnerQueueAppliesBackpressure: when the owner's task queue is +// full and the owner is blocked inside another task, run() must NOT execute +// the new task inline on the caller's goroutine — that would mutate live +// state concurrently with the owner. It blocks until the owner drains. +func TestFullOwnerQueueAppliesBackpressure(t *testing.T) { + g := &Game{owner: newOwner()} + release := make(chan struct{}) + started := make(chan struct{}) + g.owner.submit(func() { + close(started) + <-release + }) + <-started + var once sync.Once + t.Cleanup(func() { + once.Do(func() { close(release) }) + g.stopOwner() + }) + + for i := 0; i < ownerTaskQueue; i++ { + if !g.owner.submit(func() {}) { + t.Fatal("setup: could not fill the owner queue") + } + } + + ran := make(chan struct{}) + go g.run(func() { close(ran) }) + select { + case <-ran: + t.Fatal("task executed inline while the owner was still running other tasks") + case <-time.After(100 * time.Millisecond): + } + + // Release the owner: the queued work drains and the blocked task runs. + once.Do(func() { close(release) }) + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("task never ran after the owner drained its queue") + } +} + +// TestStoppedOwnerRunsStragglersInline: after the owner stops (graceful +// shutdown), straggler submissions execute inline rather than blocking +// forever — the documented admissions-closed behavior. +func TestStoppedOwnerRunsStragglersInline(t *testing.T) { + g := &Game{owner: newOwner()} + g.stopOwner() + ran := make(chan struct{}) + go g.run(func() { close(ran) }) + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("straggler task blocked forever after the owner stopped") + } +} diff --git a/internal/player/password.go b/internal/player/password.go index 17e4218..f8ce3cc 100644 --- a/internal/player/password.go +++ b/internal/player/password.go @@ -3,30 +3,87 @@ package player import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/base64" + "encoding/hex" "fmt" + "strconv" "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters for password hashing. Memory cost dominates the +// GPU/ASIC attack economics; 64 MiB per hash is affordable for a MUD's +// login rate while making brute force expensive. Stored format: +// +// argon2id: