aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-10-05 17:05:14 -0400
committerhistoria <[not public]>2026-10-05 17:05:14 -0400
commite42952469dadfb3535633c66c881050ac128703c (patch)
treea78f7396b72cdbd6fa4f6c454c830f40cf2ad0cb /internal/game
parent90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (diff)
downloadthehouseoficarus-e42952469dadfb3535633c66c881050ac128703c.tar.gz
fix: gather verbs, walk oom, admin path bypass, roulette refund
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act.go19
-rw-r--r--internal/game/act_talk.go14
-rw-r--r--internal/game/big_six_test.go2
-rw-r--r--internal/game/cash_hunt_test.go6
-rw-r--r--internal/game/cmd_bank.go5
-rw-r--r--internal/game/cmd_drop.go4
-rw-r--r--internal/game/cmd_queued.go17
-rw-r--r--internal/game/cmd_shop.go4
-rw-r--r--internal/game/cmd_verbs.go7
-rw-r--r--internal/game/cmd_walk.go12
-rw-r--r--internal/game/combat_attack.go8
-rw-r--r--internal/game/combat_mob.go5
-rw-r--r--internal/game/core_command_queue.go8
-rw-r--r--internal/game/core_hacking.go4
-rw-r--r--internal/game/correct_audit_fixes_test.go104
-rw-r--r--internal/game/game.go18
-rw-r--r--internal/game/predictions.go34
-rw-r--r--internal/game/production_engine.go5
-rw-r--r--internal/game/production_index.go6
-rw-r--r--internal/game/render_cash_hunt.go9
20 files changed, 251 insertions, 40 deletions
diff --git a/internal/game/act.go b/internal/game/act.go
index 7ff805c..0d2cd70 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -14,12 +14,19 @@ import (
"thehouseoficarus/internal/world"
)
-// verbAliases maps a gather-style verb that has no commandRegistry entry to
-// the action it starts. Verbs that are registered directly (mine, chop, fish,
-// cut, talk/speak/ask, steal/thieve, hide) must not be duplicated here: a
-// registered command is looked up first, so an entry would be dead and its
-// removal untested. Only shear remains as a pure action alias.
+// verbAliases maps a gather-style verb to the action type used by the
+// bhType != verb gate in startAction. Every gather command (mine, chop, fish,
+// cut, shear) resolves here even though the command registry dispatches them
+// to executeGather: executeGather passes the raw verb to startAction, and the
+// gate compares it against the object's BehaviorType(). Without an entry the
+// gate compares "mine" to "gather" and always fails. Non-gather actions
+// (talk/speak/ask, steal/thieve, hide) have their own classification and must
+// not be added here.
var verbAliases = map[string]behavior.ActionType{
+ "mine": behavior.TypeGather,
+ "chop": behavior.TypeGather,
+ "fish": behavior.TypeGather,
+ "cut": behavior.TypeGather,
"shear": behavior.TypeGather,
}
@@ -106,7 +113,7 @@ func (g *Game) startAction(sess *net.Session, verb, target string) {
sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb))
return
}
- if !obj.IsInteractable() {
+ if !obj.IsInteractable() && obj.ID != "estate_directory" {
sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name))
return
}
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index 239e6f5..fa93435 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -278,7 +278,19 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
td, ok := p.Action.Data.(*behavior.TalkData)
- if !ok || td.Cfg == nil {
+ if !ok {
+ g.cancelAction(p)
+ sess.SetState(net.StateGame)
+ return
+ }
+ if td.Cfg == nil {
+ if td.EstateDirectory {
+ // The estate-directory prompt is a talk action with no dialogue
+ // tree; it waits for player input, so there is nothing to advance.
+ // (handleTalkInput has the same exemption.) Without this the prompt
+ // was cancelled one tick after it appeared.
+ return
+ }
g.cancelAction(p)
sess.SetState(net.StateGame)
return
diff --git a/internal/game/big_six_test.go b/internal/game/big_six_test.go
index 6a83cdf..8bec27f 100644
--- a/internal/game/big_six_test.go
+++ b/internal/game/big_six_test.go
@@ -50,7 +50,7 @@ func TestBigSixRendering(t *testing.T) {
wall = "│"
}
// The bill outline must continue through the number row.
- bill := wall + " $1 "
+ var bill string
if phase == "result" {
bill = wall + " $1 " + wall
} else {
diff --git a/internal/game/cash_hunt_test.go b/internal/game/cash_hunt_test.go
index 78d3e38..ded4ea0 100644
--- a/internal/game/cash_hunt_test.go
+++ b/internal/game/cash_hunt_test.go
@@ -25,7 +25,7 @@ func TestCashHuntCommandsAndReceipt(t *testing.T) {
g.executeBet(s, []string{"1"}, "bet 1")
text := string(conn.data)
preview, hidden := strings.Index(text, "PRIZE PREVIEW"), strings.Index(text, "hidden and mixed")
- if preview < 0 || hidden <= preview || !strings.Contains(text, cashHuntCenter("100", cashHuntTileWidth)) || len(s.Player.PendingWagers) != 1 {
+ if preview < 0 || hidden <= preview || !strings.Contains(text, cashHuntCenter("100")) || len(s.Player.PendingWagers) != 1 {
t.Fatalf("preview/shuffle sequence missing: %s", text)
}
if table.Busy(s.Player.Name) {
@@ -155,7 +155,7 @@ func TestCashHuntBoardRendering(t *testing.T) {
}
start := 1 + col*(cashHuntTileWidth+1)
got := string(numberRunes[start : start+cashHuntTileWidth])
- if want := cashHuntCenter(label, cashHuntTileWidth); got != want {
+ if want := cashHuntCenter(label); got != want {
t.Fatalf("square %d label: got %q want %q", square, got, want)
}
}
@@ -191,7 +191,7 @@ func TestCashHuntBoardRendering(t *testing.T) {
t.Fatal("grid characters should be darkened")
}
// 0x is not a prize: it stays dim and is never bold.
- if !strings.Contains(board, cashHuntCenter("0x", cashHuntTileWidth)) {
+ if !strings.Contains(board, cashHuntCenter("0x")) {
t.Fatal("0x prize missing")
}
if strings.Contains(rendered, "\x1b[1m"+color.FgCode("xterm256", 237)) {
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 15ce85f..bb03869 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -141,7 +141,10 @@ func (g *Game) doBankDeposit(sess *net.Session, input string) {
return
}
- if len(matches) > 1 {
+ // Multiple slots often hold the same item name (stacking does not always
+ // merge), so only treat distinct names as ambiguous; showWhichOne dedups
+ // by name and would otherwise print a one-line prompt with no valid reply.
+ if len(matches) > 1 && len(uniqueItemNames(matches)) > 1 {
g.showWhichOne(sess, matches)
return
}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 1c30cce..c0a8baf 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -63,7 +63,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
}
total += slot.Quantity
p.SetInvSlot(match.Slot, nil)
- g.World.AddGroundItem(p.RoomID, itemID, slot.Quantity)
+ g.World.AddGroundItemQ(p.RoomID, itemID, slot.Quantity, slot.Quality, slot.MaxQuality)
}
g.saveCharacter(p)
if total == 1 {
@@ -82,7 +82,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
}
total += slot.Quantity
p.SetInvSlot(match.Slot, nil)
- g.World.AddGroundItem(p.RoomID, itemID, slot.Quantity)
+ g.World.AddGroundItemQ(p.RoomID, itemID, slot.Quantity, slot.Quality, slot.MaxQuality)
}
g.saveCharacter(p)
if total == 1 {
diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go
index 00cf994..325d197 100644
--- a/internal/game/cmd_queued.go
+++ b/internal/game/cmd_queued.go
@@ -15,8 +15,9 @@ func (g *Game) doQueued(sess *net.Session) {
freeCmds := g.queue.PeekFree(p.Name)
activeCmd, hasActive := g.queue.PeekActive(p.Name)
+ consumeCmd, hasConsume := g.queue.PeekConsume(p.Name)
- if len(freeCmds) == 0 && !hasActive {
+ if len(freeCmds) == 0 && !hasActive && !hasConsume {
sess.WriteLine("No actions queued.")
return
}
@@ -46,8 +47,20 @@ func (g *Game) doQueued(sess *net.Session) {
sess.WriteLine(" " + cmd)
}
+ if consumeCmd != nil {
+ if len(freeCmds) > 0 || activeCmd != nil {
+ sess.WriteLine("")
+ }
+ cmd := consumeCmd.Command
+ if consumeCmd.Args != "" {
+ cmd += " " + consumeCmd.Args
+ }
+ sess.WriteLine(" Queued consume action (executes in combat):")
+ sess.WriteLine(" " + cmd)
+ }
+
sess.WriteLine("")
- sess.WriteLine(" All queued actions take effect on the next game tick (600ms).")
+ sess.WriteLine(fmt.Sprintf(" All queued actions take effect on the next game tick (%dms).", g.Ticks.TickLengthMilliseconds()))
sess.WriteLine(" Free actions stack. Only the most recent active action survives.")
// ActivePositions returns the names in resolution order and the caller's
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index 282d3ca..95e9e01 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -308,7 +308,9 @@ func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input strin
sess.WriteLine("You don't have that item.")
return
}
- if len(matches) > 1 {
+ // Multiple slots often hold the same item name (stacking does not always
+ // merge), so only treat distinct names as ambiguous.
+ if len(matches) > 1 && len(uniqueItemNames(matches)) > 1 {
sess.WriteLine("That's ambiguous, which one?")
for _, m := range matches {
def, _ := g.ItemStore.Load(m.ID)
diff --git a/internal/game/cmd_verbs.go b/internal/game/cmd_verbs.go
index b5b0c3d..6102978 100644
--- a/internal/game/cmd_verbs.go
+++ b/internal/game/cmd_verbs.go
@@ -277,7 +277,12 @@ func writeCmdLineStrings(sess *net.Session, cmds []string) {
}
func wrapCmdLines(sess *net.Session, cmds []string, prefix string) {
- const width = 70
+ width := 70
+ if sess.Player != nil {
+ if w := sess.Player.OptionInt("wrap_width"); w > 0 {
+ width = w
+ }
+ }
var current string
for _, cmd := range cmds {
if current == "" {
diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go
index 101c0fe..c9e7c0a 100644
--- a/internal/game/cmd_walk.go
+++ b/internal/game/cmd_walk.go
@@ -42,16 +42,28 @@ func (g *Game) doWalk(sess *net.Session, args []string) {
func parseWalkDirections(input string) ([]string, error) {
var dirs []string
+ total := 0
+ // maxWalkSteps bounds the slice before it is allocated. Agility caps a
+ // real walk well below this; the limit exists only so a hostile count
+ // like "walk 999999999n" cannot allocate gigabytes on the owner goroutine.
+ const maxWalkSteps = 1000
i := 0
for i < len(input) {
num := 0
for i < len(input) && input[i] >= '0' && input[i] <= '9' {
num = num*10 + int(input[i]-'0')
+ if num > maxWalkSteps {
+ return nil, fmt.Errorf("too many steps at once (limit %d)", maxWalkSteps)
+ }
i++
}
if num == 0 {
num = 1
}
+ total += num
+ if total > maxWalkSteps {
+ return nil, fmt.Errorf("too many steps at once (limit %d)", maxWalkSteps)
+ }
if i >= len(input) {
return nil, fmt.Errorf("unexpected end of directions")
}
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index 1e53510..180038b 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -135,6 +135,14 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn
}
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
+ // guard a second pair of combat subscribers is registered and both the
+ // player and the mob attack twice per tick.
+ if g.Combat.Get(p.Name) != nil {
+ sess.WriteLine("You are already in combat!")
+ return
+ }
if mob.OwnerOnly && mob.Owner != "" && mob.Owner != p.Name {
sess.WriteLine("They don't seem interested in you.")
return
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index b717d8e..c5a6d9f 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -379,6 +379,11 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns
if mob.HP < 0 {
mob.HP = 0
}
+ // Every other damage site arms the regen timer; without this a
+ // surviving mob heals 1 HP per tick instead of 1 per 100.
+ if mob.HP > 0 && mob.HP < mob.MaxHP {
+ mob.StartRegen()
+ }
sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %s damage!",
mobDisplayName(mob, true), g.dmgDisplay(sess, retDmg)))
}
diff --git a/internal/game/core_command_queue.go b/internal/game/core_command_queue.go
index 8fce71d..0108ecd 100644
--- a/internal/game/core_command_queue.go
+++ b/internal/game/core_command_queue.go
@@ -89,6 +89,14 @@ func (q *CommandQueue) PeekActive(name string) (*QueuedCommand, bool) {
return qc, ok
}
+// PeekConsume returns the pending consume command without removing it.
+func (q *CommandQueue) PeekConsume(name string) (*QueuedCommand, bool) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ qc, ok := q.consumeQueue[name]
+ return qc, ok
+}
+
// PeekFree returns a copy of the free commands for the player without removing.
func (q *CommandQueue) PeekFree(name string) []QueuedCommand {
q.mu.Lock()
diff --git a/internal/game/core_hacking.go b/internal/game/core_hacking.go
index 0dad4c8..fcfde20 100644
--- a/internal/game/core_hacking.go
+++ b/internal/game/core_hacking.go
@@ -34,7 +34,9 @@ func (g *Game) handleHackingInput(sess *net.Session, input string) {
hs.Started = true
output, done, won := hs.Minigame.HandleInput(input)
- sess.WriteLine(output)
+ // Minigames author inline color tags (e.g. {C4}...{/}); expand them here
+ // because Session.WriteLine only handles ANSI, not tag expansion.
+ sess.WriteLine(g.expandTags(sess, output))
if done {
g.endHacking(sess, p, true, won)
diff --git a/internal/game/correct_audit_fixes_test.go b/internal/game/correct_audit_fixes_test.go
new file mode 100644
index 0000000..90fdc53
--- /dev/null
+++ b/internal/game/correct_audit_fixes_test.go
@@ -0,0 +1,104 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/prediction"
+)
+
+// Regression: gather verbs must normalize to the gather action type, otherwise
+// startAction's bhType != verb gate rejects mine/chop/fish/cut outright.
+func TestGatherVerbsNormalizeToGatherAction(t *testing.T) {
+ for _, verb := range []string{"mine", "chop", "fish", "cut", "shear"} {
+ if got := normalizeVerb(verb); got != string(behavior.TypeGather) {
+ t.Errorf("normalizeVerb(%q) = %q, want %q", verb, got, behavior.TypeGather)
+ }
+ }
+}
+
+// Regression: an unbounded step count must be rejected before it allocates.
+func TestParseWalkDirectionsRejectsHugeCount(t *testing.T) {
+ if _, err := parseWalkDirections("999999999n"); err == nil {
+ t.Fatal("expected an error for an unbounded step count")
+ }
+ dirs, err := parseWalkDirections("3n2e")
+ if err != nil {
+ t.Fatalf("parseWalkDirections: %v", err)
+ }
+ if len(dirs) != 5 {
+ t.Fatalf("len(dirs) = %d, want 5", len(dirs))
+ }
+}
+
+// Regression: collectPredictions must not mark resolved contracts collected
+// when the credit cap blocks the payout, or the winnings are lost forever.
+func TestCollectPredictionsKeepsContractsWhenCreditCapBlocks(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+
+ sess.Player.PredictionContracts = []prediction.Contract{
+ {MarketID: "m1", Status: "resolved", Proceeds: 1000},
+ }
+ sess.Player.Credits = casino.MaxCredits
+
+ h.g.RunOnOwner(func() { h.g.collectPredictions(sess) })
+ if got := sess.Player.PredictionContracts[0].Status; got != "resolved" {
+ t.Fatalf("credit-cap collect changed status to %q; the payout would be lost", got)
+ }
+
+ // With room under the cap, the same contract must still be collectable.
+ sess.Player.Credits = 0
+ h.g.RunOnOwner(func() { h.g.collectPredictions(sess) })
+ if got := sess.Player.PredictionContracts[0].Status; got != "collected" {
+ t.Fatalf("status = %q, want collected", got)
+ }
+ if sess.Player.Credits != 1000 {
+ t.Fatalf("credits = %d, want 1000", sess.Player.Credits)
+ }
+}
+
+// Regression: a craft with no ingredient entries must be unsatisfiable, not
+// "always satisfied" (which would loop producing output for free).
+func TestPlanIngredientsRejectsEmptyCraft(t *testing.T) {
+ c := &item.CraftDef{Type: "fletch"}
+ if plan := planIngredients(c, func(string) int { return 999 }); plan != nil {
+ t.Fatalf("empty craft produced a plan: %+v", plan)
+ }
+}
+
+// Regression: a command reached through a prefix/abbreviation must not have its
+// arguments lowercased (global/symbol/prompt/addlocalobj read rawInput).
+func TestGlobalCommandPreservesArgumentCase(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ h.seedAccount("bob")
+ h.seedCharacter("bob", "bob")
+ alice, _ := h.connect("alice")
+ _, bobConn := h.connect("bob")
+ h.g.RunOnOwner(func() { h.g.HandleSessionOnOwner(alice, "glo Hello World") })
+ if !bobConn.Contains("Hello World") {
+ t.Fatalf("global broadcast lost argument case: %s", bobConn.Bytes())
+ }
+}
+
+// Regression: the player-file classifier must canonicalize repeated "/" and
+// "data/" prefixes so a raw-editor path cannot dodge the online check.
+func TestPlayerDataFileNameCanonicalizesPrefixes(t *testing.T) {
+ for _, in := range []string{
+ "players/characters/alice.yaml",
+ "//players/characters/alice.yaml",
+ "data/data/players/characters/alice.yaml",
+ "/data/players/characters/alice.yaml",
+ } {
+ name, kind, ok := playerDataFileName(in)
+ if !ok || name != "alice" || kind != "characters" {
+ t.Errorf("playerDataFileName(%q) = (%q, %q, %v), want (alice, characters, true)", in, name, kind, ok)
+ }
+ }
+}
diff --git a/internal/game/game.go b/internal/game/game.go
index 0c550ae..df8dab2 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -567,7 +567,11 @@ func (g *Game) AdminPlayerFileOnline(relPath string) bool {
// path that is not players/<kind>/<name>.yaml, including a bare directory or
// a non-YAML file, so callers never treat an unrelated file as a player file.
func playerDataFileName(relPath string) (name, kind string, ok bool) {
- clean := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(strings.TrimPrefix(relPath, "/"), "data/")))
+ clean := filepath.ToSlash(filepath.Clean("/" + strings.TrimPrefix(relPath, "/")))
+ clean = strings.TrimPrefix(clean, "/")
+ for strings.HasPrefix(clean, "data/") {
+ clean = strings.TrimPrefix(clean, "data/")
+ }
name, found := strings.CutSuffix(filepath.Base(clean), ".yaml")
if !found || name == "" {
return "", "", false
@@ -627,10 +631,16 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
}
parts := strings.Fields(strings.ToLower(input))
+ orig := strings.Fields(input)
if len(parts) == 0 {
g.reprompt(sess)
return
}
+ // orig preserves argument case for handlers that read rawInput (global,
+ // symbol, prompt, addlocalobj); parts is the lowercased dispatch form.
+ if len(orig) != len(parts) {
+ orig = parts
+ }
cmd := parts[0]
// Counter commands exist only while using the counter, before global prefixes.
@@ -649,12 +659,14 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) {
if canon, ok := g.casinoAbbreviation(sess, cmd); ok {
cmd = canon
parts[0] = canon
- input = strings.Join(parts, " ")
+ orig[0] = canon
+ input = strings.Join(orig, " ")
} else if len(g.casinoVerbsFor(sess.Player.Name)) == 0 {
if canon, ok := g.resolveCommandPrefix(sess, cmd); ok {
cmd = canon
parts[0] = canon
- input = strings.Join(parts, " ")
+ orig[0] = canon
+ input = strings.Join(orig, " ")
}
}
diff --git a/internal/game/predictions.go b/internal/game/predictions.go
index c61e64e..2cd5917 100644
--- a/internal/game/predictions.go
+++ b/internal/game/predictions.go
@@ -82,11 +82,11 @@ func (g *Game) executePredictions(s *net.Session, args []string, origin string)
if len(args) > 0 {
r.action = strings.ToLower(args[0])
}
- number := func(i int) int {
- if i >= len(args) {
+ number1 := func() int {
+ if len(args) <= 1 {
return 0
}
- n, _ := strconv.Atoi(args[i])
+ n, _ := strconv.Atoi(args[1])
return n
}
switch r.action {
@@ -108,7 +108,7 @@ func (g *Game) executePredictions(s *net.Session, args []string, origin string)
r.search = s.PredictionSearch
case "list":
if len(args) > 1 {
- r.page = number(1)
+ r.page = number1()
}
case "refresh", "next", "prev":
r.action = s.PredictionScreen
@@ -149,7 +149,7 @@ func (g *Game) executePredictions(s *net.Session, args []string, origin string)
r.id = s.PredictionSelected
break
}
- n := number(1)
+ n := number1()
if n < 1 || n > len(s.PredictionIDs) {
s.WriteLine("Choose a market number from markets.")
g.predictionHints(s)
@@ -163,7 +163,7 @@ func (g *Game) executePredictions(s *net.Session, args []string, origin string)
g.predictionHints(s)
return
}
- r.quantity = number(1)
+ r.quantity = number1()
if len(args) < 3 || r.quantity < 1 || r.quantity > 10000 {
s.WriteLine("buy SHARES OUTCOME - for example: buy 10 yes or buy 10 2")
g.predictionHints(s)
@@ -176,7 +176,7 @@ func (g *Game) executePredictions(s *net.Session, args []string, origin string)
r.outcomeName = outcomeTok
}
case "holding", "sell":
- r.lot = number(1) - 1
+ r.lot = number1() - 1
if r.lot < 0 || r.lot >= len(s.Player.PredictionContracts) {
s.WriteLine("Use a contract number from active.")
return
@@ -254,7 +254,7 @@ func (g *Game) executePredictions(s *net.Session, args []string, origin string)
case "active":
g.predictionActive(s, result.markets)
case "closed":
- g.predictionClosed(s, result.markets, r.page)
+ g.predictionClosed(s, r.page)
case "view":
if _, ok := result.markets[r.id]; ok {
g.predictionView(s, result.markets[r.id], result.history)
@@ -386,14 +386,15 @@ func (g *Game) collectPredictions(s *net.Session) {
p := s.Player
before := append([]prediction.Contract(nil), p.PredictionContracts...)
credits := p.Credits
+ // Compute the payout before mutating contract status: if the credit cap
+ // rejects the deposit we must leave the contracts resolved so the player
+ // can collect later. Marking them collected first destroyed the winnings.
total, count := 0, 0
- for i, c := range p.PredictionContracts {
+ for _, c := range p.PredictionContracts {
if c.Status != "resolved" {
continue
}
total += c.Proceeds
- c.Status = "collected"
- p.PredictionContracts[i] = c
count++
}
if count == 0 {
@@ -406,6 +407,13 @@ func (g *Game) collectPredictions(s *net.Session) {
s.WriteLine("Collecting would exceed your credit limit. Bank or spend some credits and try again.")
return
}
+ for i, c := range p.PredictionContracts {
+ if c.Status != "resolved" {
+ continue
+ }
+ c.Status = "collected"
+ p.PredictionContracts[i] = c
+ }
p.Credits = balance
if !g.savePredictions(s, before, credits) {
return
@@ -415,7 +423,7 @@ func (g *Game) collectPredictions(s *net.Session) {
} else {
s.WriteLine(g.colorize(s, "casino_result_push", fmt.Sprintf("Cleared %d resolved contract(s); no payout this time.", count)))
}
- g.predictionClosed(s, nil, 1)
+ g.predictionClosed(s, 1)
}
func (g *Game) tradePrediction(s *net.Session, r predictionRequest, m prediction.Market) (traded bool) {
p := s.Player
@@ -679,7 +687,7 @@ func (g *Game) predictionActive(s *net.Session, markets map[string]prediction.Ma
g.predictionHints(s)
}
-func (g *Game) predictionClosed(s *net.Session, markets map[string]prediction.Market, page int) {
+func (g *Game) predictionClosed(s *net.Session, page int) {
p := s.Player
s.PredictionScreen = "closed"
s.PredictionSelected = ""
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 9795db0..f80daa2 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -154,7 +154,10 @@ func (g *Game) startProductionFromItem(sess *net.Session, p *player.Player, item
info, ok := productionTypes[cKey]
if !ok {
info = productionTypeInfo{
- ActionType: behavior.ActionType(cKey),
+ // Use a known action type so the action actually advances; the
+ // raw subtype is not in productionActionTypes and used to strand
+ // the player in a non-advancing action with no output.
+ ActionType: behavior.TypeCraft,
DisplayVerb: cKey,
StartMessage: "You start " + cKey + " %i1.",
SuccessMessage: "You produce %n.",
diff --git a/internal/game/production_index.go b/internal/game/production_index.go
index 22e944e..4ded387 100644
--- a/internal/game/production_index.go
+++ b/internal/game/production_index.go
@@ -229,6 +229,12 @@ func planIngredients(c *item.CraftDef, countItem func(string) int) []consumption
if c == nil {
return nil
}
+ // A craft with no ingredient entries is not "always satisfied": it would
+ // consume nothing and loop forever producing output. Treat it as
+ // unsatisfiable; the validator also rejects it.
+ if len(c.Ingredients) == 0 {
+ return nil
+ }
reserved := make(map[string]int, len(c.Ingredients))
plan := make([]consumption, 0, len(c.Ingredients))
for i := range c.Ingredients {
diff --git a/internal/game/render_cash_hunt.go b/internal/game/render_cash_hunt.go
index 0bb05fc..bdda321 100644
--- a/internal/game/render_cash_hunt.go
+++ b/internal/game/render_cash_hunt.go
@@ -51,7 +51,8 @@ const (
// cashHuntCenter pads text to a fixed tile width, keeping every column aligned
// whether it holds a one- or three-digit square number.
-func cashHuntCenter(text string, width int) string {
+func cashHuntCenter(text string) string {
+ width := cashHuntTileWidth
if len(text) >= width {
return text
}
@@ -175,12 +176,12 @@ func renderCashHunt(s *casino.CashHuntSnapshot, mode string, unicode bool, rtp s
for col := 0; col < cashHuntColumns; col++ {
k := row*cashHuntColumns + col
bg := cashHuntTileBlack
- number := cashHuntCenter(fmt.Sprintf("%d", k+1), cashHuntTileWidth)
- value := cashHuntCenter(fmt.Sprintf("%dx", s.Squares[k]), cashHuntTileWidth)
+ number := cashHuntCenter(fmt.Sprintf("%d", k+1))
+ value := cashHuntCenter(fmt.Sprintf("%dx", s.Squares[k]))
nf, vf := 244, cashHuntPrizeColor(s.Squares[k])
selected := s.Selected == k+1
if selected {
- number = cashHuntCenter(fmt.Sprintf(">%d<", k+1), cashHuntTileWidth)
+ number = cashHuntCenter(fmt.Sprintf(">%d<", k+1))
nf, vf, bg = 16, 16, 226
}
numbers[col] = color.Render(mode, color.ColorSpec{Fg: nf, Bg: bg, Bold: selected}, number)