aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
blob: e917fa6de5ca3d1ff5e97de24234177000f77915 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
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.
//
// 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 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 (outstanding stakes and unresolved payouts).

import (
	"bufio"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"thehouseoficarus/internal/player"
)

type economyRecord struct {
	ID     string `json:"id"`
	Player string `json:"player"`
	// PlayerID is the character's stable UUID (player.Player.ID), assigned
	// at creation and backfilled at first startup. Records resolve their
	// subject by ID at recovery so a character RENAME can never strand an
	// owed payout or outstanding stake; Player (the name) is kept for
	// operator inspection and for legacy records written before IDs.
	PlayerID string    `json:"player_id,omitempty"`
	Kind     string    `json:"kind"` // "wager" or "payout"
	Chips    int       `json:"chips"`
	Credit   int       `json:"credits"`
	Status   string    `json:"status"` // "open", "settled", "void"
	TS       time.Time `json:"ts"`
	// Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the
	// stake being returned), not a game settlement. Recovery must not
	// treat the chip part of a refund as paid in credits: a refund's chip
	// stake returns in kind, so a crash mid-refund still owes the chips.
	// Settlement payouts (a win, push, or surrender return) instead prove
	// the round resolved, closing the stake without any chip return.
	Refund bool `json:"refund,omitempty"`

	// Balance checkpoints used to decide recovery compensation.
	CreditsBefore int `json:"credits_before"`
	CreditsAfter  int `json:"credits_after"`
	ChipsBefore   int `json:"chips_before"`
	ChipsAfter    int `json:"chips_after"`
}

type economyJournal struct {
	path string
	// stakes indexes outstanding wager records per player identity key
	// (economyKey: the character's stable ID when known, else its 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"), stakes: make(map[string][]*economyRecord)}
}

func newEconomyRecordID() string {
	var b [8]byte
	if _, err := rand.Read(b[:]); err != nil {
		panic(fmt.Sprintf("economy record id: %v", err))
	}
	return hex.EncodeToString(b[:])
}

// append writes one durable line. Errors are returned to the caller, which
// must treat the transaction as failed.
func (j *economyJournal) append(rec economyRecord) error {
	rec.TS = time.Now()
	data, err := json.Marshal(rec)
	if err != nil {
		return err
	}
	f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
	if err != nil {
		return fmt.Errorf("open economy journal: %w", err)
	}
	defer f.Close()
	if _, err := f.Write(append(data, '\n')); err != nil {
		return fmt.Errorf("append economy journal: %w", err)
	}
	if err := f.Sync(); err != nil {
		return fmt.Errorf("sync economy journal: %w", err)
	}
	return nil
}

// appendRetry appends one durable line, retrying a bounded number of
// times: a transient write failure must not permanently lose a marker
// that money correctness depends on.
func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error {
	var err error
	for i := 0; i < max(attempts, 1); i++ {
		if err = j.append(rec); err == nil {
			return nil
		}
	}
	return err
}

// 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)
}

// 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)
}

func (j *economyJournal) startKind(kind string, refund bool, 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,
		Chips:         chips,
		Credit:        credits,
		Status:        "open",
		CreditsBefore: creditsBefore,
		CreditsAfter:  creditsAfter,
		ChipsBefore:   chipsBefore,
		ChipsAfter:    chipsAfter,
	}
	if err := j.append(*rec); err != nil {
		return nil, err
	}
	return rec, nil
}

// settle marks a record settled (the balance mutation is durable).
func (j *economyJournal) settle(rec *economyRecord) {
	rec.Status = "settled"
	_ = j.append(*rec)
}

// void marks a record void (the mutation was reverted in memory and never
// became durable; recovery must not compensate).
func (j *economyJournal) void(rec *economyRecord) {
	rec.Status = "void"
	_ = 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.key()] = append(j.stakes[rec.key()], rec)
}

// openStakes returns the outstanding stake records for a player identity
// key (see economyKey).
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.
// A marker whose append cannot be made durable leaves that stake OPEN
// (never dropped from the index): recovery then settles it from later
// 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) {
	remaining := j.stakes[player][:0]
	for _, rec := range j.stakes[player] {
		rec.Status = "settled"
		if err := j.appendRetry(*rec, 3); err != nil {
			// The marker is not durable: the stake stays open so recovery
			// (or a later attempt) still resolves it.
			rec.Status = "open"
			remaining = append(remaining, rec)
		}
	}
	if len(remaining) == 0 {
		delete(j.stakes, player)
	} else {
		j.stakes[player] = remaining
	}
}

// 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 {
	if p.ID != "" {
		return p.ID
	}
	return p.Name
}

