aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 19:57:45 -0400
committerhistoria <[not public]>2026-09-16 19:57:45 -0400
commit6ef40a5739f1723ec26cc32dfc48fe764f776dca (patch)
treee215a4dc96ea639bcea07bb589aae3e31c3609bb /internal/game/economy.go
parent1dad15698845d370e998f7cc5feb8aece3fab58f (diff)
downloadthehouseoficarus-6ef40a5739f1723ec26cc32dfc48fe764f776dca.tar.gz
fix: durable payouts survive journal failure, cap bank withdrawals, hash passwords off the owner loop.
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go215
1 files changed, 190 insertions, 25 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go
index 0d5f08f..fc1b289 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -181,7 +181,9 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
ChipsBefore: chipsBefore,
ChipsAfter: chipsAfter,
}
- if err := j.append(*rec); err != nil {
+ // Retry a bounded number of times: a transient append failure must not
+ // drop the record that makes the wager or owed payout durable.
+ if err := j.appendRetry(*rec, 5); err != nil {
return nil, err
}
if rec.Tracked {
@@ -240,7 +242,13 @@ func (j *economyJournal) openStakes(player string) []*economyRecord {
// payout evidence, or — if the round was a loss with no settlement
// payout — refunds it under the documented refund-on-crash policy. A
// stake is only forgotten once its marker is durable.
-func (j *economyJournal) ResolveStakes(player string) {
+//
+// It returns the records whose settled marker could NOT be made durable.
+// Callers holding the player should record those IDs as resolved-stake
+// evidence (player.Player.ResolvedStakes) so recovery still honors the
+// resolution even though the journal could not persist its marker.
+func (j *economyJournal) ResolveStakes(player string) []*economyRecord {
+ var unresolved []*economyRecord
remaining := j.stakes[player][:0]
for _, rec := range j.stakes[player] {
rec.Status = "settled"
@@ -249,6 +257,7 @@ func (j *economyJournal) ResolveStakes(player string) {
// (or a later attempt) still resolves it.
rec.Status = "open"
remaining = append(remaining, rec)
+ unresolved = append(unresolved, rec)
}
}
if len(remaining) == 0 {
@@ -256,6 +265,7 @@ func (j *economyJournal) ResolveStakes(player string) {
} else {
j.stakes[player] = remaining
}
+ return unresolved
}
// economyKey is the identity a player's journal records are indexed by:
@@ -275,6 +285,41 @@ func (rec *economyRecord) key() string {
return rec.Player
}
+// resolvedStakeRecorded reports whether the character's durable evidence
+// list claims rec's stake was resolved by the session (its settled marker
+// could not be journaled; see player.Player.ResolvedStakes).
+func resolvedStakeRecorded(p *player.Player, id string) bool {
+ for _, existing := range p.ResolvedStakes {
+ if existing == id {
+ return true
+ }
+ }
+ return false
+}
+
+// pruneResolvedStakes drops resolved-stake evidence IDs that no longer
+// correspond to an open wager record (settled or void). It reports whether
+// anything changed, so callers only pay for a save when the file would
+// actually differ.
+func pruneResolvedStakes(openStakeIDs map[string]bool, p *player.Player) bool {
+ if len(p.ResolvedStakes) == 0 {
+ return false
+ }
+ kept := p.ResolvedStakes[:0]
+ changed := false
+ for _, id := range p.ResolvedStakes {
+ if openStakeIDs[id] {
+ kept = append(kept, id)
+ } else {
+ changed = true
+ }
+ }
+ if changed {
+ p.ResolvedStakes = kept
+ }
+ return changed
+}
+
// payoutIdentifies reports whether two journal records belong to the same
// character identity. ID-carrying records match only on the stable ID: a
// different character that reused the display name must never supply (or
@@ -295,11 +340,16 @@ func payoutIdentifies(a, b economyRecord) bool {
// 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() {
+// recovery compaction. A read failure leaves the previous indexes intact
+// (never a wiped index over a live journal); the caller logs loudly.
+func (j *economyJournal) reloadStakes() error {
+ folded, err := j.loadFolded()
+ if err != nil {
+ return err
+ }
j.stakes = make(map[string][]*economyRecord)
j.openPayouts = make(map[string]bool)
- for _, fr := range j.loadFolded() {
+ for _, fr := range folded {
if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
rec := fr.rec
j.registerStake(&rec)
@@ -308,18 +358,23 @@ func (j *economyJournal) reloadStakes() {
j.openPayouts[fr.rec.ID] = true
}
}
+ return nil
}
// loadOpen folds the journal and returns the still-open records.
-func (j *economyJournal) loadOpen() []*economyRecord {
+func (j *economyJournal) loadOpen() ([]*economyRecord, error) {
var open []*economyRecord
- for _, fr := range j.loadFolded() {
+ folded, err := j.loadFolded()
+ if err != nil {
+ return nil, err
+ }
+ for _, fr := range folded {
if fr.rec.Status == "open" {
rec := fr.rec
open = append(open, &rec)
}
}
- return open
+ return open, nil
}
// foldedRecord is the latest state of one record ID together with the
@@ -330,11 +385,15 @@ type foldedRecord struct {
}
// loadFolded folds the journal (latest line per record ID wins) and
-// returns the surviving records in journal append order.
-func (j *economyJournal) loadFolded() []foldedRecord {
+// returns the surviving records in journal append order. A read failure
+// (oversized or unreadable region) is an ERROR, never a silent truncation:
+// callers must not compact or compensate over a partially-read journal,
+// because compaction would permanently erase every record beyond the
+// unreadable point — including owed payouts and outstanding stakes.
+func (j *economyJournal) loadFolded() ([]foldedRecord, error) {
f, err := os.Open(j.path)
if err != nil {
- return nil
+ return nil, nil // no journal yet: nothing to fold
}
defer f.Close()
latest := map[string]*foldedRecord{}
@@ -362,16 +421,29 @@ func (j *economyJournal) loadFolded() []foldedRecord {
order = append(order, fr)
}
}
+ if err := scanner.Err(); err != nil {
+ // A scanner error means the fold is INCOMPLETE (a line exceeded the
+ // buffer or the file became unreadable mid-scan). Returning a
+ // partial fold would let compaction erase valid records; surface
+ // the failure instead.
+ return nil, fmt.Errorf("scan economy journal: %w", err)
+ }
folded := make([]foldedRecord, 0, len(order))
for _, fr := range order {
folded = append(folded, *fr)
}
- return folded
+ return folded, nil
}
-// compact rewrites the journal keeping only still-open records.
+// compact rewrites the journal keeping only still-open records. A fold
+// failure aborts the rewrite: compacting over a journal that could not be
+// fully read would silently erase every record beyond the unreadable point.
func (j *economyJournal) compact() {
- open := j.loadOpen()
+ open, err := j.loadOpen()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
+ return
+ }
tmp := j.path + ".compact"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
@@ -468,6 +540,27 @@ func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error {
return nil
}
+// grantCredits adds credits from ordinary gameplay (shop sales, credit
+// pickups, activity payouts). The economy cap is enforced by clamping: the
+// cap is far above any reachable balance, so hitting it means a data bug —
+// clamp (never wrap) and log loudly for the operator.
+func (g *Game) grantCredits(p *player.Player, amount int) {
+ if amount <= 0 {
+ return
+ }
+ sum, err := addCreditsChecked(p.Credits, amount)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err)
+ p.Credits = maxCreditsValue
+ return
+ }
+ p.Credits = sum
+}
+
+// maxCreditsValue mirrors casino.MaxCredits; kept local to avoid a
+// dependency cycle (see addCreditsChecked).
+const maxCreditsValue = 1_000_000_000_000
+
// addCreditsChecked is the game-side checked addition against the economy
// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle).
func addCreditsChecked(a, b int) (int, error) {
@@ -555,7 +648,14 @@ func (g *Game) recoverEconomy() {
if g.Economy == nil {
return
}
- folded := g.Economy.loadFolded()
+ folded, err := g.Economy.loadFolded()
+ if err != nil {
+ // The journal could not be fully read. Compensation over a partial
+ // fold could double-pay or erase liabilities, and compaction would
+ // truncate the file. Preserve everything for operator inspection.
+ fmt.Fprintf(os.Stderr, "economy recovery aborted: %v (journal and character files preserved for inspection)\n", err)
+ return
+ }
if g.Economy.openPayouts == nil {
g.Economy.openPayouts = make(map[string]bool)
} else {
@@ -585,12 +685,21 @@ func (g *Game) recoverEconomy() {
}
}
}
- // finalizeLoaded prunes stale open-payout evidence the first time a
- // character file is touched: IDs the journal shows as settled or void
- // cannot prove anything and are dropped (with a durable save) so the
- // list stays small and meaningful.
+ // finalizeLoaded prunes stale open-payout evidence and resolved-stake
+ // evidence the first time a character file is touched: IDs the journal
+ // shows as settled or void cannot prove anything and are dropped (with
+ // a durable save) so the lists stay small and meaningful.
+ openStakeIDs := make(map[string]bool)
+ for i := range folded {
+ rec := &folded[i].rec
+ if rec.Kind == "wager" && rec.Status == "open" {
+ openStakeIDs[rec.ID] = true
+ }
+ }
finalizeLoaded := func(p *player.Player) *player.Player {
- if pruneOpenPayouts(g.Economy, p) {
+ prunedPayouts := pruneOpenPayouts(g.Economy, p)
+ prunedStakes := pruneResolvedStakes(openStakeIDs, p)
+ if prunedPayouts || prunedStakes {
if err := g.AccountStore.SaveCharacter(p); err != nil {
fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err)
}
@@ -830,11 +939,23 @@ func (g *Game) recoverEconomy() {
}
for k := len(openWagers) - 1; k >= 0; k-- {
rec := &folded[openWagers[k]].rec
- resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq)
p := loadPlayer(rec)
if p == nil {
continue
}
+ // File-side resolution evidence: the session resolved this stake
+ // but its settled marker could not be journaled (e.g. a broken
+ // economy path), so the character file carries the resolution ID.
+ // The round resolved — the stake must not be refunded, and its chip
+ // part must not return in kind on top of a settlement that already
+ // returned it as credits (a refund, by contrast, already gave the
+ // chips back in the file).
+ if resolvedStakeRecorded(p, rec.ID) {
+ g.Economy.settle(rec)
+ compensated++
+ continue
+ }
+ resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq)
// Checkpoint comparisons use the DURABLE state, captured before any
// in-memory chip restoration below.
creditsNow, chipsNow := p.Credits, countChips(p)
@@ -887,8 +1008,19 @@ func (g *Game) recoverEconomy() {
// The debit is durable but the round was lost in the crash:
// refund the stake (approved refund-on-recovery policy). The
// chips (if owed) are already restored in memory; one durable
- // save covers both the chip return and the credit refund.
- p.Credits += rec.Credit
+ // save covers both the chip return and the credit refund. The
+ // credit refund is checked: a record can never push the
+ // balance past the economy cap.
+ sum, capErr := addCreditsChecked(creditsNow, rec.Credit)
+ if capErr != nil {
+ if chipsOwed {
+ p.RemoveItem("chips", rec.Chips)
+ }
+ fmt.Fprintf(os.Stderr, "economy recovery: refund %s for %s blocked (%v) — kept open for operator inspection\n",
+ rec.ID, rec.Player, capErr)
+ continue
+ }
+ p.Credits = sum
if err := g.AccountStore.SaveCharacter(p); err != nil {
// Revert the in-memory refund so later records for this
// player still compare against the durable balance; the
@@ -918,7 +1050,40 @@ func (g *Game) recoverEconomy() {
}
}
g.Economy.compact()
+ // Final evidence prune: the phases above settled or voided records, so
+ // evidence IDs that no longer correspond to an open record are stale
+ // (a settled stake's resolution evidence, an applied payout's entry).
+ // Drop them now so the character files stay small and meaningful; a
+ // failed save only defers the prune to the next startup (finalizeLoaded).
+ stillOpen := make(map[string]bool)
+ for i := range folded {
+ rec := &folded[i].rec
+ if rec.Kind == "wager" && rec.Status == "open" {
+ stillOpen[rec.ID] = true
+ }
+ }
+ pruneSeen := make(map[*player.Player]bool)
+ prunePlayer := func(p *player.Player) {
+ if p == nil || pruneSeen[p] {
+ return
+ }
+ pruneSeen[p] = true
+ if pruneOpenPayouts(g.Economy, p) || pruneResolvedStakes(stillOpen, p) {
+ if err := g.AccountStore.SaveCharacter(p); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: final evidence prune save %s: %v\n", p.Name, err)
+ }
+ }
+ }
+ for _, p := range playersByID {
+ prunePlayer(p)
+ }
+ for _, p := range playersByName {
+ prunePlayer(p)
+ }
// Rebuild the outstanding-stake index from whatever open wager records
- // remain (normally none; ambiguous ones stay refundable/resolvable).
- g.Economy.reloadStakes()
+ // remain (normally none; ambiguous ones stay refundable/resolvable). A
+ // fold failure leaves the previous index intact and was already logged.
+ if err := g.Economy.reloadStakes(); err != nil {
+ fmt.Fprintf(os.Stderr, "economy recovery: stake index rebuild failed: %v\n", err)
+ }
}