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
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
|
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"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"thehouseoficarus/internal/casino"
"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"`
// Seq is the record's monotonic creation order within the journal. It
// lets coverage checks distinguish a settlement that predates a stake
// (and therefore cannot return it) from one written after it.
Seq int64 `json:"seq,omitempty"`
// 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"`
// 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 {
path string
// dirSynced records whether the directory entry of path has been made
// durable. A file's own fsync does not persist the entry that names it,
// so the first append must sync the directory (as persistence.WriteFile
// does) or a crash can lose the whole journal — including the record of
// a debit that is already durable in the character file.
dirSynced bool
// tailChecked records whether the journal tail has been verified to end on
// a newline before the first append of this process. A torn final line
// (short write) must be truncated before a new record is appended, or the
// two merge into one malformed mid-file line that permanently aborts
// recovery.
tailChecked bool
// 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
// 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
// seq is the monotonic record counter (see economyRecord.Seq). It is
// advanced by startKind and raised to the journal maximum on every fold.
seq int64
}
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), openPayoutRecs: make(map[string][]*economyRecord)}
}
// syncDir fsyncs a directory so an entry created or replaced in it survives a
// crash (the same durability step persistence.WriteFile performs).
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
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
}
if !j.tailChecked {
if err := j.repairTornTail(); err != nil {
return fmt.Errorf("repair economy journal tail: %w", err)
}
j.tailChecked = true
}
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)
}
if !j.dirSynced {
if err := syncDir(filepath.Dir(j.path)); err != nil {
return fmt.Errorf("sync economy journal dir: %w", err)
}
j.dirSynced = true
}
return nil
}
// repairTornTail guarantees the journal ends on a newline before a new record
// is appended. A short write (ENOSPC, a flaky filesystem) can leave a partial
// JSON suffix; appending a full record after it merges the two into one
// malformed mid-file line, which loadFolded treats as fatal and which then
// permanently disables recovery and compaction. Truncating the torn suffix is
// safe: a partial record was never a committed transaction. This runs at most
// once per process (append is otherwise hot).
func (j *economyJournal) repairTornTail() error {
f, err := os.OpenFile(j.path, os.O_RDWR, 0600)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
size := fi.Size()
if size == 0 {
return nil
}
last := make([]byte, 1)
if _, err := f.ReadAt(last, size-1); err != nil {
return err
}
if last[0] == '\n' {
return nil
}
// Scan backwards in bounded chunks for the last newline and truncate the
// torn suffix after it. If none exists the whole file is one torn line.
const chunk = 64 * 1024
buf := make([]byte, chunk)
for end := size; end > 0; {
start := end - chunk
if start < 0 {
start = 0
}
n := int(end - start)
if _, err := f.ReadAt(buf[:n], start); err != nil {
return err
}
if idx := bytes.LastIndexByte(buf[:n], '\n'); idx >= 0 {
return f.Truncate(start + int64(idx) + 1)
}
end = start
}
return f.Truncate(0)
}
// 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, 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, nil, p, 0, credits, creditsBefore, creditsAfter, 0, 0)
}
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,
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.
j.seq++
rec.Seq = j.seq
if err := j.appendRetry(*rec, 5); err != nil {
return nil, err
}
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). The
// marker is retried and the record is left OPEN (with its tracking index
// intact) if no append succeeds: a non-durable settle must never drop the
// evidence a later recovery needs, or the liability is paid twice.
func (j *economyJournal) settle(rec *economyRecord) {
rec.Status = "settled"
if err := j.appendRetry(*rec, 3); err != nil {
rec.Status = "open"
return
}
j.closePayoutTracking(rec)
}
// 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
}
j.closePayoutTracking(rec)
return true
}
// void marks a record void (the mutation was reverted in memory and never
// became durable; recovery must not compensate). Like settle, the marker is
// retried and the record is left open on failure so its index/evidence is
// preserved for a later attempt.
func (j *economyJournal) void(rec *economyRecord) {
rec.Status = "void"
if err := j.appendRetry(*rec, 3); err != nil {
rec.Status = "open"
return
}
j.closePayoutTracking(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.
//
// 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
}
// 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
}
// A payout written BEFORE the stake cannot be its settlement: without
// this ordering check an old open payout (for example a failed slots
// win) would mask a later, unrelated stake and RefundOutstanding
// would resolve it without returning the money.
if payout.Seq <= stake.Seq {
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 {
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)
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
}
// 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. Ordering decisions
// (which record came first) are made from economyRecord.Seq — the record's
// creation order — never from the record's line position, which a later
// rewrite of the same record moves.
type foldedRecord struct {
rec economyRecord
}
// 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 {
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++ {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var rec economyRecord
if err := json.Unmarshal(line, &rec); err != nil {
// 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 rec.Seq > j.seq {
j.seq = rec.Seq
}
if existing, ok := latest[rec.ID]; ok {
existing.rec = rec
} else {
fr := &foldedRecord{rec: rec}
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 {
fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
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
}
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 {
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. The rename is made durable like the file's own
// sync, so a crash cannot lose the compacted journal entirely.
if err := os.Rename(tmp, j.path); err != nil {
os.Remove(tmp)
return
}
if err := syncDir(filepath.Dir(j.path)); err != nil {
fmt.Fprintf(os.Stderr, "economy compaction: sync journal dir: %v\n", err)
}
}
// 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 = casino.MaxCredits
return
}
p.Credits = sum
}
// addCreditsChecked is the game-side checked addition against the shared
// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second,
// independently drifting cap would let the two sides of the money path
// disagree about what a balance may hold).
func addCreditsChecked(a, b int) (int, error) {
if b > 0 && a > casino.MaxCredits-b {
return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.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.
// reanchorOwedPayout rewrites an open payout record's checkpoints to the
// current durable balance so a later recovery reads it as still owed (the
// balance sits at the record's before-checkpoint). It is used when a recovery
// run could not pay every sibling in an identical-signature group: the paid
// siblings moved the durable balance, so leaving the unpaid remainder at its
// original checkpoints would let a later run mistake the balance for its own
// durability and settle the liability without paying it. The post-checkpoint
// respects the economy cap: when the credit cannot fit, before == after, so
// the record keeps reading as owed at this balance instead of claiming an
// impossible post-state.
func reanchorOwedPayout(rec *economyRecord, credits, credit int) {
rec.CreditsBefore = credits
rec.CreditsAfter = credits
if after, err := addCreditsChecked(credits, credit); err == nil {
rec.CreditsAfter = after
}
}
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
}
// 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
// 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.
stakeEvidence := func(rec *economyRecord) (evidence bool, settlement bool) {
for _, fr := range folded {
// Ordering is by the record's CREATION Seq, not by the line of
// its latest write: a payout created before this stake is not
// its settlement even when a later rewrite of that payout
// (a settle marker, or a recovery checkpoint re-anchor) lands
// after the stake's line. Line order would let an old payout
// mask a later stake and silently erase its refund (see
// stakeCovered, which orders the same way).
if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.rec.Seq <= rec.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 evidence, 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 || paid > 0 {
// Either the credit cap blocked re-application, or an
// earlier sibling WAS paid before this record's save failed
// transiently. In both cases the durable balance already
// moved away from this record's original before-checkpoint,
// so re-anchor the checkpoints to the current durable balance
// — without this, the balance a PAID sibling moved reads as
// this record's own durability and the liability is silently
// settled instead of paid (and compaction then erases the
// settled sibling evidence, so a later run sees len(group)==1
// and settles the remainder as "already durable").
reanchorOwedPayout(other, 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 the FIRST re-application
// (paid == 0) and the balance still sits at the original
// before-checkpoint: keep the record open with its ORIGINAL
// checkpoints so a later recovery reads it as owed and retries
// cleanly. Re-anchoring here would make the record unreadable
// against the unchanged durable balance (before-checkpoint
// mismatch) and permanently ambiguous.
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 := stakeEvidence(rec)
// 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)
}
}
|