// economyRecordKey mirrors economyKey for a raw record.
func (rec *economyRecord) key() string {
	if rec.PlayerID != "" {
		return rec.PlayerID
	}
	return rec.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]*foldedRecord{}
	var order []*foldedRecord
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for seq := 0; scanner.Scan(); seq++ {
		line := scanner.Bytes()
		if len(line) == 0 {
			continue
		}
		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.
			continue
		}
		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)
		}
	}
	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.
func (j *economyJournal) compact() {
	open := j.loadOpen()
	tmp := j.path + ".compact"
	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
	if err != nil {
		return
	}
	defer os.Remove(tmp)
	w := bufio.NewWriter(f)
	for _, rec := range open {
		rec.TS = time.Now()
		data, err := json.Marshal(rec)
		if err != nil {
			continue
		}
		w.Write(data)
		w.WriteByte('\n')
	}
	if err := w.Flush(); err != nil {
		f.Close()
		return
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return
	}
	f.Close()
	// Best-effort atomic-ish replacement; a torn compaction leaves the
	// original file intact.
	if err := os.Rename(tmp, j.path); err != nil {
		os.Remove(tmp)
	}
}

// giveChips re-credits chips to a player's inventory (used by refund and
// recovery paths). It stacks onto an existing chips slot when one exists.
// It reports false when the chips do NOT fit (no existing stack and no free
// slot): the caller must then treat the chips as undelivered — they are
// never silently dropped; the money record stays open instead.
func (g *Game) giveChips(p *player.Player, qty int) bool {
	for _, slot := range p.Inventory {
		if slot != nil && slot.ItemID == "chips" {
			slot.Quantity += qty
			return true
		}
	}
	slot := p.FirstFreeSlot()
	if slot < 0 {
		return false
	}
	p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty})
	return true
}

// payCreditsDurable applies a checked credit increase and persists it.
// A failed save reverts the in-memory mutation: recovery must keep
// comparing later records against the DURABLE balance.
func (g *Game) payCreditsDurable(p *player.Player, amount int) error {
	if amount < 0 {
		return fmt.Errorf("negative payout %d", amount)
	}
	sum, err := addCreditsChecked(p.Credits, amount)
	if err != nil {
		return err
	}
	before := p.Credits
	p.Credits = sum
	if err := g.AccountStore.SaveCharacter(p); err != nil {
		p.Credits = before
		return err
	}
	return nil
}

// 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) {
	const maxCredits = 1_000_000_000_000
	if b > 0 && a > maxCredits-b {
		return maxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, maxCredits)
	}
	if b < 0 {
		return 0, fmt.Errorf("negative credit add %d", b)
	}
	return a + b, nil
}

