aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_rooms.go12
-rw-r--r--internal/admin/api_rooms_bulk.go146
-rw-r--r--internal/admin/bulk_edit_regression_test.go125
-rw-r--r--internal/admin/room_rename_state_test.go68
-rw-r--r--internal/admin/server.go29
-rw-r--r--internal/casino/baccarat.go64
-rw-r--r--internal/casino/blackjack.go61
-rw-r--r--internal/casino/cards.go16
-rw-r--r--internal/casino/credits.go7
-rw-r--r--internal/casino/machine.go56
-rw-r--r--internal/casino/manager_test.go25
-rw-r--r--internal/casino/money_regression_test.go177
-rw-r--r--internal/casino/types.go11
-rw-r--r--internal/game/casino.go60
-rw-r--r--internal/game/casino_forfeit_regression_test.go61
-rw-r--r--internal/game/core_login_account.go14
-rw-r--r--internal/game/economy.go241
-rw-r--r--internal/game/economy_recovery_regression_test.go169
-rw-r--r--internal/game/economy_shutdown_test.go16
-rw-r--r--internal/game/owner.go36
-rw-r--r--internal/game/owner_regression_test.go68
-rw-r--r--internal/player/password.go77
-rw-r--r--internal/player/password_test.go60
23 files changed, 1390 insertions, 209 deletions
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:<time>:<memoryKiB>:<threads>:<b64salt>:<hexkey>
+//
+// Legacy accounts may still carry the old single-pass salted SHA-256 hash
+// ("sha256:<b64salt>:<hexhash>"); CheckPassword verifies both, and the
+// login paths rehash to Argon2id on successful verification (see
+// NeedsRehash).
+const (
+ argonTime = 1
+ argonMemory = 64 * 1024 // KiB
+ argonThreads = 2
+ argonKeyLen = 32
+ argonSaltLen = 16
)
+// HashPassword derives an Argon2id password hash.
func HashPassword(password string) (string, error) {
- salt := make([]byte, 16)
+ salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
- hash := sha256.Sum256(append(salt, []byte(password)...))
- return fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), hash), nil
+ key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
+ return fmt.Sprintf("argon2id:%d:%d:%d:%s:%s",
+ argonTime, argonMemory, argonThreads,
+ base64.RawStdEncoding.EncodeToString(salt), hex.EncodeToString(key)), nil
}
+// CheckPassword verifies a password against a stored hash in either the
+// current Argon2id format or the legacy salted SHA-256 format.
func CheckPassword(password, stored string) bool {
- parts := strings.SplitN(stored, ":", 3)
- if len(parts) != 3 || parts[0] != "sha256" {
+ parts := strings.SplitN(stored, ":", 6)
+ if len(parts) == 0 {
return false
}
- salt, err := base64.RawStdEncoding.DecodeString(parts[1])
- if err != nil {
+ switch parts[0] {
+ case "argon2id":
+ if len(parts) != 6 {
+ return false
+ }
+ t, errT := strconv.Atoi(parts[1])
+ m, errM := strconv.Atoi(parts[2])
+ p, errP := strconv.Atoi(parts[3])
+ salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4])
+ expected, errKey := hex.DecodeString(parts[5])
+ if errT != nil || errM != nil || errP != nil || errSalt != nil || errKey != nil {
+ return false
+ }
+ key := argon2.IDKey([]byte(password), salt, uint32(t), uint32(m), uint8(p), uint32(len(expected)))
+ return subtle.ConstantTimeCompare(key, expected) == 1
+ case "sha256":
+ legacy := strings.SplitN(stored, ":", 3)
+ if len(legacy) != 3 {
+ return false
+ }
+ salt, err := base64.RawStdEncoding.DecodeString(legacy[1])
+ if err != nil {
+ return false
+ }
+ hash := sha256.Sum256(append(salt, []byte(password)...))
+ return legacy[2] == fmt.Sprintf("%x", hash)
+ default:
return false
}
- hash := sha256.Sum256(append(salt, []byte(password)...))
- expected := fmt.Sprintf("%x", hash)
- return parts[2] == expected
+}
+
+// NeedsRehash reports whether a stored hash uses the legacy single-pass
+// SHA-256 scheme. Callers rehash with HashPassword after a successful
+// verification and persist the new hash (transparent upgrade on login).
+func NeedsRehash(stored string) bool {
+ return strings.HasPrefix(stored, "sha256:")
}
diff --git a/internal/player/password_test.go b/internal/player/password_test.go
new file mode 100644
index 0000000..82b580b
--- /dev/null
+++ b/internal/player/password_test.go
@@ -0,0 +1,60 @@
+package player
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestHashPasswordUsesArgon2idAndVerifies(t *testing.T) {
+ hash, err := HashPassword("secret password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(hash, "argon2id:") {
+ t.Fatalf("hash format = %q, want argon2id", hash)
+ }
+ if !CheckPassword("secret password", hash) {
+ t.Fatal("correct password rejected")
+ }
+ if CheckPassword("wrong password", hash) {
+ t.Fatal("wrong password accepted")
+ }
+ // Salts must be unique per call: two hashes of the same password differ.
+ other, _ := HashPassword("secret password")
+ if other == hash {
+ t.Fatal("two hashes of the same password are identical (salt reuse)")
+ }
+}
+
+func TestCheckPasswordVerifiesLegacySHA256(t *testing.T) {
+ salt := make([]byte, 16)
+ for i := range salt {
+ salt[i] = byte(i)
+ }
+ sum := sha256.Sum256(append(salt, []byte("legacy pw")...))
+ legacy := fmt.Sprintf("sha256:%s:%x", base64.RawStdEncoding.EncodeToString(salt), sum)
+
+ if !CheckPassword("legacy pw", legacy) {
+ t.Fatal("correct legacy password rejected")
+ }
+ if CheckPassword("wrong", legacy) {
+ t.Fatal("wrong legacy password accepted")
+ }
+ if !NeedsRehash(legacy) {
+ t.Fatal("legacy hash not flagged for rehash")
+ }
+ if NeedsRehash("argon2id:1:65536:2:c2FsdA:x") {
+ t.Fatal("argon2id hash flagged for rehash")
+ }
+}
+
+func TestCheckPasswordRejectsGarbage(t *testing.T) {
+ for _, stored := range []string{"", "plain", "argon2id:1:2:3:no-salt:zz", "unknown:v1:salt:deadbeef"} {
+ if CheckPassword("anything", stored) {
+ t.Fatalf("garbage hash accepted: %q", stored)
+ }
+ }
+}