aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
blob: fcc0010d6c906794cc67232f93f29ed4ec75b981 (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
package game

// The economy journal: durable, idempotent money movement. Every casino
// wager and payout writes a journal record BEFORE the balance mutation,
// then the character save makes the balance durable.
//
// Two kinds of records exist:
//
//   - "wager": a committed stake. The record stays OPEN while the stake is
//     outstanding (the round is in flight); the game resolves it with a
//     settled marker when the round resolves (won, lost, pushed, or
//     forfeited). At startup, open wager records are refunded — the round
//     state was lost in the crash, so the stake returns to the player.
//   - "payout": money movement (winnings, pushes, refunds). The record
//     closes when the balance mutation is durable; an open payout at
//     startup is owed money and is re-applied.
//
// A crash at any point therefore leaves either:
//   - an open record with the character file at its pre-transaction state
//     (crash before save) — recovery settles it as void (debit) or applies
//     it (payout, which the player is owed); or
//   - an open wager with the file at its post-transaction state and no
//     later payout evidence (the round state was lost in the crash) — the
//     stake is refunded; or
//   - an open payout (crash after the journal write, before the save) —
//     the owed amount is re-applied.
//
// Records whose checkpoints match neither balance state after compensating
// earlier records are ambiguous: they are kept open and surfaced for
// operator inspection rather than silently settled or voided.
//
// The reducer folds journal lines by record ID (later lines win), so
// settlement markers are idempotent appends. Recovery runs once at startup
// before any session can join; compaction rewrites the journal keeping only
// open records (outstanding stakes and unresolved payouts).

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

	"thehouseoficarus/internal/player"
)

type economyRecord struct {
	ID     string    `json:"id"`
	Player string    `json:"player"`
	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"`

	// 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 name. All
	// journal mutations happen on the game owner (or during startup
	// recovery before any session connects), so no lock is needed.
	stakes map[string][]*economyRecord
}

func newEconomyJournal(dataDir string) *economyJournal {
	dir := filepath.Join(dataDir, "economy")
	_ = os.MkdirAll(dir, 0700)
	return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord)}
}

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

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

// 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) {
	rec := &economyRecord{
		ID:            newEconomyRecordID(),
		Player:        p.Name,
		Kind:          kind,
		Chips:         chips,
		Credit:        credits,
		Status:        "open",
		CreditsBefore: creditsBefore,
		CreditsAfter:  creditsAfter,
		ChipsBefore:   chipsBefore,
		ChipsAfter:    chipsAfter,
	}
	if err := j.append(*rec); err != nil {
		return nil, err
	}
	return rec, nil
}

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

// void marks a record void (the mutation was reverted in memory and never
// became durable; recovery must not compensate).
func (j *economyJournal) void(rec *economyRecord) {
	rec.Status = "void"
	_ = j.append(*rec)
}

// registerStake tracks an outstanding wager record for its player. Wager
// records stay open (refundable) until the round resolves.
func (j *economyJournal) registerStake(rec *economyRecord) {
	j.stakes[rec.Player] = append(j.stakes[rec.Player], rec)
}

// openStakes returns the outstanding stake records for a player.
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.
func (j *economyJournal) ResolveStakes(player string) {
	for _, rec := range j.stakes[player] {
		rec.Status = "settled"
		_ = j.append(*rec)
	}
	delete(j.stakes, player)
}

// reloadStakes rebuilds the outstanding-stake index from the journal file:
// open wager records remain tracked (and refundable/resolvable) after
// recovery compaction.
func (j *economyJournal) reloadStakes() {
	j.stakes = make(map[string][]*economyRecord)
	for _, fr := range j.loadFolded() {
		if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
			rec := fr.rec
			j.registerStake(&rec)
		}
	}
}

// loadOpen folds the journal and returns the still-open records.
func (j *economyJournal) loadOpen() []*economyRecord {
	var open []*economyRecord
	for _, fr := range j.loadFolded() {
		if fr.rec.Status == "open" {
			rec := fr.rec
			open = append(open, &rec)
		}
	}
	return open
}

// foldedRecord is the latest state of one record ID together with the
// order (line position of its latest write) it occupied in the journal.
type foldedRecord struct {
	rec economyRecord
	seq int
}

// loadFolded folds the journal (latest line per record ID wins) and
// returns the surviving records in journal append order.
func (j *economyJournal) loadFolded() []foldedRecord {
	f, err := os.Open(j.path)
	if err != nil {
		return nil
	}
	defer f.Close()
	latest := map[string]*foldedRecord{}
	var order []*foldedRecord
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for seq := 0; scanner.Scan(); seq++ {
		line := scanner.Bytes()
		if len(line) == 0 {
			continue
		}
		var rec economyRecord
		if err := json.Unmarshal(line, &rec); err != nil {
			// A torn final line (crash mid-append) is not a valid record;
			// skip it. Appends are small and single-line, so only the last
			// line can be torn.
			continue
		}
		if existing, ok := latest[rec.ID]; ok {
			existing.rec = rec
			existing.seq = seq
		} else {
			fr := &foldedRecord{rec: rec, seq: seq}
			latest[rec.ID] = fr
			order = append(order, fr)
		}
	}
	folded := make([]foldedRecord, 0, len(order))
	for _, fr := range order {
		folded = append(folded, *fr)
	}
	return folded
}