// 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
	}
	folded := g.Economy.loadFolded()
	// Player records resolve by the STABLE character ID (a rename must
	// never strand an owed payout); the name lookup remains for legacy
	// records written before IDs existed. Every character file is loaded
	// up front so records resolve by stable ID without filename guessing.
	playersByID := make(map[string]*player.Player)
	playersByName := make(map[string]*player.Player)
	if names, err := g.AccountStore.ListCharacterNames(); err == nil {
		for _, name := range names {
			if p, err := g.AccountStore.LoadCharacter(name); err == nil {
				if p.ID != "" {
					playersByID[p.ID] = p
				}
				playersByName[strings.ToLower(p.Name)] = p
			}
		}
	}
	loadPlayer := func(rec *economyRecord) *player.Player {
		if rec.PlayerID != "" {
			if p, ok := playersByID[rec.PlayerID]; ok {
				return p
			}
		}
		if rec.Player != "" {
			if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
				return p
			}
		}
		if rec.PlayerID != "" {
			// A character file added since the index was built: load every
			// file once and match by stable ID.
			if names, err := g.AccountStore.ListCharacterNames(); err == nil {
				for _, name := range names {
					if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID {
						playersByID[p.ID] = p
						playersByName[strings.ToLower(p.Name)] = p
						return p
					}
				}
			}
		}
		// 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 (id %q): character not found\n", rec.Player, rec.PlayerID)
		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.
	//
	// settlement separately reports whether a SETTLEMENT payout (not a
	// refund) was journaled later: a settlement — even a partial one like
	// a surrendered hand or the first hand of a multi-hand win — proves
	// the round resolved, so the stake must NOT be refunded and its chip
	// part must NOT return in kind on top of the settlement (the chips
	// were lost at settlement, by the rules of the game). A REFUND payout
	// 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(name string, seq int) (resolved bool, settlement bool) {
		for _, fr := range folded {
			if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq {
				resolved = true
				if !fr.rec.Refund {
					settlement = true
				}
			}
		}
		return resolved, settlement
	}
	compensated := 0

	// Phase 1 — payouts, in append order. Records with identical
	// checkpoints are decided as one group: k sibling records all journaled
	// against balance `before` were all written against the same in-memory
	// balance (a failed save reverts it), so either none became durable
	// (the file is still at `before`: all k are owed) or at most one did
	// (the file is at `after` and k == 1). Re-applying identical siblings
	// one at a time would let the second record misread the first
	// re-application as its own durability and silently drop the owed
	// payout — the group is settled together instead.
	type payoutSig struct {
		player        string
		before, after int
		credit        int
	}
	siblings := make(map[payoutSig][]*economyRecord)
	for i := range folded {
		rec := &folded[i].rec
		if rec.Kind == "payout" && rec.Status == "open" {
			sig := payoutSig{rec.Player, rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
			siblings[sig] = append(siblings[sig], rec)
		}
	}
	settled := make(map[string]bool)
	for i := range folded {
		rec := &folded[i].rec
		if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] {
			continue
		}
		p := loadPlayer(rec)
		if p == nil {
			continue
		}
		sig := payoutSig{rec.Player, rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
		group := siblings[sig]
		switch {
		case p.Credits == rec.CreditsBefore:
			// The payout (and every identical sibling) was lost in the
			// crash: the player is owed them all. Only the siblings whose
			// re-application actually became durable are settled; a save
			// failure (e.g. the credit cap) leaves the unpaid remainder open
			// for operator inspection — a liability is never erased.
			paid := 0
			capBlocked := false
			for m := 0; m < len(group); m++ {
				if _, err := addCreditsChecked(p.Credits, rec.Credit); err != nil {
					capBlocked = true
					break
				}
				if err := g.payCreditsDurable(p, rec.Credit); err != nil {
					fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
					break
				}
				paid++
				compensated++
			}
			for idx, other := range group {
				if idx < paid {
					g.Economy.settle(other)
					settled[other.ID] = true
					continue
				}
				if capBlocked {
					// The credit cap blocked re-application. Re-anchor the
					// record's 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)
					continue
				}
				// A TRANSIENT save failure blocked re-application and the
				// balance still has room below the cap: keep the record open
				// with its ORIGINAL checkpoints. Re-anchoring here would make
				// the record unreadable against the unchanged durable balance
				// (before-checkpoint mismatch) and permanently ambiguous; the
				// original checkpoints let a later recovery retry cleanly.
				settled[other.ID] = true // handled for the rest of this run
			}
		case p.Credits == rec.CreditsAfter && len(group) == 1:
			g.Economy.settle(rec)
			settled[rec.ID] = true
		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, resolvedBySettlement := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq)
		p := loadPlayer(rec)
		if p == nil {
			continue
		}
		// Checkpoint comparisons use the DURABLE state, captured before any
		// in-memory chip restoration below.
		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 — unless a
		// SETTLEMENT payout was journaled later: a settlement proves the
		// round resolved, so the chips were lost at settlement by the rules
		// of the game, and returning them in kind on top would double-pay
		// the round (e.g. a surrendered hand paying half the stake must not
		// ALSO return the full chip stake). A REFUND payout instead marks a
		// refund in progress: its credit part was re-applied by phase 1 and
		// the chip part still returns in chips.
		chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter &&
			!resolvedBySettlement
		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
			// chips; a later startup (or operator action) retries.
			fmt.Fprintf(os.Stderr, "economy recovery: wager %s for %s: no inventory room for %d chips — kept open for operator inspection\n",
				rec.ID, rec.Player, rec.Chips)
			continue
		}
		saveChipReturn := func() error {
			if !chipsOwed {
				return nil
			}
			if err := g.AccountStore.SaveCharacter(p); err != nil {
				p.RemoveItem("chips", rec.Chips)
				fmt.Fprintf(os.Stderr, "economy recovery: chip return save %s: %v\n", p.Name, err)
				return err
			}
			return nil
		}
		switch {
		case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore:
			// The debit never became durable: void it.
			g.Economy.void(rec)
		case creditsNow == rec.CreditsAfter:
			if resolvedByPayout {
				// A later payout record proves the round resolved (the win,
				// push, or refund already returned the credit stake inside
				// that payout). Only the resolution marker was lost; the
				// chip stake may still be outstanding.
				if saveChipReturn() == nil {
					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). 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
			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
				// record stays open and a later startup can retry.
				p.Credits = creditsNow
				if chipsOwed {
					p.RemoveItem("chips", rec.Chips)
				}
				fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err)
				continue
			}
			g.Economy.settle(rec)
			compensated++
		default:
			// Persist an owed chip return even when the credit side stays
			// ambiguous: the record remains open either way, but the chips
			// must not be silently lost. A failed save reverts them.
			if saveChipReturn() != nil {
				continue
			}
			if resolvedByPayout {
				g.Economy.settle(rec)
				continue
			}
			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.compact()
	// Rebuild the outstanding-stake index from whatever open wager records
	// remain (normally none; ambiguous ones stay refundable/resolvable).
	g.Economy.reloadStakes()
}