aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-17 22:00:33 -0400
committerhistoria <[not public]>2026-09-17 22:00:33 -0400
commit32a3c59cebf2a16277e54d715a67adfdbe00b456 (patch)
tree9bc87011513ef92f0613d9339fc55689f130915d /internal/game/economy.go
parentfea2cc6839ec3ee17940a2dea69622dc6f4deabf (diff)
downloadthehouseoficarus-32a3c59cebf2a16277e54d715a67adfdbe00b456.tar.gz
fix: exact casino settlement/refund coverage, journal damage handling, craft output ordering, aggregate ingredient planning
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go175
1 files changed, 151 insertions, 24 deletions
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)