// compact rewrites the journal keeping only still-open records.
func (j *economyJournal) compact() {
	open := j.loadOpen()
	tmp := j.path + ".compact"
	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
	if err != nil {
		return
	}
	defer os.Remove(tmp)
	w := bufio.NewWriter(f)
	for _, rec := range open {
		rec.TS = time.Now()
		data, err := json.Marshal(rec)
		if err != nil {
			continue
		}
		w.Write(data)
		w.WriteByte('\n')
	}
	if err := w.Flush(); err != nil {
		f.Close()
		return
	}
	if err := f.Sync(); err != nil {
		f.Close()
		return
	}
	f.Close()
	// Best-effort atomic-ish replacement; a torn compaction leaves the
	// original file intact.
	if err := os.Rename(tmp, j.path); err != nil {
		os.Remove(tmp)
	}
}

// giveChips re-credits chips to a player's inventory (used by recovery
// refunds). Stacks onto an existing chips slot when one exists.
func (g *Game) giveChips(p *player.Player, qty int) {
	for _, slot := range p.Inventory {
		if slot != nil && slot.ItemID == "chips" {
			slot.Quantity += qty
			return
		}
	}
	slot := p.FirstFreeSlot()
	if slot >= 0 {
		p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty})
	}
}

// payCreditsDurable applies a checked credit increase and persists it.
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
	}
	p.Credits = sum
	return g.AccountStore.SaveCharacter(p)
}

// addCreditsChecked is the game-side checked addition against the economy
// cap (mirrors casino.MaxCredits; kept local to avoid a dependency cycle).
func addCreditsChecked(a, b int) (int, error) {
	const maxCredits = 1_000_000_000_000
	if b > 0 && a > maxCredits-b {
		return maxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, maxCredits)
	}
	if b < 0 {
		return 0, fmt.Errorf("negative credit add %d", b)
	}
	return a + b, nil
}

// recoverEconomy compensates open records from the last crash. It is called
// once from New, before any session connects.
//
// Recovery runs in two phases so checkpoints stay meaningful:
//
//  1. Open PAYOUT records are processed in append order against the evolving
//     durable balance: file at the pre-payout balance → the payout is
//     re-applied (the player is owed it); at the post-payout balance → it is
//     already durable and settles; anything else stays open and is surfaced
//     for operator inspection (a liability is never silently dropped).
//  2. Open WAGER records are processed NEWEST FIRST (each refund restores
//     the balance an earlier record's checkpoints refer to). A record at
//     its pre-debit state (credits AND chips) never became durable → void;
//     at its post-debit state → the stake was charged but the round state
//     was lost in the crash → the stake is refunded, unless a payout record
//     written later proves the round resolved (a win/push/refund already
//     returned the stake inside that payout) → settled with no refund.
//     Anything else stays open for operator inspection.
func (g *Game) recoverEconomy() {
	if g.Economy == nil {
		return
	}
	folded := g.Economy.loadFolded()
	players := make(map[string]*player.Player)
	loadPlayer := func(name string) *player.Player {
		if p, ok := players[name]; ok {
			return p
		}
		p, err := g.AccountStore.LoadCharacter(name)
		if err != nil {
			// Character file unreadable: leave the record open so a later
			// attempt (or operator inspection) can resolve it; do not
			// silently drop money.
			fmt.Fprintf(os.Stderr, "economy recovery: load %s: %v\n", name, err)
			return nil
		}
		players[name] = p
		return p
	}
	hasLaterPayout := func(name string, seq int) bool {
		for _, fr := range folded {
			if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq {
				return true
			}
		}
		return false
	}
	compensated := 0

	// Phase 1 — payouts, in append order.
	for i := range folded {
		rec := &folded[i].rec
		if rec.Kind != "payout" || rec.Status != "open" {
			continue
		}
		p := loadPlayer(rec.Player)
		if p == nil {
			continue
		}
		switch {
		case p.Credits == rec.CreditsBefore:
			// The payout was lost in the crash: the player is owed it.
			if err := g.payCreditsDurable(p, rec.Credit); err != nil {
				fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
				continue
			}
			g.Economy.settle(rec)
			compensated++
		case p.Credits == rec.CreditsAfter:
			g.Economy.settle(rec)
		default:
			fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
				rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
		}
	}

	// Phase 2 — outstanding wagers, newest first.
	var openWagers []int
	for i := range folded {
		if folded[i].rec.Kind == "wager" && folded[i].rec.Status == "open" {
			openWagers = append(openWagers, i)
		}
	}
	for k := len(openWagers) - 1; k >= 0; k-- {
		rec := &folded[openWagers[k]].rec
		resolvedByPayout := hasLaterPayout(rec.Player, folded[openWagers[k]].seq)
		p := loadPlayer(rec.Player)
		if p == nil {
			continue
		}
		creditsNow, chipsNow := p.Credits, countChips(p)
		switch {
		case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore:
			// The debit never became durable: void it.
			g.Economy.void(rec)
		case creditsNow == rec.CreditsAfter && chipsNow == rec.ChipsAfter:
			if resolvedByPayout {
				// A later payout record proves the round resolved (the win,
				// push, or refund already returned this stake inside that
				// payout). Only the resolution marker was lost.
				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).
			p.Credits += rec.Credit
			if rec.Chips > 0 {
				g.giveChips(p, rec.Chips)
			}
			if err := g.AccountStore.SaveCharacter(p); err != nil {
				fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err)
				continue
			}
			g.Economy.settle(rec)
			compensated++
		default:
			if resolvedByPayout {
				g.Economy.settle(rec)
				continue
			}
			fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n",
				rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter)
		}
	}
	g.Economy.compact()
	// Rebuild the outstanding-stake index from whatever open wager records
	// remain (normally none; ambiguous ones stay refundable/resolvable).
	g.Economy.reloadStakes()
}