aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
blob: fc1b289af9b8ecf0e4e975c0917b71d00bec8782 (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
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
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. Durability is proven by
//     the character file itself, not by balance checkpoints: the same
//     save that includes the payout in the balance records the record's
//     ID in the character's open_payouts evidence list, and the entry is
//     dropped once the settled marker is durable. Checkpoints alone are
//     ambiguous — an unrelated credit change (shop sale, bank deposit)
//     can coincidentally land on the record's CreditsAfter balance, and
//     settling on that coincidence silently erases an owed payout.
//
// 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"`

	// Tracked marks a payout written under the character-file evidence
	// scheme: the record's ID is recorded in the character's open_payouts
	// list by the same save that includes its amount in the balance, and
	// recovery uses that membership — not balance checkpoints — to decide
	// whether the payout became durable.
	Tracked bool `json:"tracked,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
	// openPayouts indexes payout records that are still open (journaled but
	// not provably durable). It drives evidence pruning: a character-file
	// ID absent from this set is settled or void and must not be trusted as
	// durability evidence.
	openPayouts map[string]bool
}

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

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,
		Tracked:       kind == "payout",
		Chips:         chips,
		Credit:        credits,
		Status:        "open",
		CreditsBefore: creditsBefore,
		CreditsAfter:  creditsAfter,
		ChipsBefore:   chipsBefore,
		ChipsAfter:    chipsAfter,
	}
	// 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 {
		j.openPayouts[rec.ID] = true
	}
	return rec, nil
}

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

// settleDurable marks a record settled and persists the marker with retries,
// reporting whether the marker became durable. A false return leaves the
// record open in the journal: callers keeping file-side evidence for this
// record must NOT drop that evidence until this returns true.
func (j *economyJournal) settleDurable(rec *economyRecord) bool {
	rec.Status = "settled"
	if err := j.appendRetry(*rec, 3); err != nil {
		rec.Status = "open"
		return false
	}
	delete(j.openPayouts, rec.ID)
	return true
}

// 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)
	delete(j.openPayouts, rec.ID)
}

// 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.
//
// 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"
		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)
			unresolved = append(unresolved, rec)
		}
	}
	if len(remaining) == 0 {
		delete(j.stakes, player)
	} else {
		j.stakes[player] = remaining
	}
	return unresolved
}

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

// 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
// consume) another character's resolution evidence. Legacy records without
// IDs keep the historical name-based match among themselves.
func payoutIdentifies(a, b economyRecord) bool {
	if a.PlayerID != "" && b.PlayerID != "" {
		return a.PlayerID == b.PlayerID
	}
	if a.PlayerID != "" || b.PlayerID != "" {
		// Mixed legacy/ID records: refuse to match on name alone so an ID
		// can never be resolved through a name that another character may
		// have reused.
		return false
	}
	return strings.EqualFold(a.Player, b.Player)
}

// reloadStakes rebuilds the outstanding-stake index from the journal file:
// open wager records remain tracked (and refundable/resolvable) after
// 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 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" {
			j.openPayouts[fr.rec.ID] = true
		}
	}
	return nil
}

// loadOpen folds the journal and returns the still-open records.
func (j *economyJournal) loadOpen() ([]*economyRecord, error) {
	var open []*economyRecord
	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, nil
}

// 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. 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, nil // no journal yet: nothing to fold
	}
	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)
		}
	}
	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, nil
}

// 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, 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 {
		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
}

// applyOpenPayout re-applies an open TRACKED payout record during recovery:
// checked credit increase plus a character save that records the record ID
// in the file's open_payouts evidence list. A failed save reverts the
// in-memory mutation (credits and evidence) so the record stays open and a
// later recovery attempt retries cleanly.
func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error {
	if rec.Credit < 0 {
		return fmt.Errorf("negative payout %d", rec.Credit)
	}
	sum, err := addCreditsChecked(p.Credits, rec.Credit)
	if err != nil {
		return err
	}
	before := p.Credits
	p.Credits = sum
	p.OpenPayouts = append(p.OpenPayouts, rec.ID)
	if err := g.AccountStore.SaveCharacter(p); err != nil {
		p.Credits = before
		removeOpenPayout(p, rec.ID)
		return err
	}
	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) {
	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
}

// openPayoutRecorded reports whether the character's durable evidence list
// claims rec's amount is included in the balance.
func openPayoutRecorded(p *player.Player, id string) bool {
	for _, existing := range p.OpenPayouts {
		if existing == id {
			return true
		}
	}
	return false
}

// removeOpenPayout drops id from the in-memory evidence list; the on-disk
// copy is corrected by the next character save.
func removeOpenPayout(p *player.Player, id string) {
	kept := p.OpenPayouts[:0]
	for _, existing := range p.OpenPayouts {
		if existing != id {
			kept = append(kept, existing)
		}
	}
	p.OpenPayouts = kept
}

// pruneOpenPayouts drops evidence IDs that no longer correspond to an open
// payout record (settled or void). It reports whether anything changed, so
// callers only pay for a save when the file would actually differ.
func pruneOpenPayouts(j *economyJournal, p *player.Player) bool {
	if j == nil || len(p.OpenPayouts) == 0 {
		return false
	}
	if j.openPayouts == nil {
		j.openPayouts = make(map[string]bool)
	}
	kept := p.OpenPayouts[:0]
	changed := false
	for _, id := range p.OpenPayouts {
		if j.openPayouts[id] {
			kept = append(kept, id)
		} else {
			changed = true
		}
	}
	if changed {
		p.OpenPayouts = kept
	}
	return changed
}

// 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 — UNLESS an earlier recovery run (or the
//     original settlement) already settled a same-checkpoint sibling, in
//     which case the balance is explained by THAT record and this open
//     record is still owed and re-applied; 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, 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 {
		for id := range g.Economy.openPayouts {
			delete(g.Economy.openPayouts, id)
		}
	}
	for i := range folded {
		rec := &folded[i].rec
		if rec.Kind == "payout" && rec.Status == "open" {
			g.Economy.openPayouts[rec.ID] = true
		}
	}
	// 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
			}
		}
	}
	// 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 {
		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)
			}
		}
		return p
	}
	loadPlayer := func(rec *economyRecord) *player.Player {
		if rec.PlayerID != "" {
			// A record carrying a stable ID resolves to THAT identity only.
			// Never fall back to the display name: a different character
			// that later reused the name would collect the original
			// character's payout and the original liability would be
			// settled as paid. See TestRecoveryDoesNotPayReusedCharacterName.
			if p, ok := playersByID[rec.PlayerID]; ok {
				return finalizeLoaded(p)
			}
			// 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 finalizeLoaded(p)
					}
				}
			}
			// Identity not found (character deleted or unreadable): leave
			// the record open so a later attempt (or operator inspection)
			// can resolve it; do not silently drop or reassign money.
			fmt.Fprintf(os.Stderr, "economy recovery: load %s (id %q): character not found\n", rec.Player, rec.PlayerID)
			return nil
		}
		// Legacy record written before stable IDs existed: the (renameable)
		// name is the only identity available.
		if rec.Player != "" {
			if p, ok := playersByName[strings.ToLower(rec.Player)]; ok {
				return finalizeLoaded(p)
			}
		}
		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.
	//
	// 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(rec *economyRecord, seq int) (resolved 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
				}
			}
		}
		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 is the record's stable identity key (PlayerID when known,
		// else the name): grouping by display name alone would merge two
		// different characters that reused the same name.
		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.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit}
			siblings[sig] = append(siblings[sig], rec)
		}
	}
	settled := make(map[string]bool)
	// settledSiblings counts SETTLED payout records (from earlier recovery
	// runs; visible in the folded journal) with the same signature as rec.
	// Each one already explains one durable credit unit of the current
	// balance: a record moved the balance from `before` to `after` and was
	// settled, so any other open record with the SAME checkpoints is still
	// owed — it must never be settled as "already durable" because the
	// balance happens to sit at `after`.
	settledSiblings := func(sig payoutSig, selfID string) int {
		n := 0
		for j := range folded {
			other := &folded[j].rec
			if other.ID == selfID || other.Kind != "payout" || other.Status != "settled" {
				continue
			}
			if other.key() == sig.player && other.CreditsBefore == sig.before &&
				other.CreditsAfter == sig.after && other.Credit == sig.credit {
				n++
			}
		}
		return n
	}
	// reapplyGroup re-applies every identical open sibling: they were all
	// journaled against the same in-memory balance and are all owed. 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.
	reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) {
		paid := 0
		capBlocked := false
		for m := 0; m < len(group); m++ {
			if _, err := addCreditsChecked(p.Credits, credit); err != nil {
				capBlocked = true
				break
			}
			if err := g.payCreditsDurable(p, 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
		}
	}
	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
		}
		if rec.Tracked {
			// Tracked records carry durable evidence in the character file
			// itself: the same save that includes the amount in the balance
			// records the record ID in open_payouts. Membership decides.
			// Balance checkpoints are NEVER consulted for tracked records:
			// unrelated credit changes (shop sales, bank deposits, other
			// games) can coincidentally land on CreditsAfter, and settling
			// on that coincidence silently erases an owed payout.
			if openPayoutRecorded(p, rec.ID) {
				// The save landed; the amount is already in the balance.
				g.Economy.settle(rec)
				settled[rec.ID] = true
				continue
			}
			if err := g.applyOpenPayout(p, rec); err != nil {
				// Cap-blocked or transiently failed: the record stays open
				// (no evidence entry was added) and a later recovery
				// retries. A liability is never silently dropped.
				fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n",
					rec.ID, rec.Player, err)
			} else {
				if g.Economy.settleDurable(rec) {
					removeOpenPayout(p, rec.ID)
				}
				compensated++
			}
			settled[rec.ID] = true
			continue
		}
		sig := payoutSig{rec.key(), 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.
			reapplyGroup(group, p, rec.Credit)
		case p.Credits == rec.CreditsAfter && len(group) == 1 && settledSiblings(sig, rec.ID) == 0:
			// The only record with these checkpoints sits at its
			// post-payout balance: it is already durable, settle it.
			g.Economy.settle(rec)
			settled[rec.ID] = true
		case p.Credits == rec.CreditsAfter && settledSiblings(sig, rec.ID) > 0:
			// A SETTLED sibling with identical checkpoints explains the
			// balance: an earlier recovery run already re-applied (and
			// settled) it, or its original settlement moved the balance
			// here. This open record is still OWED — settling it as
			// "already durable" because the balance reads `after` would
			// silently drop the liability (see the
			// partially-paid-identical-siblings regression).
			reapplyGroup(group, p, rec.Credit)
		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
		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)
		// 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. 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
				// 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()
	// 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). 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)
	}
}