aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-06 12:57:23 -0400
committerhistoria <[not public]>2026-10-06 12:57:23 -0400
commit9666676766bf21364f4fc4d0d1d336e72f2a91b4 (patch)
tree0cade34091d7602790dc38cd44053f0b74bbadf2 /internal/game
parent4bc1daaf259e0c19c5fe4a12ec8bc7f6e5a735c1 (diff)
downloadthehouseoficarus-9666676766bf21364f4fc4d0d1d336e72f2a91b4.tar.gz
refactor: split casino.go, dedup helpers, remove audit docs
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/account_txn.go4
-rw-r--r--internal/game/act_combine.go35
-rw-r--r--internal/game/act_state_test.go2
-rw-r--r--internal/game/act_steal.go6
-rw-r--r--internal/game/act_trigger_module.go2
-rw-r--r--internal/game/baccarat_rules_test.go2
-rw-r--r--internal/game/baseline_race_test.go2
-rw-r--r--internal/game/blackjack_rules.go31
-rw-r--r--internal/game/blackjack_rules_test.go4
-rw-r--r--internal/game/casino.go622
-rw-r--r--internal/game/casino_commands.go3
-rw-r--r--internal/game/casino_house_edge.go2
-rw-r--r--internal/game/casino_ux.go21
-rw-r--r--internal/game/casino_wallet.go252
-rw-r--r--internal/game/cmd_alias.go2
-rw-r--r--internal/game/cmd_colortable.go2
-rw-r--r--internal/game/cmd_dig.go30
-rw-r--r--internal/game/cmd_estate_directory.go22
-rw-r--r--internal/game/cmd_global.go7
-rw-r--r--internal/game/cmd_goto.go15
-rw-r--r--internal/game/cmd_hide.go32
-rw-r--r--internal/game/cmd_look.go6
-rw-r--r--internal/game/cmd_move.go22
-rw-r--r--internal/game/cmd_option.go7
-rw-r--r--internal/game/cmd_prompt.go7
-rw-r--r--internal/game/cmd_remove.go30
-rw-r--r--internal/game/cmd_room.go12
-rw-r--r--internal/game/cmd_room_insert.go15
-rw-r--r--internal/game/cmd_say.go7
-rw-r--r--internal/game/cmd_summon.go15
-rw-r--r--internal/game/cmd_trigger_utility.go20
-rw-r--r--internal/game/combat_attack.go13
-rw-r--r--internal/game/core_login_account.go23
-rw-r--r--internal/game/core_login_char.go5
-rw-r--r--internal/game/exit_display.go6
-rw-r--r--internal/game/exit_migrate.go9
-rw-r--r--internal/game/fix_game_regression_test.go53
-rw-r--r--internal/game/game.go17
-rw-r--r--internal/game/hardening_tick_lifecycle_test.go12
-rw-r--r--internal/game/login_offowner_regression_test.go2
-rw-r--r--internal/game/look_casino.go2
-rw-r--r--internal/game/look_casino_test.go8
-rw-r--r--internal/game/look_target.go9
-rw-r--r--internal/game/money.go14
-rw-r--r--internal/game/owner.go15
-rw-r--r--internal/game/owner_goexit_test.go26
-rw-r--r--internal/game/production_engine.go43
-rw-r--r--internal/game/render_baccarat_felt.go4
-rw-r--r--internal/game/render_blackjack_felt.go2
-rw-r--r--internal/game/render_cards.go330
-rw-r--r--internal/game/render_casino_settlement.go14
-rw-r--r--internal/game/render_color.go16
-rw-r--r--internal/game/render_craps.go3
-rw-r--r--internal/game/render_felt_precision_test.go41
-rw-r--r--internal/game/render_help.go8
-rw-r--r--internal/game/render_map.go21
-rw-r--r--internal/game/render_plinko.go2
-rw-r--r--internal/game/render_prompt.go18
-rw-r--r--internal/game/render_roulette.go14
-rw-r--r--internal/game/render_roulette_tracker.go2
-rw-r--r--internal/game/render_slots.go86
-rw-r--r--internal/game/sys_combat.go9
-rw-r--r--internal/game/sys_combat_test.go6
-rw-r--r--internal/game/sys_escape_test.go38
-rw-r--r--internal/game/sys_safespot.go23
-rw-r--r--internal/game/sys_science.go3
-rw-r--r--internal/game/sys_technology.go2
-rw-r--r--internal/game/sys_triggers.go28
-rw-r--r--internal/game/video_poker_cabinet_test.go6
-rw-r--r--internal/game/wallet_refund_kind_test.go40
-rw-r--r--internal/game/wallet_test.go1
71 files changed, 1302 insertions, 911 deletions
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index 77a4219..32c02f8 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -411,6 +411,10 @@ func (g *Game) publishAccountUpdate(oldName, newName string) {
Options: acc.Options,
Admin: acc.Admin,
}
+ // A session still sitting at the password prompt for the OLD name must
+ // authorize against the NEW name on its next submit, or it loads the
+ // stale name and fails with "Error loading account.".
+ sess.SetAuthName(newName)
if sess.Account.Aliases == nil {
sess.Account.Aliases = make(map[string]string)
}
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index d7aed04..a50d310 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -18,6 +18,21 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
return
}
+ // See startProduction: combine recipes can start outside the crafting
+ // menus, so the station/tool gate must live at the execution site.
+ if len(craft.Station) > 0 {
+ if stID, _ := g.findStation(p.RoomID, craft.Station); stID == "" {
+ sess.WriteLine("You are not near the required station.")
+ g.reprompt(sess)
+ return
+ }
+ }
+ if craft.Tool != "" && !g.hasToolType(p, craft.Tool) {
+ sess.WriteLine("You do not have the required tool.")
+ g.reprompt(sess)
+ return
+ }
+
outDef, _ := g.ItemStore.Load(item.ID)
outputName := item.ID
if outDef != nil {
@@ -47,13 +62,12 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item
g.broadcastAction(sess, "%s starts combining.", p.Name)
}
-// combineSkill resolves the skill a combine recipe trains. Combine recipes
-// declare type "combine", which is an action, not a skill: player.SkillName
-// of it is the literal "combine", so p.Level would read a map miss as level 1
-// when rolling success and awardSkillXP would persist a phantom "combine"
-// skill entry. A recipe that declares a real skill name is honored; anything
-// else trains Crafting, the skill combine work belongs to.
-func combineSkill(craft *item.CraftDef) player.SkillName {
+// recipeSkill resolves the skill a recipe trains and rolls against. A recipe
+// type is an action name, not necessarily a skill: player.SkillName of e.g.
+// "combine" or "cleaning" is a literal action, so p.Level would read a map
+// miss as level 1 and AddSkillXP would persist a phantom skill key. A recipe
+// that declares a real skill name is honored; anything else trains Crafting.
+func recipeSkill(craft *item.CraftDef) player.SkillName {
skill := player.SkillName(craft.EffectiveSkill())
if player.ValidSkill(skill) {
return skill
@@ -61,6 +75,13 @@ func combineSkill(craft *item.CraftDef) player.SkillName {
return player.Crafting
}
+// combineSkill resolves the skill a combine recipe trains. Combine recipes
+// declare type "combine", which is an action, not a skill: the shared
+// recipeSkill fallback keeps success rolls and XP on Crafting.
+func combineSkill(craft *item.CraftDef) player.SkillName {
+ return recipeSkill(craft)
+}
+
func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
d, ok := p.Action.Data.(*behavior.CombineData)
if !ok {
diff --git a/internal/game/act_state_test.go b/internal/game/act_state_test.go
index 4182c61..75b82ec 100644
--- a/internal/game/act_state_test.go
+++ b/internal/game/act_state_test.go
@@ -7,7 +7,7 @@ import (
)
func TestPlayerActionDisplayFightingInCombat(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
inst := spawnTestMob(g, 100)
p := player.New("combatant")
p.EscapeDir = "north"
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 7df400b..50c83ee 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -146,6 +146,12 @@ func (g *Game) resolveStealTarget(p *player.Player, input string) (targetType st
})
if len(matchingMobs) > 0 {
+ // Out-of-range 1-based selectors are no-target, matching findMob. The
+ // previous fallthrough silently stole from the first mob for a single
+ // candidate and reported "nothing here" only for 2+ candidates.
+ if instanceIdx >= 0 && instanceIdx >= len(matchingMobs) {
+ return "", nil, nil, "There's nothing here to steal from."
+ }
uniqueDefs := make(map[string]bool)
for _, m := range matchingMobs {
uniqueDefs[m.DefID] = true
diff --git a/internal/game/act_trigger_module.go b/internal/game/act_trigger_module.go
index 467cb83..c222dd7 100644
--- a/internal/game/act_trigger_module.go
+++ b/internal/game/act_trigger_module.go
@@ -44,7 +44,7 @@ func (g *Game) advanceTriggerModule(sess *net.Session, p *player.Player) {
} else {
g.triggerModReward(sess, p, mod, 1.0)
if g.Combat.Get(p.Name) != nil {
- g.Combat.Leave(p.Name)
+ g.leaveCombat(p)
}
switch mod.Category {
case ModTransport:
diff --git a/internal/game/baccarat_rules_test.go b/internal/game/baccarat_rules_test.go
index 63e2dc2..34f8a79 100644
--- a/internal/game/baccarat_rules_test.go
+++ b/internal/game/baccarat_rules_test.go
@@ -35,7 +35,7 @@ func TestLookBaccaratFixtureWithoutScenery(t *testing.T) {
}
}
}
- if g.lookBaccaratTable(sess, "other") {
+ if g.lookCasinoTable(sess, "other", casino.GameBaccarat) {
t.Fatal("matched unrelated target")
}
}
diff --git a/internal/game/baseline_race_test.go b/internal/game/baseline_race_test.go
index 4efc907..e0b28f3 100644
--- a/internal/game/baseline_race_test.go
+++ b/internal/game/baseline_race_test.go
@@ -45,7 +45,9 @@ func TestBaselineRaceSessionInputTickDisconnect(t *testing.T) {
}()
// Disconnect bob from a third goroutine while everything else runs.
+ wg.Add(1)
go func() {
+ defer wg.Done()
for i := 0; i < 50; i++ {
h.hub.PlayersInRoom(1)
if i == 25 {
diff --git a/internal/game/blackjack_rules.go b/internal/game/blackjack_rules.go
index f163fb8..2a0712d 100644
--- a/internal/game/blackjack_rules.go
+++ b/internal/game/blackjack_rules.go
@@ -7,37 +7,6 @@ import (
"thehouseoficarus/internal/net"
)
-// Tables are room fixtures in their own right, not authored scenery objects.
-// Each game resolves through the shared details view; the felt games append
-// their per-table rule card there.
-func (g *Game) lookBlackjackTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameBlackjack)
-}
-
-func (g *Game) lookBaccaratTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameBaccarat)
-}
-
-func (g *Game) lookCrapsTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameCraps)
-}
-
-func (g *Game) lookBigSixTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameBigSix)
-}
-
-func (g *Game) lookRouletteTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GameRoulette)
-}
-
-func (g *Game) lookPlinkoTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GamePlinko)
-}
-
-func (g *Game) lookPaiGowTable(sess *net.Session, target string) bool {
- return g.lookCasinoTable(sess, target, casino.GamePaiGow)
-}
-
// A cabinet's published pay table is public; another player's cards are not.
func (g *Game) lookVideoPokerCabinet(sess *net.Session, target string) bool {
if g.Casino == nil || sess.Player == nil {
diff --git a/internal/game/blackjack_rules_test.go b/internal/game/blackjack_rules_test.go
index 47d057c..ebf5f64 100644
--- a/internal/game/blackjack_rules_test.go
+++ b/internal/game/blackjack_rules_test.go
@@ -26,7 +26,7 @@ func TestLookBlackjackFixtureWithoutScenery(t *testing.T) {
sess.Player.RoomID = 1
for _, target := range []string{"blackjack", "blackjack table", "table", "test", "test table"} {
conn.data = nil
- if !g.lookBlackjackTable(sess, target) {
+ if !g.lookCasinoTable(sess, target, casino.GameBlackjack) {
t.Fatalf("did not match %q", target)
}
text := plainRoulette(string(conn.data))
@@ -36,7 +36,7 @@ func TestLookBlackjackFixtureWithoutScenery(t *testing.T) {
}
}
}
- if g.lookBlackjackTable(sess, "other") {
+ if g.lookCasinoTable(sess, "other", casino.GameBlackjack) {
t.Fatal("matched unrelated target")
}
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 87b7176..a0cc675 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -1,211 +1,15 @@
package game
import (
- "errors"
"fmt"
- "os"
"strconv"
"strings"
- "unicode/utf8"
- "thehouseoficarus/internal/cardart"
"thehouseoficarus/internal/casino"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
- "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/player"
)
-type casinoWallet struct {
- g *Game
- p *player.Player
- // Optional per-wallet persistence seam for fault-injection tests.
- saveCharacter func(*player.Player) error
-}
-
-// save treats a completed replacement as applied, even if its durability is
-// uncertain. Reverting memory then would disagree with the visible file and
-// could overwrite a paid win or resurrect a resolved stake on the next save.
-// Report the storage fault, but do not tell the game to retry/refund an
-// already-applied mutation. Only pre-replacement failures require rollback.
-func (w casinoWallet) save(operation string) error {
- save := w.saveCharacter
- if save == nil {
- save = w.g.AccountStore.SaveCharacter
- }
- err := save(w.p)
- if errors.Is(err, persistence.ErrDurabilityUncertain) {
- fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err)
- return nil
- }
- return err
-}
-
-// Wager debits the stake and records it in PendingWagers in the SAME atomic
-// character save. The entry stays until the round resolves: a settlement
-// removes the stakes it returns (PayCredits) or consumes them
-// (ResolveStake/ResolveStakes), and startup refunds anything still listed
-// (see recoverPendingWagers). A pre-replacement save failure reverts the
-// debit and ledger entry; an uncertain replacement keeps both (see save).
-func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
- if amount <= 0 {
- return casino.Wager{}, casino.ErrInsufficientFunds
- }
- creditsBefore := w.p.Credits
- chips := countChips(w.p)
- if chips > amount {
- chips = amount
- }
- credits := amount - chips
- if creditsBefore < credits {
- return casino.Wager{}, casino.ErrInsufficientFunds
- }
- if chips > 0 {
- w.p.RemoveItem("chips", chips)
- }
- w.p.Credits -= credits
- pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits}
- w.p.PendingWagers = append(w.p.PendingWagers, pending)
- if err := w.save("wager"); err != nil {
- // Revert the in-memory mutation: the play must not proceed on a
- // stake that could not be made durable.
- if chips > 0 {
- if !w.g.giveChips(w.p, chips) {
- // Cannot happen (the debit just freed or reduced the chips
- // stack), but surface it instead of silently losing chips.
- fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips)
- }
- }
- w.p.Credits = creditsBefore
- removePendingWager(w.p, pending.ID)
- return casino.Wager{}, casino.ErrWalletPersistence
- }
- return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
-}
-
-// PayCredits banks a payout durably and, in the SAME save, resolves the
-// listed stakes. stakeIDs may be empty for a payout that returns no stake (a
-// free-spin bank already refunded by RefundOutstanding). A table settlement
-// passes the hand's stake IDs so the credit and the stake return are one
-// atomic step; a slot win passes its triggering stake for the same reason.
-// Either way a crash can never pay the payout AND refund the same stake. A
-// pre-replacement failure reverts both the credit and the ledger and reports
-// failure. An uncertain replacement is applied and logged (see save).
-func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error {
- return w.payCredits(amount, stakeIDs)
-}
-
-// payCredits credits the payout and, when stakeIDs is non-empty, removes
-// those stakes from the pending ledger in the same atomic save. On a
-// pre-replacement failure the credit and the ledger are reverted together.
-func (w casinoWallet) payCredits(amount int, stakeIDs []string) error {
- if amount <= 0 && len(stakeIDs) == 0 {
- return nil
- }
- creditsBefore := w.p.Credits
- sum, capErr := addCreditsChecked(creditsBefore, amount)
- if capErr != nil {
- return capErr
- }
- var removed []player.PendingWager
- if len(stakeIDs) > 0 {
- removed = takePendingWagers(w.p, stakeIDs)
- }
- w.p.Credits = sum
- if err := w.save("payout"); err != nil {
- w.p.Credits = creditsBefore
- w.p.PendingWagers = append(w.p.PendingWagers, removed...)
- return casino.ErrWalletPersistence
- }
- return nil
-}
-
-// ResolveStake consumes ONE pending stake by wallet handle: its hand lost or
-// was paid by a cover, so it must never be refunded. A settlement that
-// already removed it (PayCredits) makes this a no-op, so no redundant
-// save is paid.
-func (w casinoWallet) ResolveStake(stakeID string) {
- if stakeID == "" {
- return
- }
- removed := takePendingWagers(w.p, []string{stakeID})
- if len(removed) == 0 {
- return
- }
- if err := w.save("resolve stake"); err != nil {
- // The durable file still lists the stake, so restore it in memory to
- // keep the two records consistent for a later retry/recovery.
- w.p.PendingWagers = append(w.p.PendingWagers, removed...)
- fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
- }
-}
-
-// ResolveStakesByIDs consumes the listed pending stakes in one durable save.
-// A losing split/doubled hand used to call ResolveStake once per handle, each
-// with its own full character save; batching removes that repeated fsync and
-// the crash window between saves.
-func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) {
- if len(stakeIDs) == 0 {
- return
- }
- removed := takePendingWagers(w.p, stakeIDs)
- if len(removed) == 0 {
- return
- }
- if err := w.save("resolve stakes"); err != nil {
- // The durable file still lists the stakes, so restore them in memory to
- // keep the two records consistent for a later retry/recovery.
- w.p.PendingWagers = append(w.p.PendingWagers, removed...)
- fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
- }
-}
-
-// RefundOutstanding returns every pending stake to the player — chips in
-// kind, credits as credits — and clears the ledger in one save. It is used
-// by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement
-// failure reverts the return and ledger for retry; an uncertain replacement
-// keeps both applied (see save).
-func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
- before := append([]player.PendingWager(nil), w.p.PendingWagers...)
- for _, pending := range before {
- chips += pending.Chips
- credits += pending.Credits
- }
- if chips == 0 && credits == 0 {
- return 0, 0, nil
- }
- creditsBefore := w.p.Credits
- w.p.PendingWagers = nil
- if chips > 0 {
- if !w.g.giveChips(w.p, chips) {
- w.p.PendingWagers = before
- return 0, 0, casino.ErrWalletPersistence
- }
- }
- sum, capErr := addCreditsChecked(creditsBefore, credits)
- if capErr != nil {
- if chips > 0 {
- w.p.RemoveItem("chips", chips)
- }
- w.p.PendingWagers = before
- return 0, 0, casino.ErrWalletPersistence
- }
- w.p.Credits = sum
- if err := w.save("refund"); err != nil {
- w.p.Credits = creditsBefore
- if chips > 0 {
- w.p.RemoveItem("chips", chips)
- }
- w.p.PendingWagers = before
- return 0, 0, casino.ErrWalletPersistence
- }
- return chips, credits, nil
-}
-
-// casinoAffordable is the most a player can wager: chips are spent first,
-// then credits.
-func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) }
-
// leaveCasinoGames departs every casino game the player participates in
// using the graceful leave policy each game defines for player-initiated
// exits (the same path the "stop" command takes): a table with a wager
@@ -349,18 +153,13 @@ func (g *Game) executePlay(sess *net.Session, args []string, rawInput string) {
g.showCasinoChoices(sess, choices)
return
}
- if len(args) > 1 {
- amount, err := strconv.Atoi(args[1])
- if !choice.table || len(args) > 3 || (args[1] != "min" && args[1] != "max" && (err != nil || amount <= 0)) {
- sess.WriteLine("Use play GAME [N|min|max] [on BET]. No wager was placed.")
- return
- }
- }
g.startCasinoGame(sess, p, choice.address)
if len(args) > 1 {
- if t, joined := g.Casino.Participation(p.Name); joined && t != nil {
- g.executeBet(sess, args[1:], rawInput)
- }
+ // Let executeBet parse the wager/target tokens. Pre-validating here
+ // rejected the advertised "play GAME N on BET" form (len > 3) and any
+ // machine amount (choice.table is false for machines); executeBet's own
+ // parser already produces precise errors for each game's syntax.
+ g.executeBet(sess, args[1:], rawInput)
}
}
@@ -667,8 +466,8 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
// casinoMenuParts resolves a structured menu into the shared header/balance
// heading/commands used by both menu render paths: the balance line, the
-// heading (falling back to the event message), and a copy of the commands
-// annotated with an unavailability note for anything the player can't afford.
+// heading (falling back to the event message), and a copy of the commands.
+// Unaffordable commands are filtered at render time by casinoCommandColumns.
func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message string) (balance, heading string, commands []casino.MenuCommand) {
balance = ""
if menu.ShowBalance {
@@ -682,11 +481,9 @@ func (g *Game) casinoMenuParts(sess *net.Session, menu *casino.MenuView, message
if !g.casinoHintsVisible(sess, menu) {
commands = nil
}
- for i := range commands {
- if commands[i].Cost > casinoAffordable(sess.Player) {
- commands[i].Desc += " [unavailable: insufficient funds]"
- }
- }
+ // Unaffordable commands are hidden by casinoCommandColumns; an
+ // "unavailable: insufficient funds" annotation here would be dead, since
+ // the renderer drops exactly those commands with the same test.
return balance, heading, commands
}
@@ -726,7 +523,7 @@ func (g *Game) casinoBoardReminder(sess *net.Session, category string, menu *cas
if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && (!isRoulette || s.Phase == "playing" || s.Phase == "result") {
return "", false
}
- if !isCashHunt && !isPlinko && !isPoker && !isCraps && !isPaiGow && !isBigSix && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
+ if isRoulette && sess.Player.OptionString("casino_display") == "compact" && (menu.Repeat || !menu.ShowBalance) {
// The compact reminder is already a short block; leave it intact.
return "", false
}
@@ -881,400 +678,3 @@ func (g *Game) casinoMenuHeader(sess *net.Session, balance, heading string) stri
func (g *Game) casinoMenuCommands(sess *net.Session, commands []casino.MenuCommand) string {
return g.casinoCommandColumns(sess, commands)
}
-
-func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
- if snapshot == nil {
- return ""
- }
- size = cardRenderSize(unicode, size)
- var lines []string
- dealerTitle := "Dealer:"
- if snapshot.DealerRevealed {
- dealerTitle = "Dealer (" + snapshot.DealerScore + "):"
- } else if snapshot.DealerShownScore != "" {
- dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
- }
- if size == 1 {
- lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " "))
- } else {
- lines = append(lines, cardLine(mode, dealerTitle))
- lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
- }
- for _, player := range snapshot.Players {
- name := player.Name
- if name == viewer {
- name = "You"
- }
- marker := " "
- if player.Active {
- marker = "> "
- }
- detail := ""
- if player.Hand > 0 {
- detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status)
- }
- if player.Active && player.Name != viewer {
- detail = strings.ReplaceAll(detail, "Your turn", "Playing")
- }
- title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail)
- if size == 1 {
- lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " "))
- } else {
- lines = append(lines, cardLine(mode, title))
- lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
- }
- }
- return strings.Join(lines, "\n")
-}
-
-// renderBaccaratSnapshot draws the player and banker hands followed by the
-// list of wagers riding on the round.
-func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
- if snapshot == nil {
- return ""
- }
- size = cardRenderSize(unicode, size)
- var lines []string
- if size == 1 {
- lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " "))
- } else {
- lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
- lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
- lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
- lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
- }
- for _, bet := range snapshot.Bets {
- lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot)))
- }
- return strings.Join(lines, "\n")
-}
-
-func cardLine(mode, text string) string {
- spec := color.NoColor()
- spec.Fg = 39
- return color.Render(mode, spec, text)
-}
-
-func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
- if len(cards) == 0 {
- return nil
- }
- if size == 1 {
- parts := make([]string, len(cards))
- for i, card := range cards {
- if !unicode {
- if card.Hidden {
- parts[i] = "[??]"
- } else {
- suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit]
- parts[i] = card.Rank + suit
- }
- } else {
- parts[i] = colorizeCardSymbol(mode, card)
- }
- }
- return []string{strings.Join(parts, " ")}
- }
- artLines := make([][]string, len(cards))
- for i, card := range cards {
- artLines[i] = cardArtLines(card, size, unicode, style, art)
- }
- lines := make([]string, size)
- for row := 0; row < size; row++ {
- var lineParts []string
- for i, card := range artLines {
- lineParts = append(lineParts, colorizeCardLine(mode, card[row], cards[i], row, size, style))
- }
- lines[row] = strings.Join(lineParts, " ")
- }
- return lines
-}
-
-// renderCardRow lays cards side by side, each column padded to its own art
-// or label width, and appends an optional centered label row. minWidth lets
-// the caller force a floor (video poker's HELD labels need it) while Pai Gow
-// passes 0. It is the shared "cards with numbers underneath" presentation.
-func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string {
- if len(cards) == 0 {
- return nil
- }
- artLines := make([][]string, len(cards))
- widths := make([]int, len(cards))
- height := 0
- for i, card := range cards {
- artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art)
- height = max(height, len(artLines[i]))
- widths[i] = minWidth
- for _, line := range artLines[i] {
- widths[i] = max(widths[i], color.VisibleLen(line))
- }
- if i < len(labels) {
- widths[i] = max(widths[i], color.VisibleLen(labels[i]))
- }
- }
- lines := make([]string, 0, height+1)
- for row := 0; row < height; row++ {
- parts := make([]string, len(cards))
- for i := range cards {
- text := ""
- if row < len(artLines[i]) {
- text = artLines[i][row]
- }
- parts[i] = rouletteCenter(text, widths[i])
- }
- lines = append(lines, strings.Join(parts, " "))
- }
- if len(labels) > 0 {
- parts := make([]string, len(cards))
- for i := range cards {
- text := ""
- if i < len(labels) {
- text = labels[i]
- }
- parts[i] = rouletteCenter(text, widths[i])
- }
- lines = append(lines, strings.Join(parts, " "))
- }
- return lines
-}
-
-func cardRenderSize(unicode bool, size int) int {
- if size == 1 {
- return 1
- }
- if !unicode {
- return 5
- }
- if size == 1 || size == 5 || size == 7 || size == 9 {
- return size
- }
- return 7
-}
-
-var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣", "joker": "★"}
-
-func cardSymbol(card casino.CardView) string {
- if card.Hidden {
- return "?"
- }
- return card.Rank + suitSymbols[card.Suit]
-}
-
-func cardArtLines(card casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
- if card.Hidden {
- return generatedCardArt("?", "", size, unicode, style)
- }
- key := card.Rank
- if card.Rank == "JK" {
- return jokerCardArt(size, unicode, style, art)
- }
- if card.Suit != "" {
- key += strings.ToUpper(card.Suit[:1])
- }
- if configured := art.Art(key, size); len(configured) == size {
- if unicode && style == "light" {
- // The shared pip art draws a dark box frame, which leaves the top
- // and bottom rows looking like the dark style. Replace only those
- // two rows with the half-block edges the white body meets.
- configured = append([]string(nil), configured...)
- configured[0] = strings.Repeat("▄", utf8.RuneCountInString(configured[0]))
- configured[size-1] = strings.Repeat("▀", utf8.RuneCountInString(configured[size-1]))
- }
- return configured
- }
- return generatedCardArt(card.Rank, card.Suit, size, unicode, style)
-}
-
-func generatedCardArt(rank, suit string, size int, unicode bool, style string) []string {
- if size < 5 {
- size = 5
- }
- border := "-"
- left, right := "+", "+"
- if unicode {
- border, left, right = "─", "┌", "┐"
- }
- bottomLeft, bottomRight := left, right
- if unicode {
- bottomLeft, bottomRight = "└", "┘"
- }
- vertical := "|"
- if unicode {
- vertical = "│"
- }
- if unicode && style == "light" {
- lines := []string{strings.Repeat("▄", size)}
- lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
- return append(lines, strings.Repeat("▀", size))
- }
- lines := []string{left + strings.Repeat(border, size-2) + right}
- lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
- lines = append(lines, bottomLeft+strings.Repeat(border, size-2)+bottomRight)
- return lines
-}
-
-func generatedCardBody(rank, suit string, size int, unicode bool, vertical string) []string {
- inner := size - 2
- lines := make([]string, 0, size-2)
- for row := 1; row < size-1; row++ {
- content := strings.Repeat(" ", inner)
- if row == 1 {
- content = rank + strings.Repeat(" ", inner-len(rank))
- }
- if row == (size-1)/2 {
- suitText := ""
- if suit != "" {
- suitText = suitLetter(suit, unicode)
- }
- suitWidth := utf8.RuneCountInString(suitText)
- leftPadding := (inner - suitWidth) / 2
- content = strings.Repeat(" ", leftPadding) + suitText + strings.Repeat(" ", inner-leftPadding-suitWidth)
- }
- if row == size-2 {
- content = strings.Repeat(" ", inner-len(rank)) + rank
- }
- lines = append(lines, vertical+content+vertical)
- }
- return lines
-}
-
-func suitLetter(suit string, unicode bool) string {
- if !unicode {
- return strings.ToUpper(suit[:1])
- }
- return suitSymbols[suit]
-}
-
-func colorizeCardLine(mode, line string, card casino.CardView, row, size int, style string) string {
- red := card.Suit == "hearts" || card.Suit == "diamonds"
- rankColor := 27
- if red {
- rankColor = 196
- }
- suitColor := 27
- if red {
- suitColor = 196
- }
- rankLine := row == 1 || row == size-2
- light := style == "light"
- bodyBackground := light && row > 0 && row < size-1
- var builder strings.Builder
- for _, r := range line {
- if strings.ContainsRune("┌─┐└┘│┬┴├┤┼╔╗╚╝═║╦╩╠╣╬▀▄+|-", r) {
- spec := color.ColorSpec{Fg: 15}
- if bodyBackground {
- spec.Bg = 15
- }
- builder.WriteString(color.Render(mode, spec, string(r)))
- } else if r == ' ' {
- if bodyBackground {
- builder.WriteString(color.Render(mode, color.ColorSpec{Fg: 15, Bg: 15}, " "))
- } else {
- builder.WriteRune(r)
- }
- } else {
- value := suitColor
- if rankLine {
- value = rankColor
- }
- spec := color.ColorSpec{Fg: value}
- if bodyBackground {
- spec.Bg = 15
- }
- builder.WriteString(color.Render(mode, spec, string(r)))
- }
- }
- return builder.String()
-}
-
-func colorizeCardSymbol(mode string, card casino.CardView) string {
- if card.Hidden {
- return color.Render(mode, color.ColorSpec{Fg: 15}, "?")
- }
- rankColor, suitColor := 27, 27
- if card.Suit == "hearts" || card.Suit == "diamonds" {
- rankColor, suitColor = 196, 196
- }
- runes := []rune(cardSymbol(card))
- rankLength := len([]rune(card.Rank))
- return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) +
- color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:]))
-}
-
-func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string {
- if snapshot == nil {
- return ""
- }
- left, junction, right, horizontal, vertical := '+', '+', '+', '-', '|'
- bottomLeft, bottomJunction, bottomRight := '+', '+', '+'
- if unicode {
- left, junction, right, horizontal, vertical = '\u250c', '\u252c', '\u2510', '\u2500', '\u2502'
- bottomLeft, bottomJunction, bottomRight = '\u2514', '\u2534', '\u2518'
- }
- separator := string(left)
- for reel := 0; reel < 5; reel++ {
- if reel > 0 {
- separator += string(junction)
- }
- separator += strings.Repeat(string(horizontal), 9)
- }
- separator += string(right)
- lines := []string{casinoFrame(mode, separator)}
- frameVertical := casinoFrame(mode, string(vertical))
- for row := 0; row < 3; row++ {
- line := frameVertical
- for reel := 0; reel < 5; reel++ {
- value := "..."
- if reel < snapshot.StoppedReels {
- value = snapshot.Grid[row][reel]
- }
- // Pad to the fixed 7-cell reel width; a malformed profile could
- // otherwise make this negative and panic. Validation caps the id
- // length, so this is a defence-in-depth backstop.
- pad := 7 - len(value)
- if pad < 0 {
- pad = 0
- }
- line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical
- }
- lines = append(lines, line)
- }
- bottom := string(bottomLeft)
- for reel := 0; reel < 5; reel++ {
- if reel > 0 {
- bottom += string(bottomJunction)
- }
- bottom += strings.Repeat(string(horizontal), 9)
- }
- bottom += string(bottomRight)
- lines = append(lines, casinoFrame(mode, bottom))
- return strings.Join(lines, "\n")
-}
-
-func casinoFrame(mode, text string) string {
- spec := color.NoColor()
- spec.Fg = 244
- return color.Render(mode, spec, text)
-}
-
-func colorSlotSymbol(mode, symbol string) string {
- spec := color.NoColor()
- spec.Fg = 250
- switch symbol {
- case "straw":
- spec.Fg = 220
- case "stick":
- spec.Fg = 46
- case "brick":
- spec.Fg = 196
- case "hat":
- spec.Fg = 201
- case "wolf":
- spec.Fg = 39
- case "scatter":
- spec.Fg = 226
- default:
- spec.Dim = true
- }
- return color.Render(mode, spec, symbol)
-}
diff --git a/internal/game/casino_commands.go b/internal/game/casino_commands.go
index b6deb21..95259c0 100644
--- a/internal/game/casino_commands.go
+++ b/internal/game/casino_commands.go
@@ -88,6 +88,9 @@ func (g *Game) casinoCommandColumns(sess *net.Session, commands []casino.MenuCom
// Input is uncolored command/description text; paint only after measuring.
func casinoWrapWords(text string, width int) []string {
+ if width < 1 {
+ width = 1
+ }
var lines []string
line := ""
for _, word := range strings.Fields(text) {
diff --git a/internal/game/casino_house_edge.go b/internal/game/casino_house_edge.go
index ff3ce59..2bd1f4d 100644
--- a/internal/game/casino_house_edge.go
+++ b/internal/game/casino_house_edge.go
@@ -157,7 +157,7 @@ func (g *Game) showCasinoListTable(sess *net.Session, betHeader string, rows []c
width = min(width, wrap)
}
}
- budget := max(len(headers), width-3*len(headers)-1)
+ budget := ui.TableBudget(len(headers), width)
// Size the pre-wrapped cells with the same shrink algorithm Table.Render
// uses, instead of a second copy that can disagree on which column yields.
ui.FitWidths(widths, budget)
diff --git a/internal/game/casino_ux.go b/internal/game/casino_ux.go
index e2b74db..88562cd 100644
--- a/internal/game/casino_ux.go
+++ b/internal/game/casino_ux.go
@@ -115,8 +115,8 @@ func (g *Game) showCasinoChoices(sess *net.Session, choices []casinoChoice) {
// renderCasinoFelt draws a table's felt (layout, payouts, board) without the
// prose rule text. The table command shows this; rules opens the help page.
-// Craps and roulette already render their felt through the live status
-// snapshot, so they return nothing here.
+// Games that draw their felt through Board() (craps, roulette, Big Six,
+// Plinko, Cash Hunt, video poker) return nothing here.
func (g *Game) renderCasinoFelt(sess *net.Session, cfg casino.TableConfig) string {
switch cfg.Game {
case casino.GameBlackjack:
@@ -143,13 +143,22 @@ func (g *Game) executeTableStatus(sess *net.Session, args []string, _ string) {
topic = "slots"
}
g.doHelp(sess, topic)
- } else {
- g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID))
+ return
+ }
+ // A machine player has no table (Participation returns nil for
+ // machines), but bare table/status must still describe the machine they
+ // occupy instead of the room's "choose a game" list.
+ if playing {
+ if machine, ok := g.Casino.MachineFor(sess.Player.Name); ok {
+ sess.WriteLine(g.renderCasinoCommands(sess, g.casinoBalance(sess), machine.Config.ID, machine.Hints()))
+ return
+ }
}
+ g.showCasinoChoices(sess, g.casinoChoices(sess.Player.RoomID))
return
}
- // table help remains a shortcut to the seated game's help page; the old
- // "table rules" subcommand is gone, so bare table always shows the felt.
+ // Only "help" is special-cased here; any other argument is ignored and the
+ // table/status command falls through to the felt below.
if len(args) > 0 && args[0] == "help" {
g.doHelp(sess, t.HelpTopic())
if t.Config.Game != casino.GameVideoPoker {
diff --git a/internal/game/casino_wallet.go b/internal/game/casino_wallet.go
new file mode 100644
index 0000000..2ec0dea
--- /dev/null
+++ b/internal/game/casino_wallet.go
@@ -0,0 +1,252 @@
+package game
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/persistence"
+ "thehouseoficarus/internal/player"
+)
+
+type casinoWallet struct {
+ g *Game
+ p *player.Player
+ // Optional per-wallet persistence seam for fault-injection tests.
+ saveCharacter func(*player.Player) error
+}
+
+// save treats a completed replacement as applied, even if its durability is
+// uncertain. Reverting memory then would disagree with the visible file and
+// could overwrite a paid win or resurrect a resolved stake on the next save.
+// Report the storage fault, but do not tell the game to retry/refund an
+// already-applied mutation. Only pre-replacement failures require rollback.
+func (w casinoWallet) save(operation string) error {
+ save := w.saveCharacter
+ if save == nil {
+ save = w.g.AccountStore.SaveCharacter
+ }
+ err := save(w.p)
+ if errors.Is(err, persistence.ErrDurabilityUncertain) {
+ fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err)
+ return nil
+ }
+ return err
+}
+
+// Wager debits the stake and records it in PendingWagers in the SAME atomic
+// character save. The entry stays until the round resolves: a settlement
+// removes the stakes it returns (PayCredits) or consumes them
+// (ResolveStake/ResolveStakes), and startup refunds anything still listed
+// (see recoverPendingWagers). A pre-replacement save failure reverts the
+// debit and ledger entry; an uncertain replacement keeps both (see save).
+func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
+ if amount <= 0 {
+ return casino.Wager{}, casino.ErrInsufficientFunds
+ }
+ creditsBefore := w.p.Credits
+ chips := countChips(w.p)
+ if chips > amount {
+ chips = amount
+ }
+ credits := amount - chips
+ if creditsBefore < credits {
+ return casino.Wager{}, casino.ErrInsufficientFunds
+ }
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.Credits -= credits
+ pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits}
+ w.p.PendingWagers = append(w.p.PendingWagers, pending)
+ if err := w.save("wager"); err != nil {
+ // Revert the in-memory mutation: the play must not proceed on a
+ // stake that could not be made durable.
+ if chips > 0 {
+ if !w.g.giveChips(w.p, chips) {
+ // Cannot happen (the debit just freed or reduced the chips
+ // stack), but surface it instead of silently losing chips.
+ fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips)
+ }
+ }
+ w.p.Credits = creditsBefore
+ removePendingWager(w.p, pending.ID)
+ return casino.Wager{}, casino.ErrWalletPersistence
+ }
+ return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
+}
+
+// PayCredits banks a payout durably and, in the SAME save, resolves the
+// listed stakes. stakeIDs may be empty for a payout that returns no stake (a
+// free-spin bank already refunded by RefundOutstanding). A table settlement
+// passes the hand's stake IDs so the credit and the stake return are one
+// atomic step; a slot win passes its triggering stake for the same reason.
+// Either way a crash can never pay the payout AND refund the same stake. A
+// pre-replacement failure reverts both the credit and the ledger and reports
+// failure. An uncertain replacement is applied and logged (see save).
+func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error {
+ return w.payCredits(amount, stakeIDs)
+}
+
+// payCredits credits the payout and, when stakeIDs is non-empty, removes
+// those stakes from the pending ledger in the same atomic save. On a
+// pre-replacement failure the credit and the ledger are reverted together.
+func (w casinoWallet) payCredits(amount int, stakeIDs []string) error {
+ if amount <= 0 && len(stakeIDs) == 0 {
+ return nil
+ }
+ creditsBefore := w.p.Credits
+ sum, capErr := addCreditsChecked(creditsBefore, amount)
+ if capErr != nil {
+ return capErr
+ }
+ var removed []player.PendingWager
+ if len(stakeIDs) > 0 {
+ removed = takePendingWagers(w.p, stakeIDs)
+ // Fail closed like PayPoker: a payout resolves the stakes it names. If
+ // any stake is already gone (or duplicated away), crediting anyway
+ // would double-pay with no ledger entry for startup recovery to see.
+ if len(removed) != countUniqueNonEmpty(stakeIDs) {
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ }
+ w.p.Credits = sum
+ if err := w.save("payout"); err != nil {
+ w.p.Credits = creditsBefore
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ return nil
+}
+
+// ResolveStake consumes ONE pending stake by wallet handle: its hand lost or
+// was paid by a cover, so it must never be refunded. A settlement that
+// already removed it (PayCredits) makes this a no-op, so no redundant
+// save is paid.
+func (w casinoWallet) ResolveStake(stakeID string) {
+ if stakeID == "" {
+ return
+ }
+ removed := takePendingWagers(w.p, []string{stakeID})
+ if len(removed) == 0 {
+ return
+ }
+ if err := w.save("resolve stake"); err != nil {
+ // The durable file still lists the stake, so restore it in memory to
+ // keep the two records consistent for a later retry/recovery.
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
+ }
+}
+
+// ResolveStakesByIDs consumes the listed pending stakes in one durable save.
+// A losing split/doubled hand used to call ResolveStake once per handle, each
+// with its own full character save; batching removes that repeated fsync and
+// the crash window between saves.
+func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) {
+ if len(stakeIDs) == 0 {
+ return
+ }
+ removed := takePendingWagers(w.p, stakeIDs)
+ if len(removed) == 0 {
+ return
+ }
+ if err := w.save("resolve stakes"); err != nil {
+ // The durable file still lists the stakes, so restore them in memory to
+ // keep the two records consistent for a later retry/recovery.
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
+ }
+}
+
+// RefundOutstanding returns every pending stake to the player — chips in
+// kind, credits as credits — and clears the ledger in one save. It is used
+// by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement
+// failure reverts the return and ledger for retry; an uncertain replacement
+// keeps both applied (see save).
+func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
+ before := append([]player.PendingWager(nil), w.p.PendingWagers...)
+ for _, pending := range before {
+ chips += pending.Chips
+ credits += pending.Credits
+ }
+ if chips == 0 && credits == 0 {
+ return 0, 0, nil
+ }
+ creditsBefore := w.p.Credits
+ w.p.PendingWagers = nil
+ if chips > 0 {
+ if !w.g.giveChips(w.p, chips) {
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ }
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ w.p.Credits = sum
+ if err := w.save("refund"); err != nil {
+ w.p.Credits = creditsBefore
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = before
+ return 0, 0, casino.ErrWalletPersistence
+ }
+ return chips, credits, nil
+}
+
+// RefundStakes returns the named pending stakes in kind — chips as chips,
+// credits as credits — and clears them from the ledger in one save. It is the
+// game-side take-back path: using PayCredits here would convert casino-only
+// chips into spendable credits.
+func (w casinoWallet) RefundStakes(stakeIDs ...string) error {
+ if len(stakeIDs) == 0 {
+ return nil
+ }
+ removed := takePendingWagers(w.p, stakeIDs)
+ if len(removed) == 0 {
+ return nil
+ }
+ var chips, credits int
+ for _, pending := range removed {
+ chips += pending.Chips
+ credits += pending.Credits
+ }
+ creditsBefore := w.p.Credits
+ if chips > 0 {
+ if !w.g.giveChips(w.p, chips) {
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ }
+ sum, capErr := addCreditsChecked(creditsBefore, credits)
+ if capErr != nil {
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ w.p.Credits = sum
+ if err := w.save("refund stakes"); err != nil {
+ w.p.Credits = creditsBefore
+ if chips > 0 {
+ w.p.RemoveItem("chips", chips)
+ }
+ w.p.PendingWagers = append(w.p.PendingWagers, removed...)
+ return casino.ErrWalletPersistence
+ }
+ return nil
+}
+
+// casinoAffordable is the most a player can wager: chips are spent first,
+// then credits.
+func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) }
diff --git a/internal/game/cmd_alias.go b/internal/game/cmd_alias.go
index 33d8de7..9e30db6 100644
--- a/internal/game/cmd_alias.go
+++ b/internal/game/cmd_alias.go
@@ -9,7 +9,7 @@ import (
)
func (g *Game) executeAlias(sess *net.Session, args []string, rawInput string) {
- g.doAlias(sess, args)
+ g.doAlias(sess, rawArgsFrom(rawInput, "alias"))
}
func (g *Game) executeUnalias(sess *net.Session, args []string, rawInput string) {
diff --git a/internal/game/cmd_colortable.go b/internal/game/cmd_colortable.go
index d228a34..3a5d353 100644
--- a/internal/game/cmd_colortable.go
+++ b/internal/game/cmd_colortable.go
@@ -10,7 +10,7 @@ import (
func (g *Game) doColortable(sess *net.Session) {
mode := g.colorMode(sess)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
sess.WriteLine("Enable colors first: option color ansi (or xterm256)")
return
}
diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go
index ff33490..9ca5f10 100644
--- a/internal/game/cmd_dig.go
+++ b/internal/game/cmd_dig.go
@@ -86,20 +86,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
sess.WriteLine(fmt.Sprintf("That spot is already occupied by %s. Created a two-way link instead.", conflictName))
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = conflictID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(conflictID)
+ g.relocatePlayer(sess, p, conflictID)
g.saveCharacter(p)
g.World.SeedGroundItems(conflictID)
@@ -182,20 +169,7 @@ func (g *Game) executeDig(sess *net.Session, args []string, rawInput string) {
}
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = newID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(newID)
+ g.relocatePlayer(sess, p, newID)
g.saveCharacter(p)
g.World.SeedGroundItems(newID)
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index 83c5b80..f29f671 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -205,26 +205,22 @@ func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
}
}
- // A teleport changes rooms just like a walk, so it must clear any safespot
- // cover: isSafespotted/safespotBlocksMelee/safespotBlocksHazard compare no
- // room, so leaving the state active grants melee and hazard immunity in the
- // destination and keeps the player as a phantom occupant of the old cover.
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
+ // A teleport changes rooms just like a walk: clear the safespot cover and
+ // every in-flight move/action/combat/sequence so no stale state follows the
+ // player into the destination or drags them back later (see relocatePlayer).
+ g.relocatePlayer(sess, p, roomID)
+ g.saveCharacter(p)
- p.RoomID = roomID
- p.Stats.RecordRoomVisit(roomID)
+ g.World.SeedGroundItems(p.RoomID)
+ g.seedRoomMobs(p.RoomID)
+ g.seedRoomObjects(p.RoomID)
if g.Hub != nil {
g.Hub.LeaveRoom(sess)
g.Hub.EnterRoom(sess, p.RoomID)
}
- g.World.SeedGroundItems(p.RoomID)
- g.seedRoomMobs(p.RoomID)
- g.seedRoomObjects(p.RoomID)
- g.saveCharacter(p)
g.doLook(sess)
g.runEnterSteps(sess, p.RoomID)
+ g.checkAggro(sess)
g.writePrompt(sess)
}
diff --git a/internal/game/cmd_global.go b/internal/game/cmd_global.go
index 32577c3..ec44b99 100644
--- a/internal/game/cmd_global.go
+++ b/internal/game/cmd_global.go
@@ -13,12 +13,7 @@ func (g *Game) executeGlobal(sess *net.Session, args []string, rawInput string)
return
}
- msgStart := strings.Index(strings.ToLower(rawInput), "global ") + 7
- if msgStart >= 7 && msgStart < len(rawInput) {
- g.doGlobal(sess, rawInput[msgStart:])
- } else {
- g.doGlobal(sess, strings.Join(args, " "))
- }
+ g.doGlobal(sess, strings.Join(rawArgsFrom(rawInput, "global"), " "))
}
func (g *Game) doGlobal(sess *net.Session, msg string) {
diff --git a/internal/game/cmd_goto.go b/internal/game/cmd_goto.go
index 96984b9..b3b7558 100644
--- a/internal/game/cmd_goto.go
+++ b/internal/game/cmd_goto.go
@@ -31,20 +31,7 @@ func (g *Game) executeGoto(sess *net.Session, args []string, rawInput string) {
}
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = roomID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(roomID)
+ g.relocatePlayer(sess, p, roomID)
g.saveCharacter(p)
g.World.SeedGroundItems(roomID)
diff --git a/internal/game/cmd_hide.go b/internal/game/cmd_hide.go
index b5d38fe..59ddf9e 100644
--- a/internal/game/cmd_hide.go
+++ b/internal/game/cmd_hide.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "strconv"
"strings"
"thehouseoficarus/internal/net"
@@ -26,13 +27,29 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
target = strings.Join(args[1:], " ")
}
- instances := g.World.FindObjInstances(p.RoomID, target)
+ // The disambiguation prompt prints "N.name", so accept the same "N."
+ // selector every sibling verb supports (act.go, look_target.go).
+ instanceIdx := -1
+ searchName := strings.ToLower(target)
+ if dotPos := strings.Index(searchName, "."); dotPos > 0 {
+ if n, err := strconv.Atoi(searchName[:dotPos]); err == nil && n > 0 {
+ instanceIdx = n - 1
+ searchName = searchName[dotPos+1:]
+ }
+ }
+
+ instances := g.World.FindObjInstances(p.RoomID, searchName)
if len(instances) == 0 {
sess.WriteLine("You don't see that here.")
return
}
- if len(instances) > 1 && objectInstancesNeedDisambiguation(instances) {
+ if instanceIdx >= 0 && instanceIdx >= len(instances) {
+ sess.WriteLine("You don't see that here.")
+ return
+ }
+
+ if instanceIdx < 0 && len(instances) > 1 && objectInstancesNeedDisambiguation(instances) {
sess.WriteLines(
"That's ambiguous, which one?",
formatObjInstanceChoices(instances),
@@ -40,14 +57,19 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
return
}
- objDef, err := g.ObjectStore.Load(instances[0].DefID)
+ chosen := instances[0]
+ if instanceIdx >= 0 {
+ chosen = instances[instanceIdx]
+ }
+
+ objDef, err := g.ObjectStore.Load(chosen.DefID)
if err != nil || !objDef.IsSafespot() {
sess.WriteLine("You can't hide behind that.")
return
}
cfg := objDef.Safespot
- st := g.World.GetObjState(p.RoomID, objDef.ID, instances[0].Index)
+ st := g.World.GetObjState(p.RoomID, objDef.ID, chosen.Index)
if st == nil {
sess.WriteLine("You can't hide behind that.")
return
@@ -56,7 +78,7 @@ func (g *Game) executeHide(sess *net.Session, args []string, rawInput string) {
existingSS, alreadyHiding := g.safespot.Get(p.Name)
if alreadyHiding && existingSS.Active {
- if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == instances[0].Index {
+ if existingSS.ObjectDefID == objDef.ID && existingSS.ObjectIndex == chosen.Index {
if existingSS.HideCountdown > 0 {
sess.WriteLine("You're already moving into position there.")
} else {
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 92aeea6..e6ed5d3 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -26,6 +26,12 @@ func (g *Game) doLook(sess *net.Session) {
if wrapWidth < 80 {
wrapWidth = 80
}
+ if wrapWidth > maxMapDimension {
+ // Do not trust the persisted option: writeLookSideBySide pads every
+ // content line to the wrap width, so an unbounded value is a
+ // one-command OOM (the same clamp map_width already has).
+ wrapWidth = maxMapDimension
+ }
var mapLines []string
mapOn := p.OptionString("tiny_map") != "off"
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 35cccba..96c2d2d 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -233,6 +233,28 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
g.checkAggro(sess)
}
+// relocatePlayer tears down every in-flight movement, combat, action, rest and
+// sequence state before moving p to roomID. Admin/trigger teleports bypass the
+// normal move queue, so they must run the same teardown completeMove/goto
+// perform or a stashed move/escape can later drag the player back and combat
+// can follow them across rooms.
+func (g *Game) relocatePlayer(sess *net.Session, p *player.Player, roomID int) {
+ p.ClearMoveState()
+ if g.Combat.Get(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+ g.cancelAction(p)
+ g.cancelBgAction(p)
+ g.cancelRest(p.Name)
+ g.cancelSequences(p.Name)
+ if ss, ok := g.safespot.Get(p.Name); ok {
+ g.forceLeaveSafespot(sess, p, &ss, "")
+ }
+ p.RoomID = roomID
+ p.HazardTimer = 0
+ p.Stats.RecordRoomVisit(roomID)
+}
+
func (g *Game) seedRoomMobs(roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil || len(room.Mobs) == 0 {
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 1bfd1b5..41346fb 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -62,6 +62,11 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
value := strings.ToLower(parts[1])
+ if def.Type == player.OptString && len(def.ValidValues) == 0 {
+ // Free-text string options take the whole remaining input verbatim
+ // ("option visual_tick_text Tick Tock" stores "Tick Tock").
+ value = strings.Join(parts[1:], " ")
+ }
if def.Name == "card_size" {
switch value {
case "1", "5", "7", "9":
@@ -112,7 +117,7 @@ func (g *Game) doOption(sess *net.Session, input string) {
}
func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) {
- g.doOption(sess, strings.Join(args, " "))
+ g.doOption(sess, strings.Join(rawArgsFrom(rawInput, "option"), " "))
}
func formatOptionValue(p *player.Player, def *player.OptionDef) string {
diff --git a/internal/game/cmd_prompt.go b/internal/game/cmd_prompt.go
index b613f44..de7b063 100644
--- a/internal/game/cmd_prompt.go
+++ b/internal/game/cmd_prompt.go
@@ -11,12 +11,7 @@ func (g *Game) executePrompt(sess *net.Session, args []string, rawInput string)
if len(args) == 0 {
g.doHelp(sess, "prompt")
} else {
- msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7
- if msgStart >= 7 && msgStart < len(rawInput) {
- g.doPrompt(sess, rawInput[msgStart:])
- } else {
- g.doPrompt(sess, strings.Join(args, " "))
- }
+ g.doPrompt(sess, strings.Join(rawArgsFrom(rawInput, "prompt"), " "))
}
}
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index d03daec..8ea21a9 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -150,7 +150,21 @@ func (g *Game) doRemoveAll(sess *net.Session) {
return
}
- needed := len(p.Equipment)
+ // Ammo routes through removeAllAmmo, which merges into an existing
+ // matching-quality stack and therefore needs no fresh slot. Count only the
+ // entries that will actually consume a slot so a mergeable ammo stack does
+ // not make the pre-gate report a false "no room".
+ needed := 0
+ for _, slot := range EquipSlots {
+ itemID, ok := p.Equipment[slot]
+ if !ok {
+ continue
+ }
+ if slot == item.SlotAmmo && p.AmmoQty > 0 && g.ammoHasMergeableStack(p, itemID) {
+ continue
+ }
+ needed++
+ }
if needed > p.FreeSlots() {
sess.WriteLine("You don't have room to remove everything!")
return
@@ -232,3 +246,17 @@ func (g *Game) removeAllAmmo(sess *net.Session, p *player.Player, itemID string)
}
sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name)))
}
+
+// ammoHasMergeableStack reports whether removeAllAmmo will merge the equipped
+// ammo stack into an existing inventory stack with the same freshly-picked
+// properties (see pickedStackProps), which consumes no free slot.
+func (g *Game) ammoHasMergeableStack(p *player.Player, itemID string) bool {
+ quality, maxQuality := g.pickedStackProps(itemID)
+ for i := 0; i < player.MaxInventorySlots; i++ {
+ s := p.InvSlot(i)
+ if s != nil && s.ItemID == itemID && s.Quality == quality && s.MaxQuality == maxQuality {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/game/cmd_room.go b/internal/game/cmd_room.go
index 33627eb..8a76e81 100644
--- a/internal/game/cmd_room.go
+++ b/internal/game/cmd_room.go
@@ -37,10 +37,10 @@ func (g *Game) executeRoom(sess *net.Session, args []string, rawInput string) {
case "addobj", "remobj", "hide", "unhide":
g.roomObject(sess, action, rest)
case "addlocalobj":
- name, aliases, desc := parseLocalObjArgs(rawInput, "addlocalobj")
+ name, aliases, desc := parseLocalObjArgs(strings.Join(rawArgsFrom(rawInput, "room")[1:], " "))
g.roomAddLocalObj(sess, name, aliases, desc)
case "remlocalobj":
- name, _, _ := parseLocalObjArgs(rawInput, "remlocalobj")
+ name, _, _ := parseLocalObjArgs(strings.Join(rawArgsFrom(rawInput, "room")[1:], " "))
g.roomRemLocalObj(sess, name)
case "addmob", "remmob":
g.roomMob(sess, action, rest)
@@ -162,12 +162,8 @@ func (g *Game) loadRoomWrite(p *player.Player) (*world.Room, string, error) {
return room, path, nil
}
-func parseLocalObjArgs(rawInput, action string) (name string, aliases []string, desc string) {
- prefix := "room " + action
- rest := rawInput
- if idx := strings.Index(strings.ToLower(rest), prefix); idx >= 0 {
- rest = strings.TrimSpace(rest[idx+len(prefix):])
- }
+func parseLocalObjArgs(rest string) (name string, aliases []string, desc string) {
+ rest = strings.TrimSpace(rest)
if rest == "" {
return "", nil, ""
}
diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go
index 8087cca..713e6b3 100644
--- a/internal/game/cmd_room_insert.go
+++ b/internal/game/cmd_room_insert.go
@@ -172,20 +172,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) {
g.World.AddRoomPath(newID, newPath)
oldRoom := p.RoomID
- p.ClearMoveState()
- if g.Combat.Get(p.Name) != nil {
- g.stopCombat(p.Name)
- }
- p.Action = nil
- g.cancelRest(p.Name)
- g.cancelSequences(p.Name)
- if ss, ok := g.safespot.Get(p.Name); ok {
- g.forceLeaveSafespot(sess, p, &ss, "")
- }
-
- p.RoomID = newID
- p.HazardTimer = 0
- p.Stats.RecordRoomVisit(newID)
+ g.relocatePlayer(sess, p, newID)
g.saveCharacter(p)
g.World.SeedGroundItems(newID)
diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go
index b7d3982..61ece74 100644
--- a/internal/game/cmd_say.go
+++ b/internal/game/cmd_say.go
@@ -11,12 +11,7 @@ func (g *Game) executeSay(sess *net.Session, args []string, rawInput string) {
if len(args) == 0 {
sess.WriteLine("Say what?")
} else {
- msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4
- if msgStart >= 4 && msgStart < len(rawInput) {
- g.doSay(sess, rawInput[msgStart:])
- } else {
- g.doSay(sess, strings.Join(args, " "))
- }
+ g.doSay(sess, strings.Join(rawArgsFrom(rawInput, "say"), " "))
}
}
diff --git a/internal/game/cmd_summon.go b/internal/game/cmd_summon.go
index 6477b11..306d467 100644
--- a/internal/game/cmd_summon.go
+++ b/internal/game/cmd_summon.go
@@ -46,20 +46,7 @@ func (g *Game) executeSummon(sess *net.Session, args []string, rawInput string)
adminRoom := adminP.RoomID
oldRoom := tp.RoomID
- tp.ClearMoveState()
- if g.Combat.Get(tp.Name) != nil {
- g.stopCombat(tp.Name)
- }
- tp.Action = nil
- g.cancelRest(tp.Name)
- g.cancelSequences(tp.Name)
- if ss, ok := g.safespot.Get(tp.Name); ok {
- g.forceLeaveSafespot(targetSess, tp, &ss, "")
- }
-
- tp.RoomID = adminRoom
- tp.HazardTimer = 0
- tp.Stats.RecordRoomVisit(adminRoom)
+ g.relocatePlayer(targetSess, tp, adminRoom)
g.saveCharacter(tp)
g.World.SeedGroundItems(adminRoom)
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index fa81f23..6b502cf 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -2,6 +2,7 @@ package game
import (
"fmt"
+ "sort"
"strings"
"thehouseoficarus/internal/item"
@@ -118,8 +119,15 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t
// Reservation-aware: only items this player may take are considered, so
// em grab cannot magnetize kill loot reserved for another player.
groundItems := g.World.AccessibleGround(p.RoomID, p.Name)
- var matchedID string
+ itemIDs := make([]string, 0, len(groundItems))
for itemID := range groundItems {
+ itemIDs = append(itemIDs, itemID)
+ }
+ // Map iteration is randomized; pick deterministically so `em grab <name>`
+ // always resolves the same matching stack.
+ sort.Strings(itemIDs)
+ var matchedID string
+ for _, itemID := range itemIDs {
def, _ := g.ItemStore.Load(itemID)
if def != nil && def.MatchesName(targetArg) {
matchedID = itemID
@@ -276,7 +284,12 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
return
}
- consumeIngredients(c, p.CountItem, p.RemoveItem)
+ if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
+ // planIngredients reserves stock across entries, so a weak pre-check
+ // (duplicate entries) can fail here. Abort without producing output.
+ sess.WriteLine("You don't have the required materials.")
+ return
+ }
outputQty := c.OutputQty
if outputQty <= 0 {
@@ -381,7 +394,8 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef
return
}
- for logID, info := range logTypes {
+ for _, logID := range []string{"logs", "oak_logs", "teak_logs", "mahogany_logs"} {
+ info := logTypes[logID]
qty := p.CountItem(logID)
if qty == 0 {
continue
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index 180038b..3cbb9c8 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -116,6 +116,7 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn
if idx-1 < len(candidates) {
return candidates[idx-1]
}
+ sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input))
return nil
}
@@ -134,6 +135,14 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn
return candidates[0]
}
+// leaveCombat ends the player's combat and clears any stashed flee state.
+// Combat.Leave alone leaves EscapeDir/Target/IsWalk set with no move in flight,
+// so the next mob miss would start an unprompted escape in the stale direction.
+func (g *Game) leaveCombat(p *player.Player) {
+ g.Combat.Leave(p.Name)
+ p.ClearEscape()
+}
+
func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) {
// One player fights one mob at a time. doAttack and the aggro path already
// check this, but act_steal/act_talk call startCombat directly; without the
@@ -447,7 +456,7 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
if isRanged && p.AmmoQty <= 0 {
sess.WriteLine("You've run out of ammo!")
- g.Combat.Leave(p.Name)
+ g.leaveCombat(p)
}
}
@@ -458,7 +467,7 @@ func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.M
g.consumeAmmo(p)
if p.AmmoQty <= 0 {
sess.WriteLine("You've run out of ammo!")
- g.Combat.Leave(p.Name)
+ g.leaveCombat(p)
}
}
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index 0224a06..67e692d 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -49,6 +49,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
// after the account is gone.
func (g *Game) resetToAccountName(sess *net.Session) {
sess.Account = nil
+ sess.SetAuthName("")
sess.PendingChar = ""
sess.PendingMenu = nil
sess.PendingPass = ""
@@ -361,15 +362,21 @@ func (g *Game) handleColorChoice(sess *net.Session, input string) {
}
input = strings.ToLower(strings.TrimSpace(input))
if input == "y" || input == "yes" {
- sess.Account.Options["color"] = "none"
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
- if err == nil {
- if acc.Options == nil {
- acc.Options = make(map[string]any)
- }
- acc.Options["color"] = "none"
- g.saveAccount(acc)
+ if err != nil {
+ sess.WriteLine("Error loading account.")
+ g.resetToAccountName(sess)
+ return
+ }
+ if acc.Options == nil {
+ acc.Options = make(map[string]any)
}
+ acc.Options["color"] = "none"
+ g.saveAccount(acc)
+ if sess.Account.Options == nil {
+ sess.Account.Options = make(map[string]any)
+ }
+ sess.Account.Options["color"] = "none"
}
g.showMenu(sess)
}
@@ -479,6 +486,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
sess.Write("New name: ")
return
}
+ sess.PendingChar = ""
sess.SetState(net.StateRenameChar)
sess.WriteLine("\nRename which character?")
for i, name := range sess.Account.Characters {
@@ -495,6 +503,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
if len(sess.Account.Characters) == 1 {
sess.PendingChar = sess.Account.Characters[0]
} else {
+ sess.PendingChar = ""
sess.SetState(net.StateDeleteChar)
sess.WriteLine("\nDelete which character?")
for i, name := range sess.Account.Characters {
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 479e397..12a8cfb 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -177,13 +177,14 @@ func (g *Game) handleRenameChar(sess *net.Session, input string) {
found := false
for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
+ if equalsFold(c, sess.PendingChar) {
found = true
break
}
}
if !found {
sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar))
+ sess.PendingChar = ""
g.showMenu(sess)
return
}
@@ -300,7 +301,7 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) {
}
found := false
for _, c := range sess.Account.Characters {
- if c == sess.PendingChar {
+ if equalsFold(c, sess.PendingChar) {
found = true
break
}
diff --git a/internal/game/exit_display.go b/internal/game/exit_display.go
index 4540669..7e3f591 100644
--- a/internal/game/exit_display.go
+++ b/internal/game/exit_display.go
@@ -38,10 +38,12 @@ func (g *Game) exitDisplayState(sess *net.Session, roomID int, dir world.ExitDir
if p != nil && g.exitDiscovered(p, roomID, dir) {
ed.Visible = true
ed.TagHidden = true
+ } else {
+ return ed
}
- return ed
+ } else {
+ ed.Visible = true
}
- ed.Visible = true
if p == nil || p.GodMode {
return ed
}
diff --git a/internal/game/exit_migrate.go b/internal/game/exit_migrate.go
index 4edab95..4f5dd72 100644
--- a/internal/game/exit_migrate.go
+++ b/internal/game/exit_migrate.go
@@ -25,6 +25,15 @@ func rewritePlayerRoomIDs(p *player.Player, idMap map[int]int) bool {
if world.RemapInt(idMap, &p.RoomID) {
changed = true
}
+ // A multi-tick move or a stashed flee escape keeps its destination in
+ // MoveTarget/EscapeTarget; remap them too or completeMove will assign a
+ // pre-migration room ID with no existence check.
+ if world.RemapInt(idMap, &p.MoveTarget) {
+ changed = true
+ }
+ if world.RemapInt(idMap, &p.EscapeTarget) {
+ changed = true
+ }
if p.PendingSequence != nil && p.PendingSequence.RoomID != 0 && world.RemapInt(idMap, &p.PendingSequence.RoomID) {
changed = true
}
diff --git a/internal/game/fix_game_regression_test.go b/internal/game/fix_game_regression_test.go
new file mode 100644
index 0000000..7edbf8a
--- /dev/null
+++ b/internal/game/fix_game_regression_test.go
@@ -0,0 +1,53 @@
+package game
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestRawArgsFromPreservesCaseAndSkipsVerb(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ verb string
+ want []string
+ }{
+ {"option free text", "option visual_tick_text Tick Tock", "option", []string{"visual_tick_text", "Tick", "Tock"}},
+ {"options fallback", "options visual_tick_text Tick", "option", []string{"visual_tick_text", "Tick"}},
+ {"alias case", "alias g Say Hi", "alias", []string{"g", "Say", "Hi"}},
+ {"collapsed whitespace", "say hello world", "say", []string{"hello", "world"}},
+ {"room action double space", "room addlocalobj [lamp] A brass lamp", "room", []string{"addlocalobj", "[lamp]", "A", "brass", "lamp"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := rawArgsFrom(tc.raw, tc.verb)
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("rawArgsFrom(%q, %q) = %#v, want %#v", tc.raw, tc.verb, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestParseLocalObjArgsUsesSplitRest(t *testing.T) {
+ name, aliases, desc := parseLocalObjArgs("[lamp] A brass lamp")
+ if name != "lamp" || len(aliases) != 0 || desc != "A brass lamp" {
+ t.Fatalf("bracket form = (%q, %v, %q)", name, aliases, desc)
+ }
+ name, aliases, desc = parseLocalObjArgs("[vase, urn] A clay pot")
+ if name != "vase" || !reflect.DeepEqual(aliases, []string{"urn"}) || desc != "A clay pot" {
+ t.Fatalf("bracket aliases = (%q, %v, %q)", name, aliases, desc)
+ }
+ name, aliases, desc = parseLocalObjArgs("lamp A brass lamp")
+ if name != "lamp" || aliases != nil || desc != "A brass lamp" {
+ t.Fatalf("plain form = (%q, %v, %q)", name, aliases, desc)
+ }
+}
+
+func TestCountUniqueNonEmpty(t *testing.T) {
+ if got := countUniqueNonEmpty([]string{"a", "b", "a", ""}); got != 2 {
+ t.Fatalf("countUniqueNonEmpty = %d, want 2", got)
+ }
+ if got := countUniqueNonEmpty(nil); got != 0 {
+ t.Fatalf("countUniqueNonEmpty(nil) = %d, want 0", got)
+ }
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index df8dab2..448dcce 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -597,6 +597,23 @@ func (g *Game) reprompt(sess *net.Session) {
}
}
+// rawArgsFrom returns the whitespace-separated arguments following verb in
+// rawInput, preserving their original case. Dispatch lowercases args before
+// handlers run, so free-text handlers (option/alias/say/global/prompt) use
+// this instead of the lowercased args slice.
+func rawArgsFrom(rawInput, verb string) []string {
+ fields := strings.Fields(rawInput)
+ for i, f := range fields {
+ if strings.EqualFold(f, verb) {
+ return fields[i+1:]
+ }
+ }
+ if len(fields) == 0 {
+ return nil
+ }
+ return fields[1:]
+}
+
func (g *Game) handleGameCommand(sess *net.Session, input string) {
if input == "" {
g.reprompt(sess)
diff --git a/internal/game/hardening_tick_lifecycle_test.go b/internal/game/hardening_tick_lifecycle_test.go
index d9a348a..f230ad7 100644
--- a/internal/game/hardening_tick_lifecycle_test.go
+++ b/internal/game/hardening_tick_lifecycle_test.go
@@ -22,7 +22,7 @@ import (
// master tick subscriber can fire before SetHub during startup.
func TestFireTickNilHubIsSafe(t *testing.T) {
t.Run("nil_hub_burning_fire", func(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
// Quality 1 burns out on this tick, which is the path that touches
// the hub; without the guard it panics on g.Hub == nil.
g.World.AddObjInstance(1, "fire", 1)
@@ -52,7 +52,7 @@ func TestFireTickNilHubIsSafe(t *testing.T) {
// g.sequences: map iteration randomly chose which simultaneous sequence
// advanced first, contradicting the deterministic-order contract.
func TestSequenceTickFiresInDeterministicOrder(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
g.sequences = make(map[string]*sequence)
g.loggedInChars = make(map[string]*net.Session)
@@ -171,7 +171,7 @@ func wanderTestSession(h *harness, name string, roomID int) *chanConn {
func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("player_detached", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 100)
p := player.New("aggro_ghost")
p.RoomID = 100
@@ -193,7 +193,7 @@ func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("session_disconnecting", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 150)
p := player.New("aggro_offline")
p.RoomID = 150
@@ -211,7 +211,7 @@ func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("replaced_by_new_session", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 200)
p := player.New("aggro_relogin")
p.RoomID = 200
@@ -230,7 +230,7 @@ func TestAggroRetryStopsForDepartedPlayer(t *testing.T) {
t.Run("live_session_still_rolls", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 300)
p := player.New("aggro_live")
p.RoomID = 300
diff --git a/internal/game/login_offowner_regression_test.go b/internal/game/login_offowner_regression_test.go
index 4826c4c..bcf03f6 100644
--- a/internal/game/login_offowner_regression_test.go
+++ b/internal/game/login_offowner_regression_test.go
@@ -41,6 +41,7 @@ func TestLoginThroughServerEntryUsesOffOwnerPath(t *testing.T) {
sess.StartWriter()
lines := make(chan string, 8)
go h.input(sess, lines)
+ t.Cleanup(func() { close(lines) })
lines <- "alice" // existing account → password prompt (echo off)
lines <- "wrongpw" // rejected, echo restored, back to account name
@@ -71,6 +72,7 @@ func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) {
sess.StartWriter()
lines := make(chan string, 8)
go h.input(sess, lines)
+ t.Cleanup(func() { close(lines) })
lines <- "newbie" // unknown name → new-account password prompt
lines <- "hunter2pass"
diff --git a/internal/game/look_casino.go b/internal/game/look_casino.go
index 32eb360..eb0b5f9 100644
--- a/internal/game/look_casino.go
+++ b/internal/game/look_casino.go
@@ -104,7 +104,7 @@ func (g *Game) renderCasinoTableDetails(sess *net.Session, cfg casino.TableConfi
func casinoTableSummary(cfg casino.TableConfig) string {
switch cfg.Game {
case casino.GameCashHunt:
- return "200 squares; three difficulties; 98% RTP; prizes up to 50000x"
+ return "100 squares; three difficulties; 98% RTP; prizes up to 50000x"
case casino.GamePlinko:
return fmt.Sprintf("16 rows; four risks; 1-10 balls; near %.4g%% target RTP", cfg.Plinko.Normalize().RTP)
case casino.GameBlackjack:
diff --git a/internal/game/look_casino_test.go b/internal/game/look_casino_test.go
index d53e02d..f48ba89 100644
--- a/internal/game/look_casino_test.go
+++ b/internal/game/look_casino_test.go
@@ -33,7 +33,7 @@ func TestLookCasinoTableDetailsAndBoards(t *testing.T) {
// Craps: details only. The graphical felt and the full rule dump are gone.
conn.data = nil
- if !g.lookCrapsTable(sess, "craps") {
+ if !g.lookCasinoTable(sess, "craps", casino.GameCraps) {
t.Fatal("look craps did not match")
}
text := stripANSI(string(conn.data))
@@ -50,7 +50,7 @@ func TestLookCasinoTableDetailsAndBoards(t *testing.T) {
// Roulette: details plus the live wheel (tracker, layout, and history).
conn.data = nil
- if !g.lookRouletteTable(sess, "roulette") {
+ if !g.lookCasinoTable(sess, "roulette", casino.GameRoulette) {
t.Fatal("look roulette did not match")
}
text = stripANSI(string(conn.data))
@@ -78,7 +78,7 @@ func TestLookCasinoTableAliasesAndFeltGames(t *testing.T) {
t.Fatalf("look %s missing %q: %s", target, want, text)
}
}
- if g.lookCrapsTable(sess, "bj") {
+ if g.lookCasinoTable(sess, "bj", casino.GameCraps) {
t.Fatal("look craps matched a blackjack target")
}
}
@@ -88,7 +88,7 @@ func TestLookCasinoUsesLiveNormalizedConfig(t *testing.T) {
// defaults EnsureTable fills in, not zero values.
g, sess, conn := casinoCrapsGame(t, "name: Bare\ncasino_tables:\n - id: wheel\n game: roulette\n")
conn.data = nil
- if !g.lookRouletteTable(sess, "wheel") {
+ if !g.lookCasinoTable(sess, "wheel", casino.GameRoulette) {
t.Fatal("look by fixture id did not match")
}
text := stripANSI(string(conn.data))
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 1c3d112..0ddd8f4 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -48,7 +48,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- if g.lookCasinoTable(sess, lower, casino.GameCashHunt) || g.lookPlinkoTable(sess, lower) || g.lookBigSixTable(sess, lower) || g.lookBlackjackTable(sess, lower) || g.lookBaccaratTable(sess, lower) || g.lookRouletteTable(sess, lower) || g.lookCrapsTable(sess, lower) || g.lookPaiGowTable(sess, lower) || g.lookVideoPokerCabinet(sess, lower) {
+ // Felt tables resolve through one data-driven loop; the video-poker cabinet
+ // has its own cabinet-id/pay-table handling.
+ for _, game := range []casino.GameName{casino.GameCashHunt, casino.GamePlinko, casino.GameBigSix, casino.GameBlackjack, casino.GameBaccarat, casino.GameRoulette, casino.GameCraps, casino.GamePaiGow} {
+ if g.lookCasinoTable(sess, lower, game) {
+ return
+ }
+ }
+ if g.lookVideoPokerCabinet(sess, lower) {
return
}
diff --git a/internal/game/money.go b/internal/game/money.go
index 6317df8..6965859 100644
--- a/internal/game/money.go
+++ b/internal/game/money.go
@@ -48,6 +48,20 @@ func removePendingWager(p *player.Player, id string) bool {
return removed
}
+// countUniqueNonEmpty returns the number of distinct non-empty IDs in ids.
+// PayCredits/PayPoker use it to fail closed when a payout names a stake that
+// is not present (or names one twice): paying it anyway would double-pay with
+// no ledger entry left for startup recovery.
+func countUniqueNonEmpty(ids []string) int {
+ seen := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ if id != "" {
+ seen[id] = true
+ }
+ }
+ return len(seen)
+}
+
// takePendingWagers removes and returns the pending stakes whose IDs are
// listed, leaving the rest. It is used by a settlement that returns a
// specific set of stakes (a table hand).
diff --git a/internal/game/owner.go b/internal/game/owner.go
index d4d3839..3954ab7 100644
--- a/internal/game/owner.go
+++ b/internal/game/owner.go
@@ -172,7 +172,13 @@ func (g *Game) run(fn func()) {
// TestShutdownRejectsSessionInput.
return
}
- <-task
+ select {
+ case <-task:
+ case <-o.done:
+ // The owner goroutine exited without draining the queue (a task
+ // called runtime.Goexit, e.g. a test's t.Fatal). Return instead of
+ // blocking forever on a task that no longer has a consumer.
+ }
}
// adminTaskPanic is the value RunAdminOnOwner re-raises on its caller's
@@ -222,7 +228,12 @@ func (g *Game) RunAdminOnOwner(fn func()) bool {
}) {
return false
}
- <-done
+ select {
+ case <-done:
+ case <-g.owner.done:
+ // Owner goroutine exited; treat as a refused task rather than hanging.
+ return false
+ }
if !finished {
panic(adminTaskPanic)
}
diff --git a/internal/game/owner_goexit_test.go b/internal/game/owner_goexit_test.go
new file mode 100644
index 0000000..5be643f
--- /dev/null
+++ b/internal/game/owner_goexit_test.go
@@ -0,0 +1,26 @@
+package game
+
+import (
+ "runtime"
+ "testing"
+ "time"
+)
+
+// A task that calls runtime.Goexit (t.Fatal on the owner goroutine) stops the
+// owner loop. Later RunOnOwner calls must return instead of blocking forever
+// waiting for a consumer that no longer exists.
+func TestOwnerGoexitDoesNotHangLaterTasks(t *testing.T) {
+ h := newHarness(t)
+ h.g.RunOnOwner(func() { runtime.Goexit() })
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ h.g.RunOnOwner(func() {})
+ }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("RunOnOwner hung after the owner goroutine exited")
+ }
+}
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 4717fbf..682cea3 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -76,14 +76,12 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
g.cancelAction(p)
g.cancelBgAction(p)
- skill := craft.EffectiveSkill()
- if skill != "" {
- skillLevel := p.Level(player.SkillName(skill))
- if skillLevel < craft.Level {
- sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill))
- g.reprompt(sess)
- return
- }
+ skill := recipeSkill(craft)
+ skillLevel := p.Level(skill)
+ if skillLevel < craft.Level {
+ sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", craft.Level, skill))
+ g.reprompt(sess)
+ return
}
if !craftHasAllItemsQty(craft, p.CountItem) {
@@ -92,6 +90,23 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
return
}
+ // Enforce station and tool requirements here, at the single execution
+ // chokepoint. Recipes can also start via `use X with Y` and trigger
+ // transports that bypass the crafting menus; without this, a recipe can
+ // be executed anywhere with no station or tool.
+ if len(craft.Station) > 0 {
+ if stID, _ := g.findStation(p.RoomID, craft.Station); stID == "" {
+ sess.WriteLine("You are not near the required station.")
+ g.reprompt(sess)
+ return
+ }
+ }
+ if craft.Tool != "" && !g.hasToolType(p, craft.Tool) {
+ sess.WriteLine("You do not have the required tool.")
+ g.reprompt(sess)
+ return
+ }
+
outDef, _ := g.ItemStore.Load(item.ID)
wait := craft.TicksPerCycle
@@ -129,8 +144,8 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, item *item.I
func (g *Game) usableCraftIndex(p *player.Player, item *item.ItemDef) int {
for i := range item.Craft {
c := &item.Craft[i]
- skill := c.EffectiveSkill()
- if skill != "" && p.Level(player.SkillName(skill)) < c.Level {
+ skill := recipeSkill(c)
+ if p.Level(skill) < c.Level {
continue
}
if !craftHasAllItemsQty(c, p.CountItem) {
@@ -248,8 +263,8 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
return
}
- skill := craft.EffectiveSkill()
- skillLevel := p.Level(player.SkillName(skill))
+ skill := recipeSkill(craft)
+ skillLevel := p.Level(skill)
chance := 1.0
if craft.Success != nil {
chance = behavior.SuccessChance(behavior.SuccessFormula(*craft.Success), skillLevel, craft.Level)
@@ -313,7 +328,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
}
if craft.XP > 0 {
- if newLevel := p.AddSkillXP(player.SkillName(skill), craft.XP); newLevel > 0 {
+ if newLevel := p.AddSkillXP(skill, craft.XP); newLevel > 0 {
sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill)))
}
}
@@ -327,7 +342,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) {
}
msg := g.resolveCraftMessage(sess, craft.SuccessMessage, defaultSuccess, craft, item.ID, byproducts, hasItem)
if p.OptionBool("xp_drops") && craft.XP > 0 {
- msg += g.formatXpDropSingle(sess, p, player.SkillName(skill), craft.XP)
+ msg += g.formatXpDropSingle(sess, p, skill, craft.XP)
}
sess.WriteLine(msg)
} else {
diff --git a/internal/game/render_baccarat_felt.go b/internal/game/render_baccarat_felt.go
index 608ef93..3120bd7 100644
--- a/internal/game/render_baccarat_felt.go
+++ b/internal/game/render_baccarat_felt.go
@@ -49,7 +49,7 @@ func renderBaccaratRulesFelt(cfg casino.TableConfig, mode string, unicode bool)
}
house := [][2]string{
{"DECKS", fmt.Sprintf("%d", b.Decks)},
- {"SHUFFLE AT", fmt.Sprintf("%g%% REMAINING", b.ShuffleAt*100)},
+ {"SHUFFLE AT", fmt.Sprintf("%.6g%% REMAINING", b.ShuffleAt*100)},
{"BET LIMITS", betLimitsText(cfg.MinBet, cfg.MaxBet)},
{"SEATS", fmt.Sprintf("%d", cfg.MaxPlayers)},
{"BETTING WINDOW", fmt.Sprintf("%d TICKS", cfg.BettingTicks)},
@@ -112,7 +112,7 @@ func bankerNoteText(b casino.BaccaratConfig) string {
case casino.BaccaratEZ:
return "3-CARD 7 PUSHES"
default:
- return fmt.Sprintf("%s%% COMMISSION", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6g", (1-b.BankerPayout)*100), "0"), "."))
+ return fmt.Sprintf("%s%% COMMISSION", strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.6f", (1-b.BankerPayout)*100), "0"), "."))
}
}
func optionalText(enabled bool, payout string) string {
diff --git a/internal/game/render_blackjack_felt.go b/internal/game/render_blackjack_felt.go
index 41a5382..ad93a88 100644
--- a/internal/game/render_blackjack_felt.go
+++ b/internal/game/render_blackjack_felt.go
@@ -37,7 +37,7 @@ func renderBlackjackRulesFelt(cfg casino.TableConfig, mode string, unicode bool)
rows := [4][][2]string{
{
{"DECKS", fmt.Sprintf("%d", b.Decks)},
- {"SHUFFLE AT", fmt.Sprintf("%g%% REMAINING", b.ShuffleAt*100)},
+ {"SHUFFLE AT", fmt.Sprintf("%.6g%% REMAINING", b.ShuffleAt*100)},
{"SHOE LOW", "RESHUFFLE MID-ROUND"},
{"HOLE CARD", "YES (PEEK)"},
{"", ""},
diff --git a/internal/game/render_cards.go b/internal/game/render_cards.go
new file mode 100644
index 0000000..ca1fc62
--- /dev/null
+++ b/internal/game/render_cards.go
@@ -0,0 +1,330 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "thehouseoficarus/internal/cardart"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func renderBlackjackSnapshotFor(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store, viewer string) string {
+ if snapshot == nil {
+ return ""
+ }
+ size = cardRenderSize(unicode, size)
+ var lines []string
+ dealerTitle := "Dealer:"
+ if snapshot.DealerRevealed {
+ dealerTitle = "Dealer (" + snapshot.DealerScore + "):"
+ } else if snapshot.DealerShownScore != "" {
+ dealerTitle = "Dealer (" + snapshot.DealerShownScore + "):"
+ }
+ if size == 1 {
+ lines = append(lines, cardLine(mode, dealerTitle)+" "+strings.Join(cardsLine(mode, snapshot.Dealer, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, dealerTitle))
+ lines = append(lines, cardsLine(mode, snapshot.Dealer, size, unicode, style, art)...)
+ }
+ for _, player := range snapshot.Players {
+ name := player.Name
+ if name == viewer {
+ name = "You"
+ }
+ marker := " "
+ if player.Active {
+ marker = "> "
+ }
+ detail := ""
+ if player.Hand > 0 {
+ detail = fmt.Sprintf(" | Hand %d/%d | Wager %d | %s", player.Hand, player.Hands, player.Bet, player.Status)
+ }
+ if player.Active && player.Name != viewer {
+ detail = strings.ReplaceAll(detail, "Your turn", "Playing")
+ }
+ title := fmt.Sprintf("%s%s (%s)%s:", marker, name, player.Score, detail)
+ if size == 1 {
+ lines = append(lines, cardLine(mode, title)+" "+strings.Join(cardsLine(mode, player.Cards, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, title))
+ lines = append(lines, cardsLine(mode, player.Cards, size, unicode, style, art)...)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// renderBaccaratSnapshot draws the player and banker hands followed by the
+// list of wagers riding on the round.
+func renderBaccaratSnapshot(snapshot *casino.BaccaratSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
+ if snapshot == nil {
+ return ""
+ }
+ size = cardRenderSize(unicode, size)
+ var lines []string
+ if size == 1 {
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Player, size, unicode, style, art), " "), cardLine(mode, "Banker ("+snapshot.BankerScore+"):")+" "+strings.Join(cardsLine(mode, snapshot.Banker, size, unicode, style, art), " "))
+ } else {
+ lines = append(lines, cardLine(mode, "Player ("+snapshot.PlayerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Player, size, unicode, style, art)...)
+ lines = append(lines, cardLine(mode, "Banker ("+snapshot.BankerScore+"):"))
+ lines = append(lines, cardsLine(mode, snapshot.Banker, size, unicode, style, art)...)
+ }
+ for _, bet := range snapshot.Bets {
+ lines = append(lines, cardLine(mode, fmt.Sprintf("%s bets %d on %s.", bet.Name, bet.Amount, bet.Spot)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func cardLine(mode, text string) string {
+ spec := color.NoColor()
+ spec.Fg = 39
+ return color.Render(mode, spec, text)
+}
+
+func cardsLine(mode string, cards []casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ if size == 1 {
+ parts := make([]string, len(cards))
+ for i, card := range cards {
+ if !unicode {
+ if card.Hidden {
+ parts[i] = "[??]"
+ } else {
+ suit := map[string]string{"hearts": "H", "diamonds": "D", "spades": "S", "clubs": "C"}[card.Suit]
+ parts[i] = card.Rank + suit
+ }
+ } else {
+ parts[i] = colorizeCardSymbol(mode, card)
+ }
+ }
+ return []string{strings.Join(parts, " ")}
+ }
+ artLines := make([][]string, len(cards))
+ for i, card := range cards {
+ artLines[i] = cardArtLines(card, size, unicode, style, art)
+ }
+ lines := make([]string, size)
+ for row := 0; row < size; row++ {
+ var lineParts []string
+ for i, card := range artLines {
+ lineParts = append(lineParts, colorizeCardLine(mode, card[row], cards[i], row, size, style))
+ }
+ lines[row] = strings.Join(lineParts, " ")
+ }
+ return lines
+}
+
+// renderCardRow lays cards side by side, each column padded to its own art
+// or label width, and appends an optional centered label row. minWidth lets
+// the caller force a floor (video poker's HELD labels need it) while Pai Gow
+// passes 0. It is the shared "cards with numbers underneath" presentation.
+func renderCardRow(cards []casino.CardView, labels []string, minWidth int, mode string, unicode bool, size int, style string, art *cardart.Store) []string {
+ if len(cards) == 0 {
+ return nil
+ }
+ artLines := make([][]string, len(cards))
+ widths := make([]int, len(cards))
+ height := 0
+ for i, card := range cards {
+ artLines[i] = cardsLine(mode, []casino.CardView{card}, size, unicode, style, art)
+ height = max(height, len(artLines[i]))
+ widths[i] = minWidth
+ for _, line := range artLines[i] {
+ widths[i] = max(widths[i], color.VisibleLen(line))
+ }
+ if i < len(labels) {
+ widths[i] = max(widths[i], color.VisibleLen(labels[i]))
+ }
+ }
+ lines := make([]string, 0, height+1)
+ for row := 0; row < height; row++ {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if row < len(artLines[i]) {
+ text = artLines[i][row]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ if len(labels) > 0 {
+ parts := make([]string, len(cards))
+ for i := range cards {
+ text := ""
+ if i < len(labels) {
+ text = labels[i]
+ }
+ parts[i] = rouletteCenter(text, widths[i])
+ }
+ lines = append(lines, strings.Join(parts, " "))
+ }
+ return lines
+}
+
+func cardRenderSize(unicode bool, size int) int {
+ if size == 1 {
+ return 1
+ }
+ if !unicode {
+ return 5
+ }
+ if size == 5 || size == 7 || size == 9 {
+ return size
+ }
+ return 7
+}
+
+var suitSymbols = map[string]string{"hearts": "♥", "spades": "♠", "diamonds": "♦", "clubs": "♣", "joker": "★"}
+
+func cardSymbol(card casino.CardView) string {
+ if card.Hidden {
+ return "?"
+ }
+ return card.Rank + suitSymbols[card.Suit]
+}
+
+func cardArtLines(card casino.CardView, size int, unicode bool, style string, art *cardart.Store) []string {
+ if card.Hidden {
+ return generatedCardArt("?", "", size, unicode, style)
+ }
+ key := card.Rank
+ if card.Rank == "JK" {
+ return jokerCardArt(size, unicode, style, art)
+ }
+ if card.Suit != "" {
+ key += strings.ToUpper(card.Suit[:1])
+ }
+ if configured := art.Art(key, size); len(configured) == size {
+ if unicode && style == "light" {
+ // The shared pip art draws a dark box frame, which leaves the top
+ // and bottom rows looking like the dark style. Replace only those
+ // two rows with the half-block edges the white body meets.
+ configured = append([]string(nil), configured...)
+ configured[0] = strings.Repeat("▄", utf8.RuneCountInString(configured[0]))
+ configured[size-1] = strings.Repeat("▀", utf8.RuneCountInString(configured[size-1]))
+ }
+ return configured
+ }
+ return generatedCardArt(card.Rank, card.Suit, size, unicode, style)
+}
+
+func generatedCardArt(rank, suit string, size int, unicode bool, style string) []string {
+ if size < 5 {
+ size = 5
+ }
+ border := "-"
+ left, right := "+", "+"
+ if unicode {
+ border, left, right = "─", "┌", "┐"
+ }
+ bottomLeft, bottomRight := left, right
+ if unicode {
+ bottomLeft, bottomRight = "└", "┘"
+ }
+ vertical := "|"
+ if unicode {
+ vertical = "│"
+ }
+ if unicode && style == "light" {
+ lines := []string{strings.Repeat("▄", size)}
+ lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
+ return append(lines, strings.Repeat("▀", size))
+ }
+ lines := []string{left + strings.Repeat(border, size-2) + right}
+ lines = append(lines, generatedCardBody(rank, suit, size, unicode, vertical)...)
+ lines = append(lines, bottomLeft+strings.Repeat(border, size-2)+bottomRight)
+ return lines
+}
+
+func generatedCardBody(rank, suit string, size int, unicode bool, vertical string) []string {
+ inner := size - 2
+ lines := make([]string, 0, size-2)
+ for row := 1; row < size-1; row++ {
+ content := strings.Repeat(" ", inner)
+ if row == 1 {
+ content = rank + strings.Repeat(" ", inner-len(rank))
+ }
+ if row == (size-1)/2 {
+ suitText := ""
+ if suit != "" {
+ suitText = suitLetter(suit, unicode)
+ }
+ suitWidth := utf8.RuneCountInString(suitText)
+ leftPadding := (inner - suitWidth) / 2
+ content = strings.Repeat(" ", leftPadding) + suitText + strings.Repeat(" ", inner-leftPadding-suitWidth)
+ }
+ if row == size-2 {
+ content = strings.Repeat(" ", inner-len(rank)) + rank
+ }
+ lines = append(lines, vertical+content+vertical)
+ }
+ return lines
+}
+
+func suitLetter(suit string, unicode bool) string {
+ if !unicode {
+ return strings.ToUpper(suit[:1])
+ }
+ return suitSymbols[suit]
+}
+
+func colorizeCardLine(mode, line string, card casino.CardView, row, size int, style string) string {
+ red := card.Suit == "hearts" || card.Suit == "diamonds"
+ rankColor := 27
+ if red {
+ rankColor = 196
+ }
+ suitColor := 27
+ if red {
+ suitColor = 196
+ }
+ rankLine := row == 1 || row == size-2
+ light := style == "light"
+ bodyBackground := light && row > 0 && row < size-1
+ var builder strings.Builder
+ for _, r := range line {
+ if strings.ContainsRune("┌─┐└┘│┬┴├┤┼╔╗╚╝═║╦╩╠╣╬▀▄+|-", r) {
+ spec := color.ColorSpec{Fg: 15}
+ if bodyBackground {
+ spec.Bg = 15
+ }
+ builder.WriteString(color.Render(mode, spec, string(r)))
+ } else if r == ' ' {
+ if bodyBackground {
+ builder.WriteString(color.Render(mode, color.ColorSpec{Fg: 15, Bg: 15}, " "))
+ } else {
+ builder.WriteRune(r)
+ }
+ } else {
+ value := suitColor
+ if rankLine {
+ value = rankColor
+ }
+ spec := color.ColorSpec{Fg: value}
+ if bodyBackground {
+ spec.Bg = 15
+ }
+ builder.WriteString(color.Render(mode, spec, string(r)))
+ }
+ }
+ return builder.String()
+}
+
+func colorizeCardSymbol(mode string, card casino.CardView) string {
+ if card.Hidden {
+ return color.Render(mode, color.ColorSpec{Fg: 15}, "?")
+ }
+ rankColor, suitColor := 27, 27
+ if card.Suit == "hearts" || card.Suit == "diamonds" {
+ rankColor, suitColor = 196, 196
+ }
+ runes := []rune(cardSymbol(card))
+ rankLength := len([]rune(card.Rank))
+ return color.Render(mode, color.ColorSpec{Fg: rankColor}, string(runes[:rankLength])) +
+ color.Render(mode, color.ColorSpec{Fg: suitColor}, string(runes[rankLength:]))
+}
diff --git a/internal/game/render_casino_settlement.go b/internal/game/render_casino_settlement.go
index 2b5acc9..8bdf9e1 100644
--- a/internal/game/render_casino_settlement.go
+++ b/internal/game/render_casino_settlement.go
@@ -90,17 +90,14 @@ func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint f
// Borders and cell padding occupy sixteen columns.
widths[0] = min(widths[0], 79-16-widths[1]-widths[2]-widths[3]-widths[4])
widths[0] = max(widths[0], 5)
- inner := 4
- for _, w := range widths {
- inner += w + 2
- }
- // A readable footer without forcing tiny one-wager receipts into a wide box.
- inner = max(inner, 50)
+ // Grow the label column to reach the 50-wide minimum box (a readable footer
+ // without forcing tiny one-wager receipts wide).
sum := 4
for _, w := range widths {
sum += w + 2
}
- widths[0] += inner - sum
+ widths[0] += max(0, 50-sum)
+ inner := max(50, sum)
border := func(left, join, right string) string {
parts := make([]string, 5)
for i, w := range widths {
@@ -182,9 +179,6 @@ func renderCasinoSettlement(lines []casino.SettlementLine, unicode bool, paint f
title, amount = "NET LOSS", fmt.Sprintf("%d credits", net)
}
tag := netTag(net)
- if settled == 0 {
- title, amount, tag = "NET PENDING", dash, "warning"
- }
footer(title+strings.Repeat(" ", max(1, inner-2-len(title)-color.VisibleLen(amount)))+amount, tag)
out = append(out, paint("dim", bl+strings.Repeat(h, inner)+br))
return strings.Join(out, "\n")
diff --git a/internal/game/render_color.go b/internal/game/render_color.go
index ccce05e..cd239ca 100644
--- a/internal/game/render_color.go
+++ b/internal/game/render_color.go
@@ -115,33 +115,25 @@ func (g *Game) itemColorize(sess *net.Session, itemDef *item.ItemDef, text strin
func (g *Game) namedColorSpecs(sess *net.Session) map[string]color.ColorSpec {
names := make(map[string]color.ColorSpec)
for name := range config.DefaultColors() {
- if spec := g.resolveColor(sess, name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveColor(sess, name)
}
if g.ColorConfig != nil {
for name := range *g.ColorConfig {
if _, ok := names[name]; ok {
continue
}
- if spec := g.resolveColor(sess, name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveColor(sess, name)
}
}
for name := range config.DefaultConstantColors() {
- if spec := g.resolveConstant(name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveConstant(name)
}
if g.ConstantColorConfig != nil {
for name := range *g.ConstantColorConfig {
if _, ok := names[name]; ok {
continue
}
- if spec := g.resolveConstant(name); !spec.Empty() {
- names[name] = spec
- }
+ names[name] = g.resolveConstant(name)
}
}
return names
diff --git a/internal/game/render_craps.go b/internal/game/render_craps.go
index e4e6618..57b7b79 100644
--- a/internal/game/render_craps.go
+++ b/internal/game/render_craps.go
@@ -448,7 +448,7 @@ func renderCrapsDice(dice [2]int, mode string, unicode bool) string {
{" ● ● ", " ● ", " ● ● "},
{" ● ● ", " ● ● ", " ● ● "},
}
- colored := mode != "" && mode != "none"
+ colored := !color.Plain(mode)
face := color.ColorSpec{Fg: 0, Bg: 15}
cap := color.ColorSpec{Fg: 15, Bg: -1}
var out strings.Builder
@@ -555,7 +555,6 @@ func renderCrapsSnapshot(s *casino.CrapsSnapshot, mode string, unicode bool, sty
}
hint := ""
if s.Phase != "waiting" && s.Phase != "" {
- hint = ""
if s.Variant == "simplified" {
if s.ShootIn > 0 {
hint = fmt.Sprintf("The dice roll in %dt.", s.ShootIn)
diff --git a/internal/game/render_felt_precision_test.go b/internal/game/render_felt_precision_test.go
new file mode 100644
index 0000000..da96d71
--- /dev/null
+++ b/internal/game/render_felt_precision_test.go
@@ -0,0 +1,41 @@
+package game
+
+import (
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+)
+
+// The felt is fixed-width art: a commission percentage must not lose its
+// trailing zero (0.9 used to trim "10" down to "1").
+func TestBaccaratFeltPrintsExactCommission(t *testing.T) {
+ cases := map[float64]string{
+ 0.9: "10% COMMISSION",
+ 0.8: "20% COMMISSION",
+ 0.95: "5% COMMISSION",
+ 0.99: "1% COMMISSION",
+ }
+ for payout, want := range cases {
+ cfg := casino.TableConfig{ID: "x", Game: casino.GameBaccarat,
+ Baccarat: casino.BaccaratConfig{Variant: casino.BaccaratCommission, BankerPayout: payout}}
+ art := renderBaccaratRulesFelt(cfg, "none", false)
+ if !strings.Contains(art, want) {
+ t.Fatalf("payout %v: missing %q in felt", payout, want)
+ }
+ }
+}
+
+// Bare %g prints shortest-round-trip floats (0.29 -> 28.999999999999996),
+// which overruns the fixed-width box borders.
+func TestBlackjackFeltPrintsRoundedShuffle(t *testing.T) {
+ cfg := casino.TableConfig{ID: "x", Game: casino.GameBlackjack,
+ Blackjack: casino.BlackjackConfig{ShuffleAt: 0.29}}
+ art := renderBlackjackRulesFelt(cfg, "none", false)
+ if !strings.Contains(art, "29% REMAINING") {
+ t.Fatalf("missing rounded shuffle value: %s", art)
+ }
+ if strings.Contains(art, "99999") || strings.Contains(art, "000000") {
+ t.Fatalf("float noise leaked into the felt: %s", art)
+ }
+}
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 9e8b45a..bfc40f7 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -47,7 +47,7 @@ var commandList = []cmdEntry{
{"drink", "Free", "Drink a potion"},
{"drop", "Active", "Drop items to the ground"},
{"eat", "Free", "Eat food to restore hitpoints"},
- {"equipment / eq", "Instant", "Show equipped items"},
+ {"equipment / eq", "Free", "Show equipped items"},
{"exits", "Instant", "List available exits"},
{"fish", "Active", "Fish at fishing spots (Fishing)"},
{"fletch", "Free", "Fletch logs into bows (Fletching)"},
@@ -81,7 +81,7 @@ var commandList = []cmdEntry{
{"prompt", "Instant", "Set custom command prompt"},
{"pull / push", "Active", "Interact with objects"},
{"queued", "Instant", "Show pending tick actions"},
- {"quit", "Active", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"},
+ {"quit", "Instant", "Rest and disconnect (\"quit now\" forfeits a mid-game bet)"},
{"rake", "Active", "Rake a farming patch"},
{"remove / unwear / unwield", "Free", "Unequip items"},
{"say", "Instant", "Chat with players in your room"},
@@ -115,7 +115,7 @@ var commandList = []cmdEntry{
func LoadHelp(dataDir string) ([]HelpDef, error) {
dir := filepath.Join(dataDir, "help")
var helps []HelpDef
- behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
h := parseHelpDef(data)
if h == nil {
return nil
@@ -123,7 +123,7 @@ func LoadHelp(dataDir string) ([]HelpDef, error) {
helps = append(helps, *h)
return nil
})
- return helps, nil
+ return helps, err
}
// helpRaw mirrors the canonical help schema and is also used by data
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index e8d7cea..ff36344 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -57,10 +57,23 @@ type mapGraph struct {
const playerZ = 0
func buildGraph(g *Game, sess *net.Session, startRoomID int) *mapGraph {
- rg := world.BuildGrid(startRoomID, func(id int) (*world.Room, bool) {
- return loadRoom(g, id)
- }, nil, func(fromID int, dir world.ExitDir, targetID int) bool {
- room, ok := loadRoom(g, fromID)
+ // BuildGrid calls load for the BFS node AND edgeInclude re-loads the same
+ // room for its exit check, so memoize per render: without this every room
+ // reachable from the player is os.ReadFile+yaml.Unmarshal'd twice per map.
+ cache := make(map[int]*world.Room)
+ load := func(id int) (*world.Room, bool) {
+ if r, ok := cache[id]; ok {
+ return r, true
+ }
+ r, ok := loadRoom(g, id)
+ if !ok {
+ return nil, false
+ }
+ cache[id] = r
+ return r, true
+ }
+ rg := world.BuildGrid(startRoomID, load, nil, func(fromID int, dir world.ExitDir, targetID int) bool {
+ room, ok := load(fromID)
if !ok {
return true
}
diff --git a/internal/game/render_plinko.go b/internal/game/render_plinko.go
index 7988d8c..7f566b3 100644
--- a/internal/game/render_plinko.go
+++ b/internal/game/render_plinko.go
@@ -188,7 +188,7 @@ func renderPlinko(s *casino.PlinkoSnapshot, mode string, unicode bool) string {
continue
}
glyph := ball
- if mode == "none" && count > 1 {
+ if color.Plain(mode) && count > 1 {
glyph = rune('0' + count)
if count == 10 {
glyph = '*'
diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go
index 2b4f50c..4bf7977 100644
--- a/internal/game/render_prompt.go
+++ b/internal/game/render_prompt.go
@@ -62,11 +62,14 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string {
continue
}
letter := exitPromptLetter(dir)
- allExits += letter
- if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
+ ed := g.exitDisplayState(sess, p.RoomID, dir, exitDef)
+ if !ed.Visible {
continue
}
- openExits += letter
+ allExits += letter
+ if !ed.Blocked {
+ openExits += letter
+ }
}
}
text = strings.ReplaceAll(text, "%E", allExits)
@@ -75,7 +78,7 @@ func (g *Game) expandPromptVars(sess *net.Session, text string) string {
for _, sk := range player.AllSkills {
tag := string(sk)
text = strings.ReplaceAll(text, "%X_"+tag, fmt.Sprint(p.Skills[sk]))
- text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(xpToLevel(p, sk)))
+ text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(player.XPForNextLevel(p.Skills[sk])))
}
text = strings.ReplaceAll(text, "\x00", "%")
@@ -110,13 +113,6 @@ func attackStyleLong(s player.AttackStyle) string {
return "accurate"
}
-func xpToLevel(p *player.Player, sk player.SkillName) int {
- currentXP := p.Skills[sk]
- currentLevel := p.Level(sk)
- nextLevelXP := player.XPForLevel(currentLevel + 1)
- return nextLevelXP - currentXP
-}
-
// Cardinal exits get uppercase single letters; intercardinal exits get lowercase
// two-letter strings. This visual distinction helps players scan the prompt at a glance.
func exitPromptLetter(dir world.ExitDir) string {
diff --git a/internal/game/render_roulette.go b/internal/game/render_roulette.go
index a0f1e04..89c14c6 100644
--- a/internal/game/render_roulette.go
+++ b/internal/game/render_roulette.go
@@ -134,7 +134,7 @@ func renderRouletteRacetrack(mode string, unicode bool) string {
pocket := func(x, y, index, width int) {
n := wheel[index%len(wheel)]
bg, name := roulettePocketColor(n)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
n += name[:1]
}
put(x, y, rouletteCenter(n, width), bg)
@@ -318,7 +318,7 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str
case !double && row == 2:
zero = "0"
}
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
zero += "G"
}
// The 0/00 lane is one green rectangle, so its blank cells above and
@@ -327,7 +327,7 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str
for n := row; n <= 36; n += 3 {
pocket := strconv.Itoa(n)
bg, name := roulettePocketColor(pocket)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
pocket += name[:1]
}
line += roulettePaint(mode, rouletteCenter(pocket, 4), bg) + rouletteInk(mode, b.v)
@@ -382,7 +382,7 @@ func renderRouletteLayout(double, racetrack bool, mode string, unicode bool) str
}
bottom += b.br
lines = append(lines, line, rouletteInk(mode, bottom))
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
lines = append(lines, "R = red, B = black, G = green")
}
return strings.Join(lines, "\n")
@@ -419,7 +419,7 @@ func renderRouletteWheel(wheel []string, mode string, unicode bool) string {
grid[y][x] = rouletteCell{' ', -1}
}
}
- plain := mode == "none" || mode == ""
+ plain := color.Plain(mode)
pocketRune := func(bg int) rune {
if !plain {
return ' '
@@ -553,7 +553,7 @@ func renderRouletteRow(row []rouletteCell, mode string) string {
j++
}
text := string(seg)
- if bg >= 0 && mode != "none" && mode != "" {
+ if bg >= 0 && !color.Plain(mode) {
sb.WriteString(roulettePaint(mode, text, bg))
} else {
sb.WriteString(rouletteInk(mode, text))
@@ -584,7 +584,7 @@ func renderRouletteResult(pockets [5]string, mode string, unicode bool, size int
ballRow := size / 2
hasRule := ballRow > ruleRow
colorRow := size - 2
- plain := mode == "none" || mode == ""
+ plain := color.Plain(mode)
var lines []string
for row := 0; row < size; row++ {
var cells []string
diff --git a/internal/game/render_roulette_tracker.go b/internal/game/render_roulette_tracker.go
index 3602224..645d28f 100644
--- a/internal/game/render_roulette_tracker.go
+++ b/internal/game/render_roulette_tracker.go
@@ -18,7 +18,7 @@ func renderRouletteTracker(s *casino.RouletteSnapshot, mode string, unicode bool
ink := func(text string) string { return rouletteInk(mode, text) }
pocket := func(n string) string {
bg, name := roulettePocketColor(n)
- if mode == "none" || mode == "" {
+ if color.Plain(mode) {
n += name[:1]
}
return roulettePaint(mode, rouletteCenter(n, 4), bg)
diff --git a/internal/game/render_slots.go b/internal/game/render_slots.go
new file mode 100644
index 0000000..82c57e7
--- /dev/null
+++ b/internal/game/render_slots.go
@@ -0,0 +1,86 @@
+package game
+
+import (
+ "strings"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/color"
+)
+
+func renderSlotSnapshot(snapshot *casino.SlotSnapshot, mode string, unicode bool) string {
+ if snapshot == nil {
+ return ""
+ }
+ left, junction, right, horizontal, vertical := '+', '+', '+', '-', '|'
+ bottomLeft, bottomJunction, bottomRight := '+', '+', '+'
+ if unicode {
+ left, junction, right, horizontal, vertical = '\u250c', '\u252c', '\u2510', '\u2500', '\u2502'
+ bottomLeft, bottomJunction, bottomRight = '\u2514', '\u2534', '\u2518'
+ }
+ separator := string(left)
+ for reel := 0; reel < 5; reel++ {
+ if reel > 0 {
+ separator += string(junction)
+ }
+ separator += strings.Repeat(string(horizontal), 9)
+ }
+ separator += string(right)
+ lines := []string{casinoFrame(mode, separator)}
+ frameVertical := casinoFrame(mode, string(vertical))
+ for row := 0; row < 3; row++ {
+ line := frameVertical
+ for reel := 0; reel < 5; reel++ {
+ value := "..."
+ if reel < snapshot.StoppedReels {
+ value = snapshot.Grid[row][reel]
+ }
+ // Pad to the fixed 7-cell reel width; a malformed profile could
+ // otherwise make this negative and panic. Validation caps the id
+ // length, so this is a defence-in-depth backstop.
+ pad := 7 - len(value)
+ if pad < 0 {
+ pad = 0
+ }
+ line += " " + colorSlotSymbol(mode, value) + strings.Repeat(" ", pad) + " " + frameVertical
+ }
+ lines = append(lines, line)
+ }
+ bottom := string(bottomLeft)
+ for reel := 0; reel < 5; reel++ {
+ if reel > 0 {
+ bottom += string(bottomJunction)
+ }
+ bottom += strings.Repeat(string(horizontal), 9)
+ }
+ bottom += string(bottomRight)
+ lines = append(lines, casinoFrame(mode, bottom))
+ return strings.Join(lines, "\n")
+}
+
+func casinoFrame(mode, text string) string {
+ spec := color.NoColor()
+ spec.Fg = 244
+ return color.Render(mode, spec, text)
+}
+
+func colorSlotSymbol(mode, symbol string) string {
+ spec := color.NoColor()
+ spec.Fg = 250
+ switch symbol {
+ case "straw":
+ spec.Fg = 220
+ case "stick":
+ spec.Fg = 46
+ case "brick":
+ spec.Fg = 196
+ case "hat":
+ spec.Fg = 201
+ case "wolf":
+ spec.Fg = 39
+ case "scatter":
+ spec.Fg = 226
+ default:
+ spec.Dim = true
+ }
+ return color.Render(mode, spec, symbol)
+}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index af8cc75..4ccce64 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -145,6 +145,15 @@ func (g *Game) checkAggro(sess *net.Session) {
if g.Combat.IsMobInCombat(aggMob.InstanceID) {
return false
}
+ // The retry outlives the immediate roll: a player can god or enter a
+ // safespot after the first miss, so re-check both gates on every
+ // retry before rolling again.
+ if p.GodMode {
+ return false
+ }
+ if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, aggMob) {
+ return false
+ }
if rollAggroRand() >= aggroChancePerTick {
return true
}
diff --git a/internal/game/sys_combat_test.go b/internal/game/sys_combat_test.go
index dcd8e5e..090ec5f 100644
--- a/internal/game/sys_combat_test.go
+++ b/internal/game/sys_combat_test.go
@@ -27,7 +27,7 @@ func patchAggroRand(val float64) func() {
func TestAggroInlineRollFiresOnArrival(t *testing.T) {
defer patchAggroRand(0.0)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 100)
p := player.New("agro_fire")
p.RoomID = 100
@@ -44,7 +44,7 @@ func TestAggroPerTickRetrySubscriber(t *testing.T) {
t.Run("miss_then_retry_fires", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 200)
p := player.New("agro_retry")
p.RoomID = 200
@@ -67,7 +67,7 @@ func TestAggroPerTickRetrySubscriber(t *testing.T) {
t.Run("miss_then_ineligible_unsubscribes", func(t *testing.T) {
defer patchAggroRand(0.5)()
- g := escapeTestGame()
+ g := escapeTestGame(t)
spawnTestMob(g, 300)
p := player.New("agro_leave")
p.RoomID = 300
diff --git a/internal/game/sys_escape_test.go b/internal/game/sys_escape_test.go
index fd4c9ab..aff37ae 100644
--- a/internal/game/sys_escape_test.go
+++ b/internal/game/sys_escape_test.go
@@ -1,7 +1,6 @@
package game
import (
- "os"
"testing"
"time"
@@ -29,11 +28,8 @@ func (c *escapeTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *escapeTestConn) Close() error { return nil }
func (c *escapeTestConn) SetEcho(bool) error { return nil }
-func escapeTestGame() *Game {
- tmpDir, err := os.MkdirTemp("", "escape_test")
- if err != nil {
- panic(err)
- }
+func escapeTestGame(t *testing.T) *Game {
+ t.Helper()
dataDir := "../../data"
return &Game{
Combat: combat.NewTracker(),
@@ -44,7 +40,7 @@ func escapeTestGame() *Game {
World: world.New(dataDir),
MobStore: world.NewMobStore(dataDir),
ItemStore: item.NewItemStore(dataDir),
- AccountStore: player.NewAccountStore(tmpDir),
+ AccountStore: player.NewAccountStore(t.TempDir()),
Ticks: engine.New(),
},
}
@@ -84,7 +80,7 @@ func TestClearMoveStateClearsEscape(t *testing.T) {
}
func TestBeginEscapeMoveWithEnergy(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("run")
p.EscapeDir = "north"
p.EscapeTarget = 10
@@ -108,7 +104,7 @@ func TestBeginEscapeMoveWithEnergy(t *testing.T) {
}
func TestBeginEscapeMoveWithoutEnergy(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("walk")
p.RunEnergy = 0
p.EscapeDir = "east"
@@ -126,7 +122,7 @@ func TestBeginEscapeMoveWithoutEnergy(t *testing.T) {
}
func TestBeginEscapeMoveWithCapeOfAgility(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("cape")
p.Equipment[item.SlotBack] = capeOfAgilityID
@@ -140,7 +136,7 @@ func TestBeginEscapeMoveWithCapeOfAgility(t *testing.T) {
}
func TestApplyMobHitCancelsEscape(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("ouch")
p.EscapeDir = "south"
p.EscapeTarget = 3
@@ -172,7 +168,7 @@ func TestApplyMobHitCancelsEscape(t *testing.T) {
}
func TestApplyMobMissFiresEscape(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("lucky")
p.EscapeDir = "east"
p.EscapeTarget = 8
@@ -217,7 +213,7 @@ func TestEscapeReaimSilently(t *testing.T) {
}
func TestDoAttackAbortsFlee(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("attacker")
p.EscapeDir = "west"
p.EscapeTarget = 4
@@ -247,7 +243,7 @@ func TestEscapeClearedOnStop(t *testing.T) {
// TestPlayerActionDisplayNoPreparingToFlee verifies the
// "preparing to flee" display branch has been removed.
func TestPlayerActionDisplayNoPreparingToFlee(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("fleer")
p.EscapeDir = "north"
@@ -261,7 +257,7 @@ func TestPlayerActionDisplayNoPreparingToFlee(t *testing.T) {
// consecutive escape-canceling mob hit in the same combat triggers the flee
// as if the mob had missed, instead of cancelling.
func TestEscapeReliefFiresOnThirdHit(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("relief")
p.HP = 100
p.EscapeDir = "north"
@@ -298,7 +294,7 @@ func TestEscapeReliefFiresOnThirdHit(t *testing.T) {
// TestEscapeReliefDoesNotFireBeforeThirdHit verifies the relief waits until
// the 3rd hit; the 1st and 2nd should still cancel normally.
func TestEscapeReliefDoesNotFireBeforeThirdHit(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("patient")
p.HP = 100
p.EscapeDir = "north"
@@ -343,7 +339,7 @@ func TestEscapeReliefDoesNotFireBeforeThirdHit(t *testing.T) {
// after a successful flee path (miss) — so the next flee attempt in the same
// combat starts fresh at 0.
func TestEscapeFailCountResetsOnSuccessfulMissFlee(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("reclucky")
p.HP = 100
@@ -382,7 +378,7 @@ func TestEscapeFailCountResetsOnSuccessfulMissFlee(t *testing.T) {
// TestEscapeFailCountResetsOnCombatEntry verifies the counter resets when a
// new combat starts (startCombat), so a fresh engagement begins at 0.
func TestEscapeFailCountResetsOnCombatEntry(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("reset")
p.HP = 100
p.EscapeFailCount = 7 // carryover from prior engagement
@@ -404,7 +400,7 @@ func TestEscapeFailCountResetsOnCombatEntry(t *testing.T) {
// TestEscapeReliefSuppressedOnKillingBlow verifies that when the 3rd hit
// would kill the player, the relief is suppressed.
func TestEscapeReliefSuppressedOnKillingBlow(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("doomed")
p.HP = 3 // exactly enough to absorb three maxHit==1 (deterministic) hits
@@ -439,7 +435,7 @@ func TestEscapeReliefSuppressedOnKillingBlow(t *testing.T) {
}
func TestEndCombatFiresEscapeOnMobDeath(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("finisher")
p.EscapeDir = "east"
p.EscapeTarget = 10
@@ -466,7 +462,7 @@ func TestEndCombatFiresEscapeOnMobDeath(t *testing.T) {
}
func TestEndCombatDoesNotFireEscapeIfMobAlive(t *testing.T) {
- g := escapeTestGame()
+ g := escapeTestGame(t)
p := player.New("patient")
p.EscapeDir = "east"
p.EscapeTarget = 10
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index 539f69d..ce99610 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -104,6 +104,18 @@ func (g *Game) activateSafespot(sess *net.Session, p *player.Player, ss *Safespo
return
}
+ // Two players can start hiding in the same tick and both pass the cap check
+ // at hide time (the occupant is only appended here, when the countdown
+ // completes). Re-check the cap now so a max_occupants cover never ends with
+ // more occupants than it allows.
+ if cfg.MaxOccupants > 0 && len(st.SafespotOccupants) >= cfg.MaxOccupants {
+ g.safespot.Delete(p.Name)
+ sess.WriteLine("")
+ sess.WriteLine(fmt.Sprintf("%s already hiding there!",
+ joinSafespotOccupants(st.SafespotOccupants)))
+ return
+ }
+
st.SafespotOccupants = append(st.SafespotOccupants, p.Name)
var msg string
@@ -143,11 +155,22 @@ func (g *Game) safespotMaintenance(sess *net.Session, p *player.Player, ss *Safe
return
}
+ // Decay accumulation and the decay roll are per-object, not per-occupant:
+ // SafespotTick calls this once per occupant, so N players on one cover
+ // would otherwise advance the counter N times and roll decay N times per
+ // tick. The first occupant does the object-level work; the per-player
+ // UnsafeChance slip below stays per occupant.
+ isFirstOccupant := st.SafespotOccupants[0] == p.Name
+
if cfg.UnsafeChance > 0 && rand.Float64() < cfg.UnsafeChance {
g.forceLeaveSafespot(sess, p, ss, "You slip and are no longer covered!")
return
}
+ if !isFirstOccupant {
+ return
+ }
+
levelIdx := st.SafespotLevel - 1
degraded := false
diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go
index 58e2db0..b28fc5e 100644
--- a/internal/game/sys_science.go
+++ b/internal/game/sys_science.go
@@ -47,7 +47,7 @@ func (g *Game) LoadMods() error {
if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var m ModDef
if err := yaml.Unmarshal(data, &m); err != nil {
- return nil
+ return fmt.Errorf("%s: %w", path, err)
}
m.ID = id
AllMods = append(AllMods, &m)
@@ -249,7 +249,6 @@ func (g *Game) triggerModReward(sess *net.Session, p *player.Player, mod *ModDef
func (g *Game) junkCostString(p *player.Player, mod *ModDef) string {
cost := g.effectiveJunkCost(p, mod)
- delete(cost, "scrap")
if len(cost) == 0 {
return "free"
}
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index 6f03276..edf0051 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -46,7 +46,7 @@ func (g *Game) LoadTechs() error {
if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
var t TechDef
if err := yaml.Unmarshal(data, &t); err != nil {
- return nil
+ return fmt.Errorf("%s: %w", path, err)
}
t.ID = id
AllTechs = append(AllTechs, &t)
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 790eb12..6ae5e21 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -33,6 +33,7 @@ type sequence struct {
key string // dedup key
locked bool // atomic + resumable on disconnect
roomLocked bool // skip steps whose player has left roomID
+ dirty bool // a step advanced since the last persisted snapshot
}
// verbSeqKey builds a dedup key for a verb-triggered sequence so two of the
@@ -144,6 +145,7 @@ func (g *Game) advanceSequence(seq *sequence) {
seq.wait--
if seq.wait <= 0 && len(seq.steps) > 0 {
seq.steps = seq.steps[1:]
+ seq.dirty = true
if len(seq.steps) > 0 {
seq.wait = seq.steps[0].Wait
}
@@ -160,6 +162,7 @@ func (g *Game) advanceSequence(seq *sequence) {
for seq.wait <= 0 && len(seq.steps) > 0 {
step := seq.steps[0]
seq.steps = seq.steps[1:]
+ seq.dirty = true
jobs = append(jobs, step)
if len(seq.steps) > 0 {
seq.wait = seq.steps[0].Wait
@@ -233,6 +236,10 @@ func (g *Game) syncPendingSequence(seq *sequence, p *player.Player) {
if len(seq.steps) == 0 {
return
}
+ if !seq.dirty {
+ return
+ }
+ seq.dirty = false
p.PendingSequence.Steps = append([]behavior.Step(nil), seq.steps...)
p.PendingSequence.Wait = seq.wait
g.saveCharacter(p)
@@ -301,6 +308,15 @@ func (g *Game) cancelSequences(name string) {
delete(g.sequences, k)
}
g.seqMu.Unlock()
+ // Clearing the live sequences is not enough: the persisted marker would
+ // otherwise re-arm the cancelled steps on the next login.
+ g.charsMu.Lock()
+ sess := g.loggedInChars[name]
+ g.charsMu.Unlock()
+ if sess != nil && sess.Player != nil && sess.Player.PendingSequence != nil {
+ sess.Player.PendingSequence = nil
+ g.saveCharacter(sess.Player)
+ }
}
// abortAllSequences drops every in-flight sequence (player and global) and
@@ -360,7 +376,8 @@ func (g *Game) resumePendingSequence(sess *net.Session, p *player.Player) {
return
}
ps := p.PendingSequence
- p.PendingSequence = nil
+ // Keep the marker: completePlayerSequence clears it when the matching key
+ // finishes, so a crash mid-resume still has a durable snapshot to replay.
seq := &sequence{
sess: sess,
player: p,
@@ -400,11 +417,12 @@ func (g *Game) TransientMobTick() {
g.charsMu.Lock()
ownerSess := g.loggedInChars[inst.Owner]
g.charsMu.Unlock()
- if ownerSess == nil || ownerSess.Player == nil {
- continue
+ // An offline owner counts as out of the allowed rooms so the despawn
+ // grace still runs; otherwise the mob persists forever after logout.
+ ownerRoom := -1
+ if ownerSess != nil && ownerSess.Player != nil {
+ ownerRoom = ownerSess.Player.RoomID
}
-
- ownerRoom := ownerSess.Player.RoomID
allowed := inst.DespawnRooms
if len(allowed) == 0 {
allowed = []int{inst.HomeRoomID}
diff --git a/internal/game/video_poker_cabinet_test.go b/internal/game/video_poker_cabinet_test.go
index 0e46c82..df45365 100644
--- a/internal/game/video_poker_cabinet_test.go
+++ b/internal/game/video_poker_cabinet_test.go
@@ -143,7 +143,11 @@ func TestVestaVideoPokerPayoutTiers(t *testing.T) {
func TestPokerInterruptedTransactionRecovery(t *testing.T) {
g, sess, _ := casinoCrapsGame(t, uxTestRoom)
- g.DataDir = t.TempDir()
+ // Align the journal root with the account store's data root: Recover
+ // refuses op paths that escape the journal's parent directory, and the
+ // harness otherwise leaves g.DataDir (../../data) different from the
+ // temp dir the stores were built with.
+ g.DataDir = filepath.Dir(filepath.Dir(g.AccountStore.CharPath("alice")))
w := casinoWallet{g: g, p: sess.Player}
stake, err := w.Wager(10)
if err != nil {
diff --git a/internal/game/wallet_refund_kind_test.go b/internal/game/wallet_refund_kind_test.go
new file mode 100644
index 0000000..370c286
--- /dev/null
+++ b/internal/game/wallet_refund_kind_test.go
@@ -0,0 +1,40 @@
+package game
+
+import "testing"
+
+// RefundStakes must return chips as chips. PayCredits here used to convert the
+// casino-only chip stake into spendable credits (an economy exploit via unbet).
+func TestRefundStakesReturnsChipsInKind(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ if !h.g.giveChips(p, 30) {
+ t.Error("could not seed chips")
+ return
+ }
+ wager, err := w.Wager(30) // spends chips first, 0 credits
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if wager.Chips != 30 || wager.Credits != 0 {
+ t.Errorf("wager = %+v, want 30 chips / 0 credits", wager)
+ }
+ if err := w.RefundStakes(wager.ID); err != nil {
+ t.Error(err)
+ }
+ })
+ h.barrier()
+ if p.Credits != 100 {
+ t.Fatalf("credits changed to %d, want 100 (chips must return in kind)", p.Credits)
+ }
+ if got := countChips(p); got != 30 {
+ t.Fatalf("chips after refund = %d, want 30", got)
+ }
+ if len(p.PendingWagers) != 0 {
+ t.Fatalf("pending stake not cleared: %+v", p.PendingWagers)
+ }
+}
diff --git a/internal/game/wallet_test.go b/internal/game/wallet_test.go
index 56789d8..dba8a5f 100644
--- a/internal/game/wallet_test.go
+++ b/internal/game/wallet_test.go
@@ -49,6 +49,7 @@ func harnessFromDir(t *testing.T, dir string) *harness {
}
h.stop = func() { close(stopTick); <-done }
t.Cleanup(h.stop)
+ t.Cleanup(g.stopOwner)
return h
}