aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 02:14:02 -0400
committerhistoria <[not public]>2026-09-16 02:14:02 -0400
commitad60843fbf577eda80105e3916822d663aaefcb3 (patch)
tree68092a6403fdb2992ef92cfe0e2dd62b479935aa /internal
parent61881fd0c1a7066293f552be1a747143f70d03ff (diff)
downloadthehouseoficarus-ad60843fbf577eda80105e3916822d663aaefcb3.tar.gz
fix: economy recovery double-refund/erased-liability/cap-drops, entity editor path traversal
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_drops.go8
-rw-r--r--internal/admin/api_entity_path_test.go62
-rw-r--r--internal/admin/api_hazards.go8
-rw-r--r--internal/admin/api_helpers.go15
-rw-r--r--internal/admin/api_items.go8
-rw-r--r--internal/admin/api_mobs.go8
-rw-r--r--internal/admin/api_modules.go4
-rw-r--r--internal/admin/api_objects.go8
-rw-r--r--internal/admin/api_room_courses.go8
-rw-r--r--internal/admin/api_techs.go4
-rw-r--r--internal/admin/api_triggers.go8
-rw-r--r--internal/admin/server.go3
-rw-r--r--internal/admin/yaml_util.go6
-rw-r--r--internal/game/casino.go13
-rw-r--r--internal/game/economy.go49
-rw-r--r--internal/game/economy_recovery_regression_test.go118
16 files changed, 310 insertions, 20 deletions
diff --git a/internal/admin/api_drops.go b/internal/admin/api_drops.go
index 0d14841..3724a6c 100644
--- a/internal/admin/api_drops.go
+++ b/internal/admin/api_drops.go
@@ -37,6 +37,10 @@ func (s *AdminServer) handleDropByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid drop id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
@@ -142,6 +146,10 @@ func (s *AdminServer) createDrop(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid drop id"}`, http.StatusBadRequest)
+ return
+ }
path := filepath.Join(s.dataDir, "drops", id+".yaml")
if _, err := os.Stat(path); err == nil {
diff --git a/internal/admin/api_entity_path_test.go b/internal/admin/api_entity_path_test.go
new file mode 100644
index 0000000..7abf65f
--- /dev/null
+++ b/internal/admin/api_entity_path_test.go
@@ -0,0 +1,62 @@
+package admin
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/config"
+ "thehouseoficarus/internal/player"
+)
+
+// TestEntityAPICannotEscapeData: entity create/rename IDs and directory
+// targets join into file paths, so a traversal ID ("../../x") must be
+// rejected instead of writing outside the data directory.
+func TestEntityAPICannotEscapeData(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "data")
+ for _, sub := range []string{"objects", "players/accounts"} {
+ if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ accounts := player.NewAccountStore(dir)
+ if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true}); err != nil {
+ t.Fatal(err)
+ }
+ s, err := NewServer(config.Default(), false, accounts, nil, nil, nil, nil, dir,
+ nil, nil, nil, nil, filepath.Join(dir, "config.yaml"),
+ func(fn func()) bool { fn(); return true })
+ if err != nil {
+ t.Fatal(err)
+ }
+ cookieRec := httptest.NewRecorder()
+ s.setAuthCookie(cookieRec, "auditor")
+ cookie := cookieRec.Result().Cookies()[0]
+
+ post := func(path, body string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.AddCookie(cookie)
+ rec := httptest.NewRecorder()
+ s.ServeHTTP(rec, req)
+ return rec
+ }
+
+ // Traversal entity ID via the structured create route.
+ if rec := post("/api/objects", `{"id":"../../outside","name":"Escaped object"}`); rec.Code != http.StatusBadRequest {
+ t.Fatalf("traversal object id: status %d body %s", rec.Code, rec.Body.String())
+ }
+ // Traversal new ID via the entity rename route: the rename handler
+ // must reject the request, not rename anything.
+ rec := post("/api/objects/rename", `{"id":"test","new_id":"../../outside"}`)
+ if !strings.Contains(rec.Body.String(), "error") {
+ t.Fatalf("traversal rename id not rejected: status %d body %s", rec.Code, rec.Body.String())
+ }
+ // Nothing may exist outside the data directory.
+ if _, err := os.Stat(filepath.Join(root, "outside.yaml")); !os.IsNotExist(err) {
+ t.Fatal("entity API escaped the data directory")
+ }
+}
diff --git a/internal/admin/api_hazards.go b/internal/admin/api_hazards.go
index 405c9ab..fac68c4 100644
--- a/internal/admin/api_hazards.go
+++ b/internal/admin/api_hazards.go
@@ -35,6 +35,10 @@ func (s *AdminServer) handleHazardByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid hazard id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
@@ -140,6 +144,10 @@ func (s *AdminServer) createHazard(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid hazard id"}`, http.StatusBadRequest)
+ return
+ }
path := filepath.Join(s.dataDir, "hazards", id+".yaml")
if _, err := os.Stat(path); err == nil {
diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go
index 3224598..11ad958 100644
--- a/internal/admin/api_helpers.go
+++ b/internal/admin/api_helpers.go
@@ -4,10 +4,25 @@ import (
"bytes"
"fmt"
"os"
+ "strings"
"gopkg.in/yaml.v3"
)
+// validEntityID reports whether an entity ID can be safely joined into a
+// file path under its data directory. IDs come from URL path segments and
+// JSON bodies, so an unchecked join could otherwise escape the data
+// directory ("../") or touch dot files.
+func validEntityID(id string) bool {
+ if id == "" || id == "." || id == ".." {
+ return false
+ }
+ if strings.ContainsAny(id, `/\`) || strings.ContainsRune(id, 0) {
+ return false
+ }
+ return true
+}
+
func yamlToMap(data []byte) (map[string]any, error) {
var v any
if err := yaml.Unmarshal(data, &v); err != nil {
diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go
index 728997a..749e8ff 100644
--- a/internal/admin/api_items.go
+++ b/internal/admin/api_items.go
@@ -33,6 +33,10 @@ func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid item id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
@@ -152,6 +156,10 @@ func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid item id"}`, http.StatusBadRequest)
+ return
+ }
if _, _, err := s.findItemFile(id); err == nil {
http.Error(w, `{"error":"item already exists"}`, http.StatusConflict)
diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go
index 0646753..8531e40 100644
--- a/internal/admin/api_mobs.go
+++ b/internal/admin/api_mobs.go
@@ -35,6 +35,10 @@ func (s *AdminServer) handleMobByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid mob id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
@@ -143,6 +147,10 @@ func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid mob id"}`, http.StatusBadRequest)
+ return
+ }
if _, _, err := s.findMobFile(id); err == nil {
http.Error(w, `{"error":"mob already exists"}`, http.StatusConflict)
return
diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go
index e468c79..074eeea 100644
--- a/internal/admin/api_modules.go
+++ b/internal/admin/api_modules.go
@@ -59,6 +59,10 @@ func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go
index 340c87c..8f9eac5 100644
--- a/internal/admin/api_objects.go
+++ b/internal/admin/api_objects.go
@@ -33,6 +33,10 @@ func (s *AdminServer) handleObjectByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid object id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
@@ -142,6 +146,10 @@ func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid object id"}`, http.StatusBadRequest)
+ return
+ }
if _, _, err := s.findObjectFile(id); err == nil {
http.Error(w, `{"error":"object already exists"}`, http.StatusConflict)
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index 097b9e8..47e4d8c 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -153,6 +153,10 @@ func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "missing id"})
return
}
+ if !validEntityID(id) {
+ writeJSON(w, map[string]any{"error": "invalid id"})
+ return
+ }
delete(body, "id")
path := s.courseFilePath(id)
if _, err := os.Stat(path); err == nil {
@@ -186,6 +190,10 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "missing id"})
return
}
+ if !validEntityID(id) {
+ writeJSON(w, map[string]any{"error": "invalid id"})
+ return
+ }
path := s.courseFilePath(id)
switch r.Method {
diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go
index 293b1db..ba3288a 100644
--- a/internal/admin/api_techs.go
+++ b/internal/admin/api_techs.go
@@ -67,6 +67,10 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go
index 749f5a1..87eb82e 100644
--- a/internal/admin/api_triggers.go
+++ b/internal/admin/api_triggers.go
@@ -40,6 +40,10 @@ func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"error": "missing id"})
return
}
+ if !validEntityID(id) {
+ writeJSON(w, map[string]any{"error": "invalid id"})
+ return
+ }
delete(m, "id")
kind := triggerKind(m)
@@ -74,6 +78,10 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request)
http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
return
}
+ if !validEntityID(id) {
+ http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
+ return
+ }
switch r.Method {
case http.MethodGet:
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 3dbe8ad..fb0c53b 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -622,7 +622,8 @@ func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc {
ID string `json:"id"`
NewID string `json:"new_id"`
}
- if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" {
+ if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" ||
+ !validEntityID(body.NewID) {
writeJSON(w, map[string]any{"error": "invalid"})
return
}
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index 7157f15..9ef8058 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -182,6 +182,9 @@ func findEntityPath(dataDir, subdir, id string) (string, error) {
}
func moveEntityFile(dataDir, subdir, id, targetDir string) (string, string, []byte, error) {
+ if targetDir != "" && !validEntityID(targetDir) {
+ return "", "", nil, fmt.Errorf("invalid directory: %q", targetDir)
+ }
oldPath, err := findEntityPath(dataDir, subdir, id)
if err != nil {
return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, id)
@@ -228,6 +231,9 @@ func createEntityDir(dataDir, subdir, name string) error {
}
func renameEntityFile(dataDir, subdir, oldID, newID string) (string, string, []byte, error) {
+ if !validEntityID(newID) {
+ return "", "", nil, fmt.Errorf("invalid new id: %q", newID)
+ }
oldPath, err := findEntityPath(dataDir, subdir, oldID)
if err != nil {
return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, oldID)
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 3f06b41..bc1e690 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -82,15 +82,20 @@ func (w casinoWallet) PayCredits(amount int) error {
}
creditsBefore := w.p.Credits
chipsBefore := countChips(w.p)
- creditsAfter, err := addCreditsChecked(creditsBefore, amount)
- if err != nil {
- return err
- }
+ creditsAfter, capErr := addCreditsChecked(creditsBefore, amount)
+ // The journal record is written FIRST, even when the economy cap
+ // blocks the payout: a won settlement must never be silently dropped.
+ // With the cap blocking, the balance is unchanged and the open record
+ // keeps the owed amount durable; startup recovery re-applies it once
+ // the balance has room below the cap.
rec, err := w.g.Economy.start("payout", w.p, 0, amount,
creditsBefore, creditsAfter, chipsBefore, chipsBefore)
if err != nil {
return casino.ErrWalletPersistence
}
+ if capErr != nil {
+ return capErr
+ }
w.p.Credits = creditsAfter
if err := w.g.AccountStore.SaveCharacter(w.p); err != nil {
w.p.Credits = creditsBefore
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 04508be..afdcdd8 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -353,13 +353,21 @@ func (g *Game) recoverEconomy() {
players[name] = p
return p
}
- hasLaterPayout := func(name string, seq int) bool {
+ // laterPayoutEvidence reports whether any non-void payout record was
+ // journaled for the player after seq — evidence the round resolved and
+ // its settlement was recorded — together with the total credits those
+ // records promise. A payout covering the stake's full value returned
+ // the stake inside the payout (win, push, or refund); a smaller total
+ // (e.g. the credit side of a crashed mixed refund) leaves the chip
+ // side of the stake outstanding.
+ laterPayoutEvidence := func(name string, seq int) (resolved bool, credits int) {
for _, fr := range folded {
if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq {
- return true
+ resolved = true
+ credits += fr.rec.Credit
}
}
- return false
+ return resolved, credits
}
compensated := 0
@@ -413,13 +421,23 @@ func (g *Game) recoverEconomy() {
paid++
compensated++
}
- for _, other := range group {
- if paid == 0 {
- break
+ for idx, other := range group {
+ if idx < paid {
+ g.Economy.settle(other)
+ settled[other.ID] = true
+ continue
}
- g.Economy.settle(other)
- settled[other.ID] = true
- paid--
+ // An unpaid sibling (the save failed, or the credit cap
+ // blocked re-application) stays owed. Re-anchor its
+ // checkpoints to the current durable balance so a later
+ // recovery re-applies it against a balance it can actually
+ // be paid from — without this, the balance a PAID sibling
+ // moved reads as this record's own durability and the
+ // liability is silently settled instead of paid.
+ other.CreditsBefore = p.Credits
+ other.CreditsAfter = p.Credits + other.Credit
+ settled[other.ID] = true // handled for the rest of this run
+ _ = g.Economy.append(*other)
}
case p.Credits == rec.CreditsAfter && len(group) == 1:
g.Economy.settle(rec)
@@ -439,7 +457,7 @@ func (g *Game) recoverEconomy() {
}
for k := len(openWagers) - 1; k >= 0; k-- {
rec := &folded[openWagers[k]].rec
- resolvedByPayout := hasLaterPayout(rec.Player, folded[openWagers[k]].seq)
+ resolvedByPayout, laterPayoutCredits := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq)
p := loadPlayer(rec.Player)
if p == nil {
continue
@@ -449,11 +467,12 @@ func (g *Game) recoverEconomy() {
creditsNow, chipsNow := p.Credits, countChips(p)
// Chips are recovered independently of the credit checkpoints. Any
// state where the chip debit is durable but the chips have not come
- // back (post-debit chip balance) still owes the chips, even when a
- // later payout record already resolves the credit side. A durable
- // chip refund always restores the balance to ChipsBefore, so this
- // cannot double-return.
- chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter
+ // back (post-debit chip balance) still owes the chips — unless the
+ // resolving payout already returned the stake's full value in
+ // credits (a win, push, or refund includes the stake): returning
+ // the chips in kind on top would double-refund the round.
+ chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter &&
+ (!resolvedByPayout || laterPayoutCredits < rec.Chips+rec.Credit)
if chipsOwed && !g.giveChips(p, rec.Chips) {
// No inventory room for the chip return: keep the record open
// for operator inspection rather than silently dropping the
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
index 12df39b..0e764ca 100644
--- a/internal/game/economy_recovery_regression_test.go
+++ b/internal/game/economy_recovery_regression_test.go
@@ -359,3 +359,121 @@ func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) {
t.Fatalf("nothing may stay owed after recovery: %+v", open)
}
}
+
+// TestRecoveryDoesNotReturnPaidChipStakeInKind: a chip wager whose round
+// resolved with a durable payout (a win returns the stake inside the
+// payout) must not ALSO get the chips back in kind on recovery — the
+// resolving payout is the refund. Only a payout that does not cover the
+// stake's full value (e.g. the credit side of a crashed mixed refund)
+// leaves the chip side outstanding.
+func TestRecoveryDoesNotReturnPaidChipStakeInKind(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: 10})
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ // The round resolved: a 2x payout (20) is durable, then the crash
+ // lands before the stake's resolution marker.
+ if err := w.PayCredits(20); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 120 || countChips(loaded) != 0 {
+ t.Fatalf("won chip bet after recovery = (%d credits, %d chips), want (120, 0) — no double refund",
+ loaded.Credits, countChips(loaded))
+ }
+}
+
+// TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries: an unpaid payout
+// liability (the credit cap blocked re-application) must survive repeated
+// recoveries. Without checkpoint re-anchoring, a later recovery mistakes
+// the balance a paid sibling moved for the unpaid record's own durability
+// and silently erases the owed amount.
+func TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits - 30
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ for i := 0; i < 2; i++ {
+ if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil {
+ t.Error(err)
+ return
+ }
+ }
+ h.g.recoverEconomy()
+ if len(h.g.Economy.loadOpen()) != 1 {
+ t.Errorf("setup: expected one unpaid record after capped recovery, got %d", len(h.g.Economy.loadOpen()))
+ return
+ }
+ // A later startup over the same files: the liability stays owed.
+ h.g.recoverEconomy()
+ if got := len(h.g.Economy.loadOpen()); got != 1 {
+ t.Errorf("second recovery erased the unpaid 20-credit liability: open=%d, want 1", got)
+ }
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != casino.MaxCredits-10 {
+ t.Fatalf("balance after repeated recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10)
+ }
+}
+
+// TestCapBlockedPayoutIsJournaled: a won payout at the economy cap must be
+// recorded as an owed (open) payout record even though it cannot be
+// applied — a reported win is never silently dropped.
+func TestCapBlockedPayoutIsJournaled(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ if err := w.PayCredits(20); err == nil {
+ t.Error("payout at the credit cap must fail, not silently clamp")
+ return
+ }
+ if p.Credits != casino.MaxCredits-10 {
+ t.Errorf("cap-blocked payout mutated the balance: %d", p.Credits)
+ }
+ })
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ restarted := harnessFromDir(t, h.dir)
+ loaded, err := restarted.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Recovery: the owed win cannot be applied (cap), so the liability
+ // stays open for operator inspection, never erased; the consumed
+ // stake is settled against the payout evidence.
+ if got := len(restarted.g.Economy.loadOpen()); got != 1 {
+ t.Fatalf("owed cap-blocked payout must stay open after recovery, open records: %+v", restarted.g.Economy.loadOpen())
+ }
+ if loaded.Credits != casino.MaxCredits-10 {
+ t.Fatalf("recovery mutated a capped balance: %d", loaded.Credits)
+ }
+}