aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/account_registration_regression_test.go55
-rw-r--r--internal/game/core_login_account.go13
-rw-r--r--internal/game/economy.go71
-rw-r--r--internal/game/economy_recovery_regression_test.go77
4 files changed, 200 insertions, 16 deletions
diff --git a/internal/game/account_registration_regression_test.go b/internal/game/account_registration_regression_test.go
new file mode 100644
index 0000000..5ecd254
--- /dev/null
+++ b/internal/game/account_registration_regression_test.go
@@ -0,0 +1,55 @@
+package game
+
+// Registration regression: a pending new-account session whose typed name
+// was created in the meantime (by another concurrent registration) must be
+// refused at confirm time, never overwrite the existing account's password
+// or character list.
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+func TestPendingRegistrationCannotOverwriteExistingAccount(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+
+ a := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
+ b := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
+ h.g.RunOnOwner(func() {
+ // Both sessions type the same name while it does not exist yet;
+ // both reach the password-confirmation state.
+ h.g.handleAccountName(a, "wait")
+ h.g.handleAccountName(b, "wait")
+ h.g.handleNewPass(a, "firstpassword")
+ h.g.handleNewPass(b, "otherpassword")
+ // Session a wins the creation race...
+ h.g.handleNewAccount(a, "firstpassword")
+ // ...and a character is attached to the account before b confirms.
+ acc, err := h.g.AccountStore.LoadAccount("wait")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ acc.Characters = []string{p.Name}
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Error(err)
+ return
+ }
+ // Session b confirms second: this must not clobber the account.
+ h.g.handleNewAccount(b, "otherpassword")
+ })
+ acc, err := h.g.AccountStore.LoadAccount("wait")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !player.CheckPassword("firstpassword", acc.PasswordHash) {
+ t.Errorf("winner's password was overwritten by the losing registration")
+ }
+ if len(acc.Characters) != 1 {
+ t.Errorf("character list destroyed by the losing registration: %v", acc.Characters)
+ }
+}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index fbd9e3a..60cca2c 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -145,6 +145,19 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
return
}
+ // Re-check existence as part of the same owner step as the save. The
+ // name was validated as new when it was typed, but another session (or
+ // the admin API) may have created it in the meantime; saving without
+ // this check would overwrite that account's password and characters.
+ if _, findErr := g.AccountStore.FindAccount(sess.Account.Name); findErr == nil {
+ sess.Conn.SetEcho(true)
+ sess.Account = nil
+ sess.State = net.StateAccountName
+ sess.WriteLine("That account name was just taken; please sign in instead.")
+ sess.Write("Account name: ")
+ return
+ }
+
acc := &player.Account{
Name: sess.Account.Name,
PasswordHash: hash,
diff --git a/internal/game/economy.go b/internal/game/economy.go
index fd12d07..04508be 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -400,17 +400,26 @@ func (g *Game) recoverEconomy() {
switch {
case p.Credits == rec.CreditsBefore:
// The payout (and every identical sibling) was lost in the
- // crash: the player is owed them all.
+ // crash: the player is owed them all. Only the siblings whose
+ // re-application actually became durable are settled; a save
+ // failure (e.g. the credit cap) leaves the unpaid remainder open
+ // for operator inspection — a liability is never erased.
+ paid := 0
for m := 0; m < len(group); m++ {
if err := g.payCreditsDurable(p, rec.Credit); err != nil {
fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err)
break
}
+ paid++
compensated++
}
for _, other := range group {
+ if paid == 0 {
+ break
+ }
g.Economy.settle(other)
settled[other.ID] = true
+ paid--
}
case p.Credits == rec.CreditsAfter && len(group) == 1:
g.Economy.settle(rec)
@@ -435,37 +444,61 @@ func (g *Game) recoverEconomy() {
if p == nil {
continue
}
+ // Checkpoint comparisons use the DURABLE state, captured before any
+ // in-memory chip restoration below.
creditsNow, chipsNow := p.Credits, countChips(p)
+ // Chips are recovered independently of the credit checkpoints. Any
+ // state where the chip debit is durable but the chips have not come
+ // back (post-debit chip balance) still owes the chips, even when a
+ // later payout record already resolves the credit side. A durable
+ // chip refund always restores the balance to ChipsBefore, so this
+ // cannot double-return.
+ chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter
+ 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 && chipsNow == rec.ChipsAfter:
+ case creditsNow == rec.CreditsAfter:
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)
+ // 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).
+ // refund the stake (approved refund-on-recovery policy). The
+ // chips (if owed) are already restored in memory; one durable
+ // save covers both the chip return and the credit refund.
p.Credits += rec.Credit
- if rec.Chips > 0 && !g.giveChips(p, rec.Chips) {
- // No inventory room for the chip return: revert the credit
- // and keep the record open for operator inspection rather
- // than silently dropping the chips.
- p.Credits = creditsNow
- 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
- }
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 rec.Chips > 0 {
+ if chipsOwed {
p.RemoveItem("chips", rec.Chips)
}
fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err)
@@ -474,6 +507,12 @@ func (g *Game) recoverEconomy() {
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
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
index 738dece..12df39b 100644
--- a/internal/game/economy_recovery_regression_test.go
+++ b/internal/game/economy_recovery_regression_test.go
@@ -282,3 +282,80 @@ func TestRecoveryPreservesAmbiguousPayout(t *testing.T) {
t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open)
}
}
+
+// TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap: two identical open payout
+// records where re-applying the second would exceed the economy cap must
+// settle only the sibling that was actually paid. The unpaid 20-credit
+// liability is never erased: its record stays open for operator inspection.
+func TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = casino.MaxCredits - 30
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ for i := 0; i < 2; i++ {
+ if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil {
+ t.Error(err)
+ return
+ }
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != casino.MaxCredits-10 {
+ t.Fatalf("balance after capped recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 1 {
+ t.Fatalf("unpaid liability must stay open for operator inspection, open records: %+v", open)
+ }
+}
+
+// TestRecoveryRestoresChipsFromMixedRefundCrash: a mixed chips+credits
+// refund whose credit side is resolved by a later payout record but whose
+// chip side was never saved must still return the chips. The wager record
+// may be settled only once nothing is owed.
+func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice", "pw", false)
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ // Durable 20-credit (10 chips + 10 credits) wager: after the debit
+ // the file holds 90 credits and no chips.
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(20); err != nil {
+ t.Error(err)
+ return
+ }
+ // Crash during RefundOutstanding, after the credit-side payout was
+ // journaled but before anything beyond the debit was durable.
+ if _, err := h.g.Economy.start("payout", p, 0, 10, 90, 100, 10, 10); err != nil {
+ t.Error(err)
+ return
+ }
+ h.g.recoverEconomy()
+ })
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 100 || countChips(loaded) != 10 {
+ t.Fatalf("mixed refund after recovery = (%d credits, %d chips), want (100, 10); open records: %+v",
+ loaded.Credits, countChips(loaded), h.g.Economy.loadOpen())
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 0 {
+ t.Fatalf("nothing may stay owed after recovery: %+v", open)
+ }
+}