diff options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/account_registration_regression_test.go | 55 | ||||
| -rw-r--r-- | internal/game/core_login_account.go | 13 | ||||
| -rw-r--r-- | internal/game/economy.go | 71 | ||||
| -rw-r--r-- | internal/game/economy_recovery_regression_test.go | 77 |
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) + } +} |
