aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 15:16:44 -0400
committerhistoria <[not public]>2026-09-18 15:16:44 -0400
commit7eab582d8a8ee2af1a6baae7baf0696c7005de2e (patch)
tree902bbc8c6e39c9f62f779ca23267714ec420b9f5 /internal/game/economy.go
parent153acb7f644d755c45533b7c5311e3ffa47e8973 (diff)
downloadthehouseoficarus-7eab582d8a8ee2af1a6baae7baf0696c7005de2e.tar.gz
fix: harden triggers, economy, casino, world data, and admin/net paths
Diffstat (limited to 'internal/game/economy.go')
-rw-r--r--internal/game/economy.go68
1 files changed, 68 insertions, 0 deletions
diff --git a/internal/game/economy.go b/internal/game/economy.go
index b436a29..3505a86 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -43,6 +43,7 @@ package game
import (
"bufio"
+ "bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -113,6 +114,12 @@ type economyJournal struct {
// 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
@@ -166,6 +173,12 @@ func (j *economyJournal) append(rec economyRecord) error {
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)
@@ -186,6 +199,61 @@ func (j *economyJournal) append(rec economyRecord) error {
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.