diff options
| author | historia <[not public]> | 2026-10-05 17:05:14 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-10-05 17:05:14 -0400 |
| commit | e42952469dadfb3535633c66c881050ac128703c (patch) | |
| tree | a78f7396b72cdbd6fa4f6c454c830f40cf2ad0cb /internal/game | |
| parent | 90ad8c0c33b519d5830c69d4128bdc984b5e9de8 (diff) | |
| download | thehouseoficarus-e42952469dadfb3535633c66c881050ac128703c.tar.gz | |
fix: gather verbs, walk oom, admin path bypass, roulette refund
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/act.go | 19 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 14 | ||||
| -rw-r--r-- | internal/game/big_six_test.go | 2 | ||||
| -rw-r--r-- | internal/game/cash_hunt_test.go | 6 | ||||
| -rw-r--r-- | internal/game/cmd_bank.go | 5 | ||||
| -rw-r--r-- | internal/game/cmd_drop.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_queued.go | 17 | ||||
| -rw-r--r-- | internal/game/cmd_shop.go | 4 | ||||
| -rw-r--r-- | internal/game/cmd_verbs.go | 7 | ||||
| -rw-r--r-- | internal/game/cmd_walk.go | 12 | ||||
| -rw-r--r-- | internal/game/combat_attack.go | 8 | ||||
| -rw-r--r-- | internal/game/combat_mob.go | 5 | ||||
| -rw-r--r-- | internal/game/core_command_queue.go | 8 | ||||
| -rw-r--r-- | internal/game/core_hacking.go | 4 | ||||
| -rw-r--r-- | internal/game/correct_audit_fixes_test.go | 104 | ||||
| -rw-r--r-- | internal/game/game.go | 18 | ||||
| -rw-r--r-- | internal/game/predictions.go | 34 | ||||
| -rw-r--r-- | internal/game/production_engine.go | 5 | ||||
| -rw-r--r-- | internal/game/production_index.go | 6 | ||||
| -rw-r--r-- | internal/game/render_cash_hunt.go | 9 |
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) |
