From 32a3c59cebf2a16277e54d715a67adfdbe00b456 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Thu, 17 Sep 2026 22:00:33 -0400 Subject: fix: exact casino settlement/refund coverage, journal damage handling, craft output ordering, aggregate ingredient planning --- internal/game/economy.go | 175 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 151 insertions(+), 24 deletions(-) (limited to 'internal/game/economy.go') diff --git a/internal/game/economy.go b/internal/game/economy.go index fc1b289..21e7721 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -89,6 +89,15 @@ type economyRecord struct { CreditsAfter int `json:"credits_after"` ChipsBefore int `json:"chips_before"` ChipsAfter int `json:"chips_after"` + + // Covers lists the stake wager IDs a settlement payout RETURNS (a + // table hand pays its stake back inside the payout). Empty means no + // explicit linkage: recovery treats such a settlement as resolving + // every open stake of its player (the historical behavior, and the + // right reading for a slots payout whose stake was consumed by + // playing the round). A REFUND payout never covers: its credit part + // is the stake return being re-applied, not a new settlement. + Covers []string `json:"covers,omitempty"` } type economyJournal struct { @@ -103,12 +112,17 @@ type economyJournal struct { // ID absent from this set is settled or void and must not be trusted as // durability evidence. openPayouts map[string]bool + // openPayoutRecs indexes open payout records per player key so a + // refund can check which stakes an owed settlement already returns + // (stakeCovered). Same ownership discipline as stakes: all mutations + // happen on the game owner or during startup recovery. + openPayoutRecs 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"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)} + return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)} } func newEconomyRecordID() string { @@ -156,22 +170,23 @@ func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error { // start opens a durable record for the in-flight transaction. func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { - return j.startKind(kind, false, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) + return j.startKind(kind, false, nil, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) } // startRefund opens a refund payout record (stake returned at shutdown or // eviction; see economyRecord.Refund). func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) { - return j.startKind("payout", true, p, 0, credits, creditsBefore, creditsAfter, 0, 0) + return j.startKind("payout", true, nil, p, 0, credits, creditsBefore, creditsAfter, 0, 0) } -func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { +func (j *economyJournal) startKind(kind string, refund bool, covers []string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { rec := &economyRecord{ ID: newEconomyRecordID(), Player: p.Name, PlayerID: p.ID, Kind: kind, Refund: refund, + Covers: covers, Tracked: kind == "payout", Chips: chips, Credit: credits, @@ -188,17 +203,35 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c } if rec.Tracked { j.openPayouts[rec.ID] = true + j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], rec) } return rec, nil } +// closePayoutTracking drops rec from the open-payout indexes (its status +// changed to settled or void). +func (j *economyJournal) closePayoutTracking(rec *economyRecord) { + delete(j.openPayouts, rec.ID) + kept := j.openPayoutRecs[rec.key()][:0] + for _, open := range j.openPayoutRecs[rec.key()] { + if open != rec { + kept = append(kept, open) + } + } + if len(kept) == 0 { + delete(j.openPayoutRecs, rec.key()) + } else { + j.openPayoutRecs[rec.key()] = kept + } +} + // settle marks a record settled (the balance mutation is durable). // Fire-and-forget: callers whose recovery semantics depend on the marker // being durable use settleDurable instead. func (j *economyJournal) settle(rec *economyRecord) { rec.Status = "settled" _ = j.append(*rec) - delete(j.openPayouts, rec.ID) + j.closePayoutTracking(rec) } // settleDurable marks a record settled and persists the marker with retries, @@ -211,7 +244,7 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool { rec.Status = "open" return false } - delete(j.openPayouts, rec.ID) + j.closePayoutTracking(rec) return true } @@ -220,7 +253,7 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool { func (j *economyJournal) void(rec *economyRecord) { rec.Status = "void" _ = j.append(*rec) - delete(j.openPayouts, rec.ID) + j.closePayoutTracking(rec) } // registerStake tracks an outstanding wager record for its player. Wager @@ -268,6 +301,65 @@ func (j *economyJournal) ResolveStakes(player string) []*economyRecord { return unresolved } +// ResolveStakeByID closes ONE outstanding stake (identified by its wallet +// handle) with a settled marker: the hand it belongs to was paid or lost, +// so it must never be refunded even while sibling stakes of the same round +// remain open. On success the stake leaves the index; a marker whose append +// cannot be made durable leaves the stake OPEN (the covering settlement's +// journal record, or file-side resolution evidence, still protects it from +// being refunded twice). +func (j *economyJournal) ResolveStakeByID(player, id string) []*economyRecord { + recs := j.stakes[player] + remaining := make([]*economyRecord, 0, len(recs)) + var unresolved []*economyRecord + for _, rec := range recs { + if rec.ID != id { + remaining = append(remaining, rec) + continue + } + rec.Status = "settled" + if err := j.appendRetry(*rec, 3); err != nil { + rec.Status = "open" + remaining = append(remaining, rec) + unresolved = append(unresolved, rec) + } + } + if len(remaining) == 0 { + delete(j.stakes, player) + } else { + j.stakes[player] = remaining + } + return unresolved +} + +// stakeCovered reports whether an OPEN payout record already accounts for +// stake's return. A table settlement lists the stake IDs it covers; a +// legacy (or slots) settlement without an explicit list is treated as +// resolving every open stake of its player — a journaled settlement means +// the round resolved, and its recovery re-application delivers the money. +// A REFUND payout never covers: its credit part is the stake return being +// re-applied, not a settlement. +func (j *economyJournal) stakeCovered(stake *economyRecord) bool { + for _, payout := range j.openPayoutRecs[stake.key()] { + if payout.Refund { + continue + } + if len(payout.Covers) == 0 || containsString(payout.Covers, stake.ID) { + return true + } + } + return false +} + +func containsString(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} + // economyKey is the identity a player's journal records are indexed by: // the stable character ID when known, else the (renameable) name. func economyKey(p *player.Player) string { @@ -349,13 +441,16 @@ func (j *economyJournal) reloadStakes() error { } j.stakes = make(map[string][]*economyRecord) j.openPayouts = make(map[string]bool) + j.openPayoutRecs = make(map[string][]*economyRecord) for _, fr := range folded { if fr.rec.Kind == "wager" && fr.rec.Status == "open" { rec := fr.rec j.registerStake(&rec) } if fr.rec.Kind == "payout" && fr.rec.Status == "open" { + rec := fr.rec j.openPayouts[fr.rec.ID] = true + j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], &rec) } } return nil @@ -393,11 +488,19 @@ type foldedRecord struct { func (j *economyJournal) loadFolded() ([]foldedRecord, error) { f, err := os.Open(j.path) if err != nil { - return nil, nil // no journal yet: nothing to fold + if os.IsNotExist(err) { + return nil, nil // no journal yet: nothing to fold + } + // Any other open failure (permissions, symlink loops, I/O errors) + // is NOT "an empty healthy journal": callers must not compensate + // or compact over a journal whose contents are unknown. + return nil, fmt.Errorf("open economy journal: %w", err) } defer f.Close() latest := map[string]*foldedRecord{} var order []*foldedRecord + corruptPending := false + corruptSeq := 0 scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for seq := 0; scanner.Scan(); seq++ { @@ -407,11 +510,22 @@ func (j *economyJournal) loadFolded() ([]foldedRecord, error) { } var rec economyRecord if err := json.Unmarshal(line, &rec); err != nil { - // A torn final line (crash mid-append) is not a valid record; - // skip it. Appends are small and single-line, so only the last - // line can be torn. + // A malformed line is only tolerable as a torn FINAL line (a + // crash mid-append; appends are small and single-line, so a + // torn write can also merge with a later append onto one + // mid-file line). A malformed record followed by ANY other + // record means the fold cannot be trusted: surface the + // failure instead of skipping the line — compensating or + // compacting over a damaged journal could erase owed money. + if !corruptPending { + corruptPending = true + corruptSeq = seq + } continue } + if corruptPending { + return nil, fmt.Errorf("economy journal line %d is malformed; later records exist and may be owed money (journal preserved for inspection)", corruptSeq) + } if existing, ok := latest[rec.ID]; ok { existing.rec = rec existing.seq = seq @@ -447,6 +561,7 @@ func (j *economyJournal) compact() { tmp := j.path + ".compact" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { + fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) return } defer os.Remove(tmp) @@ -457,7 +572,11 @@ func (j *economyJournal) compact() { if err != nil { continue } - w.Write(data) + if _, err := w.Write(data); err != nil { + f.Close() + fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) + return + } w.WriteByte('\n') } if err := w.Flush(); err != nil { @@ -743,9 +862,13 @@ func (g *Game) recoverEconomy() { fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player) return nil } - // laterPayoutEvidence reports whether any non-void payout record was - // journaled for the player after seq — evidence the round resolved and - // its settlement was recorded. + // stakeEvidence reports whether any non-void payout record was + // journaled for this stake after seq — evidence the round resolved and + // its settlement was recorded. A settlement that lists explicit stake + // IDs (a table hand) only proves THIS stake when it covers it; a + // legacy (or slots) payout without a list proves the round resolved + // (the historical, and slots-correct, reading: the stake was consumed + // by playing the round). // // settlement separately reports whether a SETTLEMENT payout (not a // refund) was journaled later: a settlement — even a partial one like @@ -756,17 +879,21 @@ func (g *Game) recoverEconomy() { // instead marks a refund in progress: its credit part is re-applied by // phase 1 and the chip part (if the debit was durable) still comes // back in chips. - laterPayoutEvidence := func(rec *economyRecord, seq int) (resolved bool, settlement bool) { + stakeEvidence := func(rec *economyRecord, seq int) (evidence bool, settlement bool) { for _, fr := range folded { - if fr.rec.Kind == "payout" && fr.rec.Status != "void" && fr.seq > seq && - payoutIdentifies(fr.rec, *rec) { - resolved = true - if !fr.rec.Refund { - settlement = true - } + if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.seq <= seq || + !payoutIdentifies(fr.rec, *rec) { + continue + } + if len(fr.rec.Covers) > 0 && !containsString(fr.rec.Covers, rec.ID) { + continue + } + evidence = true + if !fr.rec.Refund { + settlement = true } } - return resolved, settlement + return evidence, settlement } compensated := 0 @@ -955,7 +1082,7 @@ func (g *Game) recoverEconomy() { compensated++ continue } - resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq) + resolvedByPayout, resolvedBySettlement := stakeEvidence(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) -- cgit v1.2.3