aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-16 21:35:55 -0400
committerhistoria <[not public]>2026-09-16 21:35:55 -0400
commitfea2cc6839ec3ee17940a2dea69622dc6f4deabf (patch)
tree9d893525ca20d2cc920e9e866f84f71b605d8ae9 /internal
parent35ddd0ad2215760bb1d3dc7724480f179ea33fa6 (diff)
downloadthehouseoficarus-fea2cc6839ec3ee17940a2dea69622dc6f4deabf.tar.gz
fix: unify crafting ingredient resolution; fix superheat alternatives, quantity gates, and dropped combine byproducts
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_room_courses.go7
-rw-r--r--internal/admin/live_owner_test.go3
-rw-r--r--internal/admin/rebuild_after_undo_regression_test.go88
-rw-r--r--internal/admin/server.go18
-rw-r--r--internal/casino/blackjack.go9
-rw-r--r--internal/casino/blackjack_split_regression_test.go36
-rw-r--r--internal/game/account_registration_regression_test.go6
-rw-r--r--internal/game/account_txn.go2
-rw-r--r--internal/game/act_clean.go4
-rw-r--r--internal/game/act_combine.go15
-rw-r--r--internal/game/act_fletch.go8
-rw-r--r--internal/game/act_talk.go30
-rw-r--r--internal/game/cmd_bank.go6
-rw-r--r--internal/game/cmd_clean.go4
-rw-r--r--internal/game/cmd_cook.go4
-rw-r--r--internal/game/cmd_craft.go2
-rw-r--r--internal/game/cmd_description.go4
-rw-r--r--internal/game/cmd_drop.go8
-rw-r--r--internal/game/cmd_estate_directory.go8
-rw-r--r--internal/game/cmd_jack.go2
-rw-r--r--internal/game/cmd_move.go6
-rw-r--r--internal/game/cmd_quit.go2
-rw-r--r--internal/game/cmd_room_remove_test.go4
-rw-r--r--internal/game/cmd_smith.go2
-rw-r--r--internal/game/cmd_trigger_utility.go39
-rw-r--r--internal/game/cmd_undig.go6
-rw-r--r--internal/game/cmd_use.go10
-rw-r--r--internal/game/core_hacking.go8
-rw-r--r--internal/game/core_login_account.go46
-rw-r--r--internal/game/core_login_char.go16
-rw-r--r--internal/game/game.go6
-rw-r--r--internal/game/harness_test.go6
-rw-r--r--internal/game/inventory_pickup_test.go6
-rw-r--r--internal/game/lifecycle_txn_test.go4
-rw-r--r--internal/game/login_offowner_regression_test.go6
-rw-r--r--internal/game/login_race_regression_test.go18
-rw-r--r--internal/game/owner_disconnect_test.go3
-rw-r--r--internal/game/production_combine_regression_test.go154
-rw-r--r--internal/game/production_engine.go16
-rw-r--r--internal/game/production_index.go92
-rw-r--r--internal/game/production_ingredients_test.go124
-rw-r--r--internal/game/production_menu.go20
-rw-r--r--internal/game/quit_casino_test.go4
-rw-r--r--internal/game/quit_state_race_test.go32
-rw-r--r--internal/game/readiness_regression_test.go3
-rw-r--r--internal/game/shutdown_admission_test.go8
-rw-r--r--internal/game/sys_triggers.go2
-rw-r--r--internal/net/server.go20
-rw-r--r--internal/net/web.go4
49 files changed, 728 insertions, 203 deletions
diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go
index bebacff..5a33e6a 100644
--- a/internal/admin/api_room_courses.go
+++ b/internal/admin/api_room_courses.go
@@ -321,6 +321,13 @@ func (s *AdminServer) handleRoomAttachCourse(w http.ResponseWriter, r *http.Requ
failJSON(w, "missing course_id")
return
}
+ // Course IDs join into file paths under data/courses, so an unchecked
+ // id ("../../x") could write course YAML outside the data directory.
+ // Same rule as every other entity route (see validEntityID).
+ if !validEntityID(body.CourseID) {
+ failJSON(w, "invalid course_id")
+ return
+ }
m, _, path, err := s.loadCourseMap(body.CourseID)
if err != nil {
failJSON(w, "course not found: "+body.CourseID)
diff --git a/internal/admin/live_owner_test.go b/internal/admin/live_owner_test.go
index 7e94a1e..e975681 100644
--- a/internal/admin/live_owner_test.go
+++ b/internal/admin/live_owner_test.go
@@ -219,7 +219,8 @@ func newEditorHarness(t *testing.T) (*editorHarness, *gamenet.Session) {
if err := g.AccountStore.SaveCharacter(p); err != nil {
t.Fatal(err)
}
- sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p, State: gamenet.StateGame}
+ sess := &gamenet.Session{Conn: discardConn{}, Account: acc, Player: p}
+ sess.SetState(gamenet.StateGame)
hub.Add(sess)
hub.EnterRoom(sess, 1)
g.Ticks.Start(2)
diff --git a/internal/admin/rebuild_after_undo_regression_test.go b/internal/admin/rebuild_after_undo_regression_test.go
new file mode 100644
index 0000000..134b281
--- /dev/null
+++ b/internal/admin/rebuild_after_undo_regression_test.go
@@ -0,0 +1,88 @@
+package admin
+
+// Regressions for the editor's undo/redo and course-attach paths: file paths
+// built from request bodies must stay inside the data directory, and every
+// disk mutation must refresh the matching runtime state (course index,
+// casino games) or gameplay keeps serving stale data.
+
+import (
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestCourseAttachRejectsTraversalCourseID: course_id joins into a file path
+// under data/courses, so a traversal id ("../../outside") must be rejected
+// instead of writing course YAML outside the data directory.
+func TestCourseAttachRejectsTraversalCourseID(t *testing.T) {
+ root := t.TempDir()
+ dir := filepath.Join(root, "data")
+ if err := os.MkdirAll(filepath.Join(dir, "courses"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(root, "outside.yaml")
+ original := "name: Outside\n"
+ if err := os.WriteFile(target, []byte(original), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)}
+
+ rr := httptest.NewRecorder()
+ s.handleRoomAttachCourse(rr, httptest.NewRequest("POST", "/api/rooms/1/attach-course",
+ strings.NewReader(`{"course_id":"../../outside","after_index":-1}`)), 1)
+
+ if got, err := os.ReadFile(target); err != nil || string(got) != original {
+ t.Fatalf("path traversal modified a file outside the data directory; response=%s file=%q err=%v", rr.Body.String(), got, err)
+ }
+}
+
+// TestUndoOfCourseChangeReloadsRuntimeCourses: undoing a course edit restores
+// the on-disk YAML, so the runtime course index must reload like the course
+// API handlers do — otherwise gameplay keeps traversing the stale course.
+func TestUndoOfCourseChangeReloadsRuntimeCourses(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{1: "id: 1\nname: Room\n"})
+ if err := os.MkdirAll(filepath.Join(s.dataDir, "courses"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ reloads := 0
+ s.reloadCourses = func() { reloads++ }
+
+ s.handleCourses(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/courses",
+ strings.NewReader(`{"id":"course","name":"Course","obstacles":[{"room_id":1,"verb":"climb"}]}`)))
+ if reloads != 1 {
+ t.Fatalf("course creation reload count: %d, want 1", reloads)
+ }
+
+ s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil))
+ if _, err := os.Stat(s.courseFilePath("course")); !os.IsNotExist(err) {
+ t.Fatal("undo did not delete the course file")
+ }
+ if reloads != 2 {
+ t.Fatalf("undo restored the disk but did not reload courses: got %d callbacks, want 2", reloads)
+ }
+}
+
+// TestUndoOfRoomCreateEvictsCasinoGames: undoing a room creation deletes the
+// room file, so its cached casino games must be evicted (and their outstanding
+// wagers refunded through the wired callback) exactly like the direct-delete
+// API does. Skipping eviction strands seated players' wagers in games whose
+// room no longer exists.
+func TestUndoOfRoomCreateEvictsCasinoGames(t *testing.T) {
+ content := "id: 1\nname: Room\n"
+ s := newTestAdminServer(t, map[int]string{1: content})
+ evictions := 0
+ s.evictRoom = func(id int) { evictions++ }
+ path, _ := s.world.GetRoomPath(1)
+
+ s.undoStack.Push(ChangeDesc{Description: "create room", FilePath: path, NewContent: []byte(content), IsCreate: true})
+ s.doUndo(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/undo/undo", nil))
+
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatal("undo did not delete the room")
+ }
+ if evictions != 1 {
+ t.Fatalf("undo removed the room without evicting/refunding its casino games: got %d callbacks, want 1", evictions)
+ }
+}
diff --git a/internal/admin/server.go b/internal/admin/server.go
index 2c8681a..12d090d 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -720,6 +720,13 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
if s.mobStore != nil {
s.mobStore.RemoveMobsInRoom(id)
}
+ // The room no longer exists, so its cached casino games
+ // (tables/machines) must be evicted like the direct-delete
+ // API does: outstanding wagers are refunded through the
+ // wired game callback before the cache entries drop.
+ if s.evictRoom != nil {
+ s.evictRoom(id)
+ }
}
}
}
@@ -728,6 +735,17 @@ func (s *AdminServer) rebuildAfterUndo(change *ChangeDesc) {
for _, p := range changeFiles(change) {
s.invalidateEntityCacheForPath(p)
}
+
+ // Course definitions changed on disk (create/update/delete via undo or
+ // redo): the runtime course index must reload, exactly like the course
+ // API handlers do, or gameplay keeps traversing the stale course.
+ for _, p := range changeFiles(change) {
+ if rel, err := filepath.Rel(filepath.Join(s.dataDir, "courses"), p); err == nil &&
+ !strings.HasPrefix(rel, "..") && rel != "." {
+ s.reloadCoursesIfWired()
+ break
+ }
+ }
}
func (s *AdminServer) invalidateEntityCaches(entityType string) {
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index bc038c7..39146f7 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -365,12 +365,15 @@ func (t *blackjackTable) splitHand(name string, p *blackjackPlayer, hand *blackj
e.Blackjack = t.snapshot(false)
events := []Event{e}
if left.cards[0].rank == "A" {
+ // Split aces receive one card each; they only stay live when a
+ // re-split of aces is allowed and the new card is itself an ace.
left.stood = !(t.Rules.AllowsResplitAces() && left.cards[1].rank == "A")
right.stood = !(t.Rules.AllowsResplitAces() && right.cards[1].rank == "A")
- events = append(events, t.finishHand()...)
- } else {
- events = append(events, t.prompt()...)
}
+ // Route through finishHand, not a raw prompt: a split draw can already
+ // total 21 (e.g. K,K splits into A+K), and prompting a complete hand
+ // offered hit/double on an unplayable 21.
+ events = append(events, t.finishHand()...)
return events
}
diff --git a/internal/casino/blackjack_split_regression_test.go b/internal/casino/blackjack_split_regression_test.go
new file mode 100644
index 0000000..1674d40
--- /dev/null
+++ b/internal/casino/blackjack_split_regression_test.go
@@ -0,0 +1,36 @@
+package casino
+
+// Regression: a non-ace split can draw to 21 on the first hand (K,K splits
+// into A+K). finishHand must skip that complete hand instead of prompting it,
+// which offered hit/double on an unplayable 21 and stalled the round until
+// the action timer fired.
+
+import "testing"
+
+func TestBlackjackSplitTwentyOneAdvancesToNextHand(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ wallet := &testWallet{credits: 100}
+ table.join("alice")
+ // Dealer 17; player K,K; the split draws A (hand one) and 5 (hand two).
+ table.shoe = tableauShoe(
+ card{rank: "10"}, card{rank: "7"},
+ card{rank: "K"}, card{rank: "K"},
+ card{rank: "A"}, card{rank: "5"}, card{rank: "2"},
+ )
+ table.shoe.shuffleAt = 0
+ table.bet("alice", "", 10, wallet)
+ if events := table.action("alice", "split"); len(events) == 0 {
+ t.Fatal("split produced no events")
+ }
+ p := table.players.get("alice")
+ if len(p.hands) != 2 {
+ t.Fatalf("split failed: %+v", p.hands)
+ }
+ if total, _ := p.hands[0].total(); total != 21 {
+ t.Fatalf("fixture: first split hand totals %d, want 21", total)
+ }
+ if table.activeHand != 1 {
+ t.Fatalf("split 21 stayed active (hand %d, actions %v); the turn must advance to the second hand",
+ table.activeHand, table.availableActions(p, p.hands[table.activeHand]))
+ }
+}
diff --git a/internal/game/account_registration_regression_test.go b/internal/game/account_registration_regression_test.go
index 42e7ad1..123ee10 100644
--- a/internal/game/account_registration_regression_test.go
+++ b/internal/game/account_registration_regression_test.go
@@ -17,8 +17,10 @@ func TestPendingRegistrationCannotOverwriteExistingAccount(t *testing.T) {
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
- a := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
- b := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
+ a := &net.Session{Conn: newChanConn()}
+ a.SetState(net.StateAccountName)
+ b := &net.Session{Conn: newChanConn()}
+ b.SetState(net.StateAccountName)
h.g.RunOnOwner(func() {
// Both sessions type the same name while it does not exist yet;
// both reach the password-confirmation state.
diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go
index 25cbcdb..2a420ca 100644
--- a/internal/game/account_txn.go
+++ b/internal/game/account_txn.go
@@ -354,7 +354,7 @@ func (g *Game) publishAccountUpdate(oldName, newName string) {
}
if !found {
sess.PendingChar = ""
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
}
}
diff --git a/internal/game/act_clean.go b/internal/game/act_clean.go
index f995ae1..2872173 100644
--- a/internal/game/act_clean.go
+++ b/internal/game/act_clean.go
@@ -42,7 +42,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
if p.Level(player.Pharmacy) < c.Level {
continue
}
- if !craftHasAllItems(c, p.HasItem) {
+ if !craftHasAllItems(c, p.CountItem) {
continue
}
matchItem = item
@@ -89,7 +89,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) {
continue
}
}
- if p.Level(player.Pharmacy) >= c.Level && craftHasAllItems(c, p.HasItem) {
+ if p.Level(player.Pharmacy) >= c.Level && craftHasAllItems(c, p.CountItem) {
hasMore = true
break
}
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index 26d85c6..bc9b6de 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -100,7 +100,12 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
return
}
- consumeIngredients(craft, p.HasItem, p.RemoveItem)
+ // Byproducts must be collected BEFORE consumption: the resolver maps
+ // each ingredient entry to the alternative actually being consumed
+ // (bucket vs jug of water), which requires the items still present.
+ byproducts := g.collectByproducts(p, item)
+
+ consumeIngredients(craft, p.CountItem, p.RemoveItem)
outputQty := craft.OutputQty
if outputQty <= 0 {
@@ -129,6 +134,14 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty})
}
+ // Return the empty containers (and any other byproducts) alongside
+ // the output; without this, combine crafts silently discarded them.
+ for _, bp := range byproducts {
+ if slot := p.FirstFreeSlot(); slot >= 0 {
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1})
+ }
+ }
+
g.saveCharacter(p)
if d.Remaining > 0 {
diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go
index 96ba736..c52aae1 100644
--- a/internal/game/act_fletch.go
+++ b/internal/game/act_fletch.go
@@ -44,7 +44,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == def.ID {
- consumeIngredients(c, p.HasItem, p.RemoveItem)
+ consumeIngredients(c, p.CountItem, p.RemoveItem)
slot.Quantity += outputQty
placed = true
break
@@ -52,7 +52,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
}
}
if !placed {
- consumeIngredients(c, p.HasItem, p.RemoveItem)
+ consumeIngredients(c, p.CountItem, p.RemoveItem)
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
break
@@ -165,7 +165,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == def.ID {
- consumeIngredients(c, p.HasItem, p.RemoveItem)
+ consumeIngredients(c, p.CountItem, p.RemoveItem)
slot.Quantity += outputQty
placed = true
break
@@ -173,7 +173,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
}
}
if !placed {
- consumeIngredients(c, p.HasItem, p.RemoveItem)
+ consumeIngredients(c, p.CountItem, p.RemoveItem)
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
sess.WriteLine("Your inventory is too full!")
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index dfa7b94..ef3c6e5 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -65,7 +65,7 @@ func (g *Game) handleNodeAction(sess *net.Session, na *behavior.Step) (intercept
if td, ok := p.Action.Data.(*behavior.TalkData); ok && td.StealGuard {
guardMob := g.findGuardInRoom(p.RoomID, "guard")
if guardMob != nil {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.cancelAction(p)
guardMob.Protected = false
g.startCombat(sess, p, guardMob)
@@ -103,7 +103,7 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
}
td.SeqIndex = 1
if td.SeqIndex < len(node.Messages) {
- sess.State = net.StateTalkSequence
+ sess.SetState(net.StateTalkSequence)
sess.Write(g.colorize(sess, "dialog_options", "[enter to continue]\n"))
return
}
@@ -119,7 +119,7 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
for i, opt := range visible {
sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, g.expandDialogTags(sess, g.resolveColor(sess, "dialog_options"), opt.Text)))
}
- sess.State = net.StateTalk
+ sess.SetState(net.StateTalk)
g.reprompt(sess)
return
}
@@ -138,7 +138,7 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
}
}
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
}
@@ -159,7 +159,7 @@ func (g *Game) finishSequence(sess *net.Session, node behavior.TalkNode) {
for i, opt := range visible {
sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, g.expandDialogTags(sess, g.resolveColor(sess, "dialog_options"), opt.Text)))
}
- sess.State = net.StateTalk
+ sess.SetState(net.StateTalk)
g.reprompt(sess)
return
}
@@ -175,13 +175,13 @@ func (g *Game) finishSequence(sess *net.Session, node behavior.TalkNode) {
}
g.cancelAction(sess.Player)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
}
func (g *Game) handleTalkInput(sess *net.Session, input string) {
p := sess.Player
if p == nil || p.Action == nil || p.Action.Type != behavior.TypeTalk {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -193,17 +193,17 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
td, ok := p.Action.Data.(*behavior.TalkData)
if !ok || td.Cfg == nil {
g.cancelAction(p)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
input = strings.TrimSpace(input)
- if input == "" && sess.State != net.StateTalkSequence {
+ if input == "" && sess.State() != net.StateTalkSequence {
input = "1"
}
- if sess.State == net.StateTalkSequence {
+ if sess.State() == net.StateTalkSequence {
_, ok := td.Cfg.Nodes[td.Node]
if !ok {
td.CancelTalk = true
@@ -270,14 +270,14 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
td, ok := p.Action.Data.(*behavior.TalkData)
if !ok || td.Cfg == nil {
g.cancelAction(p)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
return
}
if td.CancelTalk {
sess.WriteLine("You leave the conversation.")
g.cancelAction(p)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
return
}
@@ -286,7 +286,7 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
node, ok := td.Cfg.Nodes[td.Node]
if !ok {
g.cancelAction(p)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
return
}
dialogSpec := g.resolveColor(sess, "dialog")
@@ -301,7 +301,7 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
g.finishSequence(sess, node)
return
}
- sess.State = net.StateTalkSequence
+ sess.SetState(net.StateTalkSequence)
sess.Write("[enter to continue]\n")
return
}
@@ -325,7 +325,7 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
}
}
g.cancelAction(p)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
return
}
}
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 264bab2..6326700 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -18,7 +18,7 @@ func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) {
func (g *Game) handleBankInput(sess *net.Session, input string) {
if sess.Player == nil {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -416,13 +416,13 @@ func (g *Game) doBank(sess *net.Session) {
return
}
- sess.State = net.StateBank
+ sess.SetState(net.StateBank)
sess.WriteLine(g.colorize(sess, "dialog", "You access the bank terminal."))
g.showBankBrowse(sess)
g.writeBankPrompt(sess)
}
func (g *Game) leaveBank(sess *net.Session) {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
}
diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go
index 9fc37b7..3714fa0 100644
--- a/internal/game/cmd_clean.go
+++ b/internal/game/cmd_clean.go
@@ -34,7 +34,7 @@ func (g *Game) doClean(sess *net.Session, input string) {
if p.Level(player.Pharmacy) < item.FirstCraft().Level {
continue
}
- if !craftHasAllItems(item.FirstCraft(), p.HasItem) {
+ if !craftHasAllItems(item.FirstCraft(), p.CountItem) {
continue
}
found = true
@@ -47,7 +47,7 @@ func (g *Game) doClean(sess *net.Session, input string) {
if filter != "" && !matchesCleanFilter(item, filter) {
continue
}
- if craftHasAllItems(item.FirstCraft(), p.HasItem) {
+ if craftHasAllItems(item.FirstCraft(), p.CountItem) {
hasHerbs = true
break
}
diff --git a/internal/game/cmd_cook.go b/internal/game/cmd_cook.go
index 0e4f194..b37a576 100644
--- a/internal/game/cmd_cook.go
+++ b/internal/game/cmd_cook.go
@@ -50,7 +50,7 @@ func (g *Game) doCook(sess *net.Session, input string) {
var recipes []*item.ItemDef
for _, item := range items {
c := item.FirstCraft()
- if stationMatch(stationDefID, c.Station) && craftMatchesEntry(c, itemID) && craftHasAllItems(c, p.HasItem) {
+ if stationMatch(stationDefID, c.Station) && craftMatchesEntry(c, itemID) && craftHasAllItems(c, p.CountItem) {
recipes = append(recipes, item)
}
}
@@ -71,7 +71,7 @@ func (g *Game) showCookMenu(sess *net.Session, p *player.Player, items []*item.I
if seen[item.ID] {
continue
}
- if !craftHasAllItems(c, p.HasItem) {
+ if !craftHasAllItems(c, p.CountItem) {
continue
}
seen[item.ID] = true
diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go
index 7091e39..fe52511 100644
--- a/internal/game/cmd_craft.go
+++ b/internal/game/cmd_craft.go
@@ -174,7 +174,7 @@ func (g *Game) hasGrimyHerbs(p *player.Player) bool {
items := g.CraftIndex.BySubtype("cleaning")
for _, def := range items {
if def.FindCraft(func(c item.CraftDef) bool {
- return craftHasAllItems(&c, p.HasItem)
+ return craftHasAllItems(&c, p.CountItem)
}) != nil {
return true
}
diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go
index c8a13d1..05a577f 100644
--- a/internal/game/cmd_description.go
+++ b/internal/game/cmd_description.go
@@ -20,7 +20,7 @@ func (g *Game) doDescription(sess *net.Session) {
}
sess.WriteLine("")
sess.Write("Enter a new description (or press enter to keep current): ")
- sess.State = net.StateChangeDesc
+ sess.SetState(net.StateChangeDesc)
}
func (g *Game) handleDescChange(sess *net.Session, input string) {
@@ -32,6 +32,6 @@ func (g *Game) handleDescChange(sess *net.Session, input string) {
} else {
sess.WriteLine("Description left unchanged.")
}
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 072f54a..5920696 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -21,7 +21,7 @@ func (g *Game) doDropAll(sess *net.Session) {
sess.WriteLine("You have nothing to drop.")
return
}
- sess.State = net.StateDropAllConfirm
+ sess.SetState(net.StateDropAllConfirm)
sess.WriteLine(fmt.Sprintf("Drop all %d items? [y/N]", count))
}
@@ -194,7 +194,7 @@ func (g *Game) executeDrop(sess *net.Session, args []string, rawInput string) {
func (g *Game) handleDropAll(sess *net.Session, input string) {
p := sess.Player
if p == nil {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -202,7 +202,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) {
g.cancelAction(p)
input = strings.TrimSpace(strings.ToLower(input))
if input != "y" && input != "yes" {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -224,7 +224,7 @@ func (g *Game) handleDropAll(sess *net.Session, input string) {
}
g.saveCharacter(p)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
if len(dropped) == 0 {
sess.WriteLine("You have nothing to drop.")
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index fc79e6c..3da2c18 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -71,7 +71,7 @@ func (g *Game) showEstateDirectoryTalk(sess *net.Session, p *player.Player, home
sess.WriteLine("")
sess.WriteLine(" You are a registered homeowner in this neighborhood.")
sess.WriteLine(" Type 'enter' to go to your house, or 'leave' to step away.")
- sess.State = net.StateTalk
+ sess.SetState(net.StateTalk)
p.Action = &behavior.Action{
Type: "talk",
TargetID: "estate_directory",
@@ -101,12 +101,12 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) {
}
houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood")
if houseRoom > 0 {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
p.Action = nil
g.teleportPlayer(sess, p, houseRoom)
return
}
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
p.Action = nil
g.writePrompt(sess)
case "leave", "bye", "exit", "quit":
@@ -114,7 +114,7 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) {
if p != nil {
p.Action = nil
}
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
default:
sess.WriteLine("Type 'enter' to go to your house, or 'leave' to step away.")
diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go
index efdbba5..e5d488c 100644
--- a/internal/game/cmd_jack.go
+++ b/internal/game/cmd_jack.go
@@ -64,7 +64,7 @@ func (g *Game) doJack(sess *net.Session, target string) {
Started: false,
}
- sess.State = net.StateHacking
+ sess.SetState(net.StateHacking)
g.broadcastAction(sess, "%s jacks into a terminal.", p.Name)
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 72c3236..35cccba 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -60,7 +60,7 @@ func (g *Game) doMove(sess *net.Session, dir string, isWalk bool) {
} else {
p.PendingDangerDir = dir
p.WalkSequence = nil
- sess.State = net.StateDangerConfirm
+ sess.SetState(net.StateDangerConfirm)
name := targetRoom.Hazard
if hz, herr := g.World.LoadHazard(targetRoom.Hazard); herr == nil && hz.Name != "" {
name = hz.Name
@@ -250,14 +250,14 @@ func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) {
func (g *Game) handleDangerConfirm(sess *net.Session, input string) {
p := sess.Player
if p == nil {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
dir := p.PendingDangerDir
choice := strings.TrimSpace(strings.ToLower(input))
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
if choice != "" && choice != "y" && choice != "yes" {
p.PendingDangerDir = ""
diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go
index e564c7e..d6e3313 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -90,7 +90,7 @@ func (g *Game) beginQuitCountdown(sess *net.Session) {
g.Hub.LeaveRoom(sess)
}
sess.Player = nil
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
return false
}
diff --git a/internal/game/cmd_room_remove_test.go b/internal/game/cmd_room_remove_test.go
index 97ab859..be2d917 100644
--- a/internal/game/cmd_room_remove_test.go
+++ b/internal/game/cmd_room_remove_test.go
@@ -49,7 +49,9 @@ func newRemoveGame(t *testing.T, dir string) *Game {
}
func newRemoveSession(p *player.Player) *net.Session {
- return &net.Session{Conn: &removeTestConn{}, Player: p, State: net.StateGame}
+ sess := &net.Session{Conn: &removeTestConn{}, Player: p}
+ sess.SetState(net.StateGame)
+ return sess
}
func writeRemoveRoomFile(t *testing.T, dir string, id int, body string) {
diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go
index 4330b1d..dbab1c2 100644
--- a/internal/game/cmd_smith.go
+++ b/internal/game/cmd_smith.go
@@ -69,7 +69,7 @@ func (g *Game) doSmith(sess *net.Session, input string) {
}
sess.PendingMenu = barMenuData(barTypes)
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
var names []string
for _, barID := range barTypes {
def, _ := g.ItemStore.Load(barID)
diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go
index ca65445..09eabb1 100644
--- a/internal/game/cmd_trigger_utility.go
+++ b/internal/game/cmd_trigger_utility.go
@@ -187,29 +187,34 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
return
}
- for _, e := range c.Ingredients {
- for _, itemID := range e.Items {
- qty := e.Quantity
- if qty <= 0 {
- qty = 1
- }
- if p.CountItem(itemID) < qty {
- def, _ := g.ItemStore.Load(itemID)
- name := itemID
- if def != nil {
- name = def.Name
- }
- sess.WriteLine(fmt.Sprintf("You need %d %s.", qty, name))
- return
+ // Availability resolves each entry the way consumeIngredients will: ONE
+ // listed alternative must cover the entry's full quantity. The previous
+ // hand-rolled loop required EVERY listed alternative, e.g. both a
+ // bucket AND a jug of water to superheat clay, and could not name the
+ // missing choice.
+ for i := range c.Ingredients {
+ id, qty := craftIngredientSource(&c.Ingredients[i], p.CountItem)
+ if id != "" {
+ continue
+ }
+ names := make([]string, 0, len(c.Ingredients[i].Items))
+ for _, itemID := range c.Ingredients[i].Items {
+ def, _ := g.ItemStore.Load(itemID)
+ name := itemID
+ if def != nil {
+ name = def.Name
}
+ names = append(names, fmt.Sprintf("%d %s", qty, name))
}
+ sess.WriteLine(fmt.Sprintf("You need one of: %s.", strings.Join(names, " or ")))
+ return
}
if p.Action != nil {
g.cancelAction(p)
}
- consumeIngredients(c, p.HasItem, p.RemoveItem)
+ consumeIngredients(c, p.CountItem, p.RemoveItem)
outputQty := c.OutputQty
if outputQty <= 0 {
@@ -226,6 +231,10 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef
}
if !placed {
freeSlot := p.FirstFreeSlot()
+ if freeSlot < 0 {
+ sess.WriteLine("Your inventory is too full!")
+ return
+ }
p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: match.ID, Quantity: outputQty})
}
diff --git a/internal/game/cmd_undig.go b/internal/game/cmd_undig.go
index c24b299..8a47db9 100644
--- a/internal/game/cmd_undig.go
+++ b/internal/game/cmd_undig.go
@@ -55,7 +55,7 @@ func (g *Game) executeUndig(sess *net.Session, args []string, rawInput string) {
sess.PendingUndigDir = string(dir)
sess.PendingUndigRoom = targetID
- sess.State = net.StateUndigConfirm
+ sess.SetState(net.StateUndigConfirm)
sess.WriteLine(fmt.Sprintf("You are about to DELETE Room #%d (%s) and all exits leading to it.", targetID, targetRoom.Name))
g.listUndigOrphans(sess, targetID)
@@ -125,7 +125,7 @@ func (g *Game) listUndigOrphans(sess *net.Session, targetID int) {
func (g *Game) handleUndigConfirm(sess *net.Session, input string) {
p := sess.Player
if p == nil {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -135,7 +135,7 @@ func (g *Game) handleUndigConfirm(sess *net.Session, input string) {
targetID := sess.PendingUndigRoom
sess.PendingUndigDir = ""
sess.PendingUndigRoom = 0
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
if dir == "" || targetID == 0 {
g.writePrompt(sess)
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index b975469..8c82f9c 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -163,7 +163,7 @@ func (g *Game) showRecipeMenu(sess *net.Session, items []*item.ItemDef, stationN
}
menu := itemMenuData(itemIDs(items))
sess.PendingMenu = menu
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
var names []string
for _, item := range items {
label := craftLabel(item.FirstCraft())
@@ -235,7 +235,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
return
}
sess.PendingMenu = itemMenuData(itemIDs(filtered))
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
var names []string
for _, item := range filtered {
names = append(names, g.itemName(sess, item))
@@ -260,7 +260,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
return
}
sess.PendingMenu = itemMenuData(itemIDs(recipes))
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
var names []string
for _, item := range recipes {
names = append(names, g.itemName(sess, item))
@@ -314,7 +314,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
names = append(names, fmt.Sprintf("%s (Fletching)", item.Name))
}
sess.PendingMenu = menu
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
g.showMenuTable(sess, "What would you like to make?", names)
return
}
@@ -366,7 +366,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName,
}
if len(matchable) > 1 {
sess.PendingMenu = itemMenuData(itemIDs(matchable))
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
var names []string
for _, item := range matchable {
names = append(names, g.itemName(sess, item))
diff --git a/internal/game/core_hacking.go b/internal/game/core_hacking.go
index af7a211..0dad4c8 100644
--- a/internal/game/core_hacking.go
+++ b/internal/game/core_hacking.go
@@ -12,13 +12,13 @@ import (
func (g *Game) handleHackingInput(sess *net.Session, input string) {
p := sess.Player
if p == nil {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
return
}
hs, ok := g.hackingStates[p.Name]
if !ok {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -47,7 +47,7 @@ func (g *Game) handleHackingInput(sess *net.Session, input string) {
func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, won bool) {
hs, ok := g.hackingStates[p.Name]
if !ok {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
return
}
@@ -87,6 +87,6 @@ func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, w
}
delete(g.hackingStates, p.Name)
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.writePrompt(sess)
}
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go
index f6e5631..921af82 100644
--- a/internal/game/core_login_account.go
+++ b/internal/game/core_login_account.go
@@ -20,7 +20,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
if err == nil {
sess.Account = &player.Account{Name: actualName}
sess.SetAuthName(actualName)
- sess.State = net.StatePassword
+ sess.SetState(net.StatePassword)
sess.SetEcho(false)
sess.Write("Password: ")
} else {
@@ -31,7 +31,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) {
}
sess.Account = &player.Account{Name: name}
sess.SetAuthName(name)
- sess.State = net.StateNewAccountPass
+ sess.SetState(net.StateNewAccountPass)
sess.SetEcho(false)
sess.WriteLine("\r\nOh, a new visitor to this rock!")
sess.Write("Choose password: ")
@@ -46,7 +46,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
if len(input) > 128 {
sess.SetEcho(true)
sess.WriteLine("Wrong password.")
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -54,14 +54,14 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
if err != nil {
sess.SetEcho(true)
sess.WriteLine("Error loading account.")
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
if !player.CheckPassword(input, acc.PasswordHash) {
sess.SetEcho(true)
sess.WriteLine("Wrong password.")
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -116,7 +116,7 @@ func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) {
}
sess.SetEcho(true)
sess.WriteLine("Wrong password.")
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
})
return
@@ -168,7 +168,7 @@ func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) {
sess.SetEcho(true)
reject := func(message string) {
sess.WriteLine(message)
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
}
if auth.loadErr {
@@ -220,7 +220,7 @@ func (g *Game) handleNewPass(sess *net.Session, input string) {
if input == "" {
sess.SetEcho(true)
sess.Account = nil
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -230,7 +230,7 @@ func (g *Game) handleNewPass(sess *net.Session, input string) {
return
}
sess.PendingPass = input
- sess.State = net.StateNewAccount
+ sess.SetState(net.StateNewAccount)
sess.Write("Confirm password: ")
}
@@ -239,7 +239,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
if input == "" {
sess.SetEcho(true)
sess.Account = nil
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -247,7 +247,7 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) {
sess.SetEcho(true)
sess.WriteLine("Passwords do not match.")
sess.Account = nil
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -287,7 +287,7 @@ func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) {
sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", hashErr))
sess.Account = nil
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -299,7 +299,7 @@ func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) {
if _, findErr := g.AccountStore.FindAccount(sess.Account.Name); findErr == nil {
sess.SetEcho(true)
sess.Account = nil
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.WriteLine("That account name was just taken; please sign in instead.")
sess.Write("Account name: ")
return
@@ -313,7 +313,7 @@ func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) {
sess.SetEcho(true)
sess.WriteLine(fmt.Sprintf("Error creating account: %v", err))
sess.Account = nil
- sess.State = net.StateAccountName
+ sess.SetState(net.StateAccountName)
sess.Write("Account name: ")
return
}
@@ -327,7 +327,7 @@ func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) {
Options: make(map[string]any),
}
sess.PendingPass = ""
- sess.State = net.StateColorChoice
+ sess.SetState(net.StateColorChoice)
sess.Write("\nShould I disable color? [y/N] ")
}
@@ -359,7 +359,7 @@ func menuColorMode(sess *net.Session) string {
}
func (g *Game) showMenu(sess *net.Session) {
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
mode := menuColorMode(sess)
lines := []string{
"",
@@ -404,7 +404,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
}
- sess.State = net.StateNewCharName
+ sess.SetState(net.StateNewCharName)
sess.PendingChar = "connect"
sess.Write("\n> ")
@@ -420,12 +420,12 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
sess.Write("\n> ")
case "n":
- sess.State = net.StateNewCharName
+ sess.SetState(net.StateNewCharName)
sess.PendingChar = ""
sess.Write("What's your character name?: ")
case "a":
- sess.State = net.StateRenameAccount
+ sess.SetState(net.StateRenameAccount)
sess.Write("New account name: ")
case "r":
@@ -436,12 +436,12 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
}
if len(sess.Account.Characters) == 1 {
sess.PendingChar = sess.Account.Characters[0]
- sess.State = net.StateRenameCharName
+ sess.SetState(net.StateRenameCharName)
sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar))
sess.Write("New name: ")
return
}
- sess.State = net.StateRenameChar
+ sess.SetState(net.StateRenameChar)
sess.WriteLine("\nRename which character?")
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
@@ -457,7 +457,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
if len(sess.Account.Characters) == 1 {
sess.PendingChar = sess.Account.Characters[0]
} else {
- sess.State = net.StateDeleteChar
+ sess.SetState(net.StateDeleteChar)
sess.WriteLine("\nDelete which character?")
for i, name := range sess.Account.Characters {
sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name))
@@ -468,7 +468,7 @@ func (g *Game) handleMenu(sess *net.Session, input string) {
g.showDeleteConfirm(sess)
case "p":
- sess.State = net.StatePurgeAccount
+ sess.SetState(net.StatePurgeAccount)
sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name))
sess.Write("\n> ")
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index c21ea87..70746fc 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -46,7 +46,7 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
p, err := g.createCharacterTxn(sess.Account.Name, name, g.StartingRoom)
if err != nil {
sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
return
}
@@ -73,13 +73,13 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
actualName, err := g.canonicalChar(name)
if err != nil {
sess.WriteLine(fmt.Sprintf("Error loading character: %v", err))
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
return
}
if !g.isMemberOf(sess.Account.Name, actualName) {
sess.WriteLine("That character does not belong to this account.")
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
return
}
@@ -89,7 +89,7 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
if existing := g.loggedInChars[name]; existing != nil {
g.charsMu.Unlock()
sess.WriteLine("This character is logged in elsewhere.")
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
return
}
@@ -98,14 +98,14 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
if err != nil {
g.charsMu.Unlock()
sess.WriteLine(fmt.Sprintf("Error loading character: %v", err))
- sess.State = net.StateMenu
+ sess.SetState(net.StateMenu)
g.showMenu(sess)
return
}
g.adoptCharacterIdentity(p, sess.Account.Name)
sess.Player = p
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.loggedInChars[name] = sess
g.charsMu.Unlock()
@@ -155,7 +155,7 @@ func (g *Game) handleRenameChar(sess *net.Session, input string) {
return
}
- sess.State = net.StateRenameCharName
+ sess.SetState(net.StateRenameCharName)
sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar))
sess.Write("New name: ")
}
@@ -230,7 +230,7 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) {
}
func (g *Game) showDeleteConfirm(sess *net.Session) {
- sess.State = net.StateDeleteChar
+ sess.SetState(net.StateDeleteChar)
sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar))
}
diff --git a/internal/game/game.go b/internal/game/game.go
index 7255042..fc6914f 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -297,7 +297,7 @@ func (g *Game) HandleSessionOnOwner(sess *net.Session, input string) {
// would stall the whole world. Verify/hash on the caller's connection
// goroutine, then submit one owner task for the (cheap) state
// transition.
- switch sess.State {
+ switch sess.State() {
case net.StatePassword:
g.handlePasswordOffOwner(sess, input)
return
@@ -328,7 +328,7 @@ func (g *Game) RunOnOwner(fn func()) {
// already be on the owner goroutine (use HandleSessionOnOwner otherwise).
func (g *Game) HandleSession(sess *net.Session, input string) {
input = player.StripControlCharacters(input)
- switch sess.State {
+ switch sess.State() {
case net.StateAccountName:
g.handleAccountName(sess, input)
case net.StatePassword:
@@ -574,7 +574,7 @@ func (g *Game) ProcessQueuedCommands() {
isBusy := p.Action != nil || len(p.WalkSequence) > 0 || g.Combat.Get(p.Name) != nil || p.MoveTicks > 0 || isHiding
isCasinoSpin := g.Casino != nil && g.Casino.MachineBusy(p.Name)
_, isResting := g.restTimers[p.Name]
- if !isResting && !isBusy && !isCasinoSpin && qc.Session.State == net.StateGame {
+ if !isResting && !isBusy && !isCasinoSpin && qc.Session.State() == net.StateGame {
g.writePrompt(qc.Session)
}
}
diff --git a/internal/game/harness_test.go b/internal/game/harness_test.go
index c80f437..1d3df10 100644
--- a/internal/game/harness_test.go
+++ b/internal/game/harness_test.go
@@ -196,7 +196,8 @@ func (h *harness) connect(name string) (*net.Session, *chanConn) {
t.Fatalf("connect %s: %v", name, err)
}
conn := newChanConn()
- sess := &net.Session{Conn: conn, Account: acc, Player: p, State: net.StateGame}
+ sess := &net.Session{Conn: conn, Account: acc, Player: p}
+ sess.SetState(net.StateGame)
sess.StartWriter()
h.hub.Add(sess)
h.hub.EnterRoom(sess, p.RoomID)
@@ -216,7 +217,8 @@ func (h *harness) connectMenu(accountName string) *net.Session {
h.t.Fatalf("connectMenu %s: %v", accountName, err)
}
conn := newChanConn()
- sess := &net.Session{Conn: conn, Account: acc, State: net.StateMenu}
+ sess := &net.Session{Conn: conn, Account: acc}
+ sess.SetState(net.StateMenu)
sess.StartWriter()
h.hub.Add(sess)
h.mu.Lock()
diff --git a/internal/game/inventory_pickup_test.go b/internal/game/inventory_pickup_test.go
index 2fc89b7..0492339 100644
--- a/internal/game/inventory_pickup_test.go
+++ b/internal/game/inventory_pickup_test.go
@@ -17,7 +17,8 @@ func TestPickupMergesIntoFullInventory(t *testing.T) {
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n"))
- sess := &net.Session{Conn: newChanConn(), Player: p, State: net.StateGame}
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
h.g.RunOnOwner(func() {
h.g.ItemStore.Reload(h.dir)
for i := 0; i < 28; i++ {
@@ -37,7 +38,8 @@ func TestPickupFullInventoryWithoutStackStillRefused(t *testing.T) {
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n"))
- sess := &net.Session{Conn: newChanConn(), Player: p, State: net.StateGame}
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
h.g.RunOnOwner(func() {
h.g.ItemStore.Reload(h.dir)
for i := 0; i < 28; i++ {
diff --git a/internal/game/lifecycle_txn_test.go b/internal/game/lifecycle_txn_test.go
index ea1cd65..b3a5907 100644
--- a/internal/game/lifecycle_txn_test.go
+++ b/internal/game/lifecycle_txn_test.go
@@ -117,7 +117,7 @@ func TestLifecycleRenameBeatsConnect(t *testing.T) {
h.g.RunOnOwner(func() {
// Drive the rename menu directly while the character is online.
sess.PendingChar = "carl"
- sess.State = net.StateRenameCharName
+ sess.SetState(net.StateRenameCharName)
h.g.handleRenameCharName(sess, "carla")
})
h.barrier()
@@ -149,7 +149,7 @@ func TestLifecycleInjectedFailureRollsBack(t *testing.T) {
// list rewrite fails, so the whole transaction must roll back.
h.g.RunOnOwner(func() {
s1.PendingChar = "alice"
- s1.State = net.StateRenameCharName
+ s1.SetState(net.StateRenameCharName)
h.g.handleRenameCharName(s1, "alicia")
})
h.barrier()
diff --git a/internal/game/login_offowner_regression_test.go b/internal/game/login_offowner_regression_test.go
index 327de7e..fa8324c 100644
--- a/internal/game/login_offowner_regression_test.go
+++ b/internal/game/login_offowner_regression_test.go
@@ -51,7 +51,8 @@ func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing
}
conn := newChanConn()
- sess := &net.Session{Conn: conn, State: net.StateAccountName}
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StateAccountName)
sess.StartWriter()
lines := make(chan string, 8)
go h.input(sess, lines)
@@ -83,7 +84,8 @@ func TestLoginThroughServerEntryUsesOffOwnerPathAndUpgradesLegacyHash(t *testing
func TestNewAccountThroughServerEntryHashesOffOwner(t *testing.T) {
h := newHarness(t)
conn := newChanConn()
- sess := &net.Session{Conn: conn, State: net.StateAccountName}
+ sess := &net.Session{Conn: conn}
+ sess.SetState(net.StateAccountName)
sess.StartWriter()
lines := make(chan string, 8)
go h.input(sess, lines)
diff --git a/internal/game/login_race_regression_test.go b/internal/game/login_race_regression_test.go
index 6448cf5..7ff98c4 100644
--- a/internal/game/login_race_regression_test.go
+++ b/internal/game/login_race_regression_test.go
@@ -22,7 +22,8 @@ func TestOffOwnerLoginDoesNotRaceAccountPublish(t *testing.T) {
h.seedAccount("alice")
h.seedCharacter("alice", "alice")
- sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
+ sess := &net.Session{Conn: newChanConn()}
+ sess.SetState(net.StateAccountName)
sess.StartWriter()
defer sess.Close()
h.hub.Add(sess)
@@ -55,23 +56,24 @@ func TestPasswordPromptRejectsEmptyAndOversizedInput(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
- sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
+ sess := &net.Session{Conn: newChanConn()}
+ sess.SetState(net.StateAccountName)
sess.StartWriter()
defer sess.Close()
h.hub.Add(sess)
h.g.HandleSessionOnOwner(sess, "alice")
- if sess.State != net.StatePassword {
- t.Fatalf("state after account name = %v, want StatePassword", sess.State)
+ if sess.State() != net.StatePassword {
+ t.Fatalf("state after account name = %v, want StatePassword", sess.State())
}
h.g.HandleSessionOnOwner(sess, "")
- if sess.State != net.StatePassword {
- t.Fatalf("empty password line must stay in StatePassword, got %v", sess.State)
+ if sess.State() != net.StatePassword {
+ t.Fatalf("empty password line must stay in StatePassword, got %v", sess.State())
}
h.g.HandleSessionOnOwner(sess, string(make([]byte, 200)))
- if sess.State != net.StateAccountName {
- t.Fatalf("oversized password must return to StateAccountName, got %v", sess.State)
+ if sess.State() != net.StateAccountName {
+ t.Fatalf("oversized password must return to StateAccountName, got %v", sess.State())
}
}
diff --git a/internal/game/owner_disconnect_test.go b/internal/game/owner_disconnect_test.go
index 9140a40..81196a4 100644
--- a/internal/game/owner_disconnect_test.go
+++ b/internal/game/owner_disconnect_test.go
@@ -50,7 +50,8 @@ func TestDisconnectTickHardRemoveCompletesOnOwner(t *testing.T) {
// loaded player through Hub.Remove, which runs the same onRemove callback.
func TestHubRemoveNilPlayerOnOwner(t *testing.T) {
h := newHarness(t)
- sess := &net.Session{Conn: newChanConn(), State: net.StateAccountName}
+ sess := &net.Session{Conn: newChanConn()}
+ sess.SetState(net.StateAccountName)
sess.StartWriter()
h.hub.Add(sess)
diff --git a/internal/game/production_combine_regression_test.go b/internal/game/production_combine_regression_test.go
new file mode 100644
index 0000000..c617565
--- /dev/null
+++ b/internal/game/production_combine_regression_test.go
@@ -0,0 +1,154 @@
+package game
+
+// End-to-end regression: a combine craft with alternative ingredients must
+// consume the alternative the availability check verified. Before the fix,
+// combine consumption debited the first alternative with ANY stock and
+// ignored a failed RemoveItem: a player holding a short first alternative
+// and a full second one produced output while keeping the second
+// alternative and losing the short first one.
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const testDoughYAML = `id: test_dough
+name: test dough
+stackable: false
+value: 2
+craft:
+ - type: combine
+ ingredients:
+ - items:
+ - pot_of_flour
+ quantity: 1
+ - items:
+ - bucket_of_water
+ - jug_of_water
+ quantity: 2
+ byproducts:
+ - empty_bucket
+ - empty_jug
+ output_qty: 1
+`
+
+// Minimal ingredient definitions so startup validation resolves the recipe.
+var testDoughIngredients = map[string]string{
+ "pot_of_flour": "pot of flour",
+ "bucket_of_water": "bucket of water",
+ "jug_of_water": "jug of water",
+ "empty_bucket": "empty bucket",
+ "empty_jug": "empty jug",
+}
+
+func seedTestDoughWorld(t *testing.T, dir string) {
+ t.Helper()
+ seedWorld(t, dir)
+ for id, name := range testDoughIngredients {
+ path := filepath.Join(dir, "items", id+".yaml")
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ data := "id: " + id + "\nname: " + name + "\nstackable: false\nvalue: 1\n"
+ if err := os.WriteFile(path, []byte(data), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ path := filepath.Join(dir, "items", "test_dough.yaml")
+ if err := os.WriteFile(path, []byte(testDoughYAML), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCombineAlternativesConsumeVerifiedSource(t *testing.T) {
+ dir := t.TempDir()
+ seedTestDoughWorld(t, dir)
+
+ h := harnessFromDir(t, dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+
+ // Ingredients and byproducts were seeded as real item definitions.
+ h.g.RunOnOwner(func() {
+ p, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "pot_of_flour", Quantity: 1})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1})
+ p.SetInvSlot(2, &player.InventorySlot{ItemID: "jug_of_water", Quantity: 2})
+ dough, _ := h.g.ItemStore.Load("test_dough")
+ if dough == nil || dough.FirstCraft() == nil {
+ t.Error("test_dough recipe missing from the craft index")
+ return
+ }
+
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Player: p}
+ sess.SetState(net.StateGame)
+
+ h.g.startCombine(sess, p, dough, dough.FirstCraft(), 1)
+ if p.Action == nil {
+ t.Error("combine action did not start")
+ return
+ }
+ // Phase 0 announces, phase 1 completes steps, phase 2 consumes and
+ // produces.
+ h.g.advanceCombine(sess, p) // phase 0 -> 1
+ h.g.advanceCombine(sess, p) // phase 1 -> 2
+ h.g.advanceCombine(sess, p) // phase 2: consume + output
+
+ if got := p.CountItem("test_dough"); got != 1 {
+ t.Errorf("test_dough = %d, want 1", got)
+ }
+ // The verified alternative (the jug, 2 >= 2) is consumed in full;
+ // the short bucket stays untouched.
+ if got := p.CountItem("jug_of_water"); got != 0 {
+ t.Errorf("jug_of_water = %d, want 0", got)
+ }
+ if got := p.CountItem("bucket_of_water"); got != 1 {
+ t.Errorf("bucket_of_water = %d, want 1 (short alternative untouched)", got)
+ }
+ if got := p.CountItem("pot_of_flour"); got != 0 {
+ t.Errorf("pot_of_flour = %d, want 0", got)
+ }
+ // The byproduct matches the consumed alternative.
+ if got := p.CountItem("empty_jug"); got != 1 {
+ t.Errorf("empty_jug = %d, want 1", got)
+ }
+ })
+}
+
+func TestCombineAlternativesRefuseShortStock(t *testing.T) {
+ dir := t.TempDir()
+ seedTestDoughWorld(t, dir)
+
+ h := harnessFromDir(t, dir)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+
+ h.g.RunOnOwner(func() {
+ p, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ // 1 bucket + 1 jug: neither alternative covers quantity 2.
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "pot_of_flour", Quantity: 1})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1})
+ p.SetInvSlot(2, &player.InventorySlot{ItemID: "jug_of_water", Quantity: 1})
+ dough, _ := h.g.ItemStore.Load("test_dough")
+ if dough == nil {
+ t.Error("test_dough missing")
+ return
+ }
+ if craftHasAllItemsQty(dough.FirstCraft(), p.CountItem) {
+ t.Error("1 bucket + 1 jug must not satisfy a quantity-2 water entry")
+ }
+ })
+}
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 65c01b9..9643483 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -229,7 +229,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
for i := 0; i < 28; i++ {
slot := p.InvSlot(i)
if slot != nil && slot.ItemID == item.ID {
- consumeIngredients(craft, p.HasItem, p.RemoveItem)
+ consumeIngredients(craft, p.CountItem, p.RemoveItem)
slot.Quantity += outputQty
placed = true
break
@@ -237,7 +237,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
}
}
if !placed {
- consumeIngredients(craft, p.HasItem, p.RemoveItem)
+ consumeIngredients(craft, p.CountItem, p.RemoveItem)
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
sess.WriteLine("Your inventory is too full!")
@@ -272,7 +272,7 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
}
sess.WriteLine(msg)
} else {
- consumeIngredients(craft, p.HasItem, p.RemoveItem)
+ consumeIngredients(craft, p.CountItem, p.RemoveItem)
if craft.Fail != "" {
freeSlot := p.FirstFreeSlot()
@@ -322,8 +322,14 @@ func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []string
if len(e.Byproducts) == 0 {
continue
}
- for i, id := range e.Items {
- if p.HasItem(id) && i < len(e.Byproducts) && e.Byproducts[i] != "" {
+ // The byproduct must match the alternative that will actually be
+ // consumed (the same craftIngredientSource resolution
+ // consumeIngredients uses), not the first alternative merely
+ // present: with a bucket and a jug of water both in the inventory,
+ // consuming the bucket must return the empty bucket.
+ id, _ := craftIngredientSource(&e, p.CountItem)
+ for i, alt := range e.Items {
+ if alt == id && i < len(e.Byproducts) && e.Byproducts[i] != "" {
byproducts = append(byproducts, e.Byproducts[i])
break
}
diff --git a/internal/game/production_index.go b/internal/game/production_index.go
index 2646a43..8c0207c 100644
--- a/internal/game/production_index.go
+++ b/internal/game/production_index.go
@@ -178,63 +178,73 @@ func craftMatchesEntry(c *item.CraftDef, itemID string) bool {
return false
}
-func craftHasAllItems(c *item.CraftDef, hasItem func(string) bool) bool {
- if c == nil {
- return false
- }
- for _, e := range c.Ingredients {
- found := false
- for _, id := range e.Items {
- if hasItem(id) {
- found = true
- break
- }
- }
- if !found {
- return false
+// craftHasAllItems reports whether every ingredient entry can be satisfied:
+// one listed alternative covering the entry's full quantity (not merely one
+// of each item being present — a recipe needing 15 feathers is not offered
+// on 1). Resolves through craftIngredientSource so availability checks can
+// never disagree with consumption.
+func craftHasAllItems(c *item.CraftDef, countItem func(string) int) bool {
+ return craftHasAllItemsQty(c, countItem)
+}
+
+// craftIngredientSource resolves one ingredient entry: the FIRST listed
+// alternative the player holds in the entry's full (normalized) quantity,
+// plus that quantity. Availability checks and consumption both resolve
+// through this helper so they always pick the same alternative — an entry
+// listing several alternatives (e.g. bucket or jug of water) consumes the
+// one the check verified, never a partially-stocked other one.
+// Returns "" when no alternative covers the quantity.
+func craftIngredientSource(e *item.IngredientEntry, countItem func(string) int) (string, int) {
+ qty := e.Quantity
+ if qty <= 0 {
+ qty = 1
+ }
+ for _, id := range e.Items {
+ if countItem(id) >= qty {
+ return id, qty
}
}
- return true
+ return "", qty
}
func craftHasAllItemsQty(c *item.CraftDef, countItem func(string) int) bool {
if c == nil {
return false
}
- for _, e := range c.Ingredients {
- found := false
- for _, id := range e.Items {
- qty := e.Quantity
- if qty <= 0 {
- qty = 1
- }
- if countItem(id) >= qty {
- found = true
- break
- }
- }
- if !found {
+ for i := range c.Ingredients {
+ if id, _ := craftIngredientSource(&c.Ingredients[i], countItem); id == "" {
return false
}
}
return true
}
-func consumeIngredients(c *item.CraftDef, hasItem func(string) bool, removeItem func(string, int) bool) {
+// consumeIngredients removes one batch of every ingredient entry. Each
+// entry consumes the same alternative the availability checks verify (the
+// first one covering the FULL quantity), and consumption is two-pass:
+// nothing is removed unless every entry resolves, so a craft can never
+// partially consume an alternative and silently keep the rest. Callers that
+// already ran a craftHasAllItems* check cannot see a false return; it
+// guards callers whose own pre-check is weaker. Callers that check the
+// result must abort the craft without producing output.
+func consumeIngredients(c *item.CraftDef, countItem func(string) int, removeItem func(string, int) bool) bool {
if c == nil {
- return
+ return true
}
- for _, e := range c.Ingredients {
- found := false
- for _, id := range e.Items {
- if hasItem(id) {
- removeItem(id, e.Quantity)
- found = true
- break
- }
- }
- if !found {
- return
+ type consumption struct {
+ id string
+ qty int
+ }
+ plan := make([]consumption, 0, len(c.Ingredients))
+ for i := range c.Ingredients {
+ id, qty := craftIngredientSource(&c.Ingredients[i], countItem)
+ if id == "" {
+ return false
}
+ plan = append(plan, consumption{id: id, qty: qty})
+ }
+ for _, s := range plan {
+ removeItem(s.id, s.qty)
}
+ return true
}
diff --git a/internal/game/production_ingredients_test.go b/internal/game/production_ingredients_test.go
new file mode 100644
index 0000000..d799df8
--- /dev/null
+++ b/internal/game/production_ingredients_test.go
@@ -0,0 +1,124 @@
+package game
+
+// Ingredient-alternative resolution regressions: availability checks and
+// consumption must resolve each ingredient entry identically (the FIRST
+// alternative covering the entry's full quantity), consumption must never
+// partially consume an alternative, and byproducts must match the
+// alternative that was actually consumed. Before the fix, the availability
+// check required full quantity of ONE alternative while consumption
+// debited the first alternative with ANY stock and ignored a failed
+// RemoveItem — a player holding 1 gold bar and 2 silver bars for a
+// "2 gold OR 2 silver" recipe kept both silver and lost the gold.
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/player"
+)
+
+// mixedCraft is one batch of "2 gold OR 2 silver + 1 flux" with byproducts
+// keyed to the alternative actually used.
+func mixedCraft() item.CraftDef {
+ return item.CraftDef{
+ Type: "smithing",
+ Ingredients: []item.IngredientEntry{
+ {Items: []string{"gold_bar", "silver_bar"}, Quantity: 2, Byproducts: []string{"gold_dust", "silver_dust"}},
+ {Items: []string{"flux"}, Quantity: 1},
+ },
+ }
+}
+
+func invOf(t *testing.T, items map[string]int) *player.Player {
+ t.Helper()
+ p := player.New("tester")
+ for id, qty := range items {
+ if qty <= 0 {
+ continue
+ }
+ slot := p.FirstFreeSlot()
+ if slot < 0 {
+ t.Fatalf("inventory overflow seeding %s", id)
+ }
+ p.SetInvSlot(slot, &player.InventorySlot{ItemID: id, Quantity: qty})
+ }
+ return p
+}
+
+func TestCraftHasAllItemsQtyRequiresFullQuantityOfOneAlternative(t *testing.T) {
+ c := mixedCraft()
+
+ // One silver covers the full quantity; the lone gold is irrelevant.
+ if !craftHasAllItemsQty(&c, invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 2, "flux": 1}).CountItem) {
+ t.Error("2 silver + 1 flux should satisfy a 2-gold-OR-2-silver recipe")
+ }
+ // No alternative covers the gold/silver entry, even when both are
+ // partially stocked.
+ if craftHasAllItemsQty(&c, invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 1, "flux": 1}).CountItem) {
+ t.Error("1 gold + 1 silver must NOT satisfy the entry")
+ }
+}
+
+func TestCraftHasAllItemsQtyRejectsShortStock(t *testing.T) {
+ c := &item.CraftDef{Ingredients: []item.IngredientEntry{{Items: []string{"feather"}, Quantity: 15}}}
+ if craftHasAllItemsQty(c, invOf(t, map[string]int{"feather": 14}).CountItem) {
+ t.Error("14 of 15 required feathers must not satisfy the recipe")
+ }
+ if !craftHasAllItemsQty(c, invOf(t, map[string]int{"feather": 15}).CountItem) {
+ t.Error("15 feathers should satisfy the recipe")
+ }
+}
+
+func TestConsumeIngredientsUsesTheVerifiedAlternative(t *testing.T) {
+ c := mixedCraft()
+ p := invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 2, "flux": 1})
+
+ if !consumeIngredients(&c, p.CountItem, p.RemoveItem) {
+ t.Fatal("consumeIngredients refused a recipe the availability check passes")
+ }
+ if got := p.CountItem("silver_bar"); got != 0 {
+ t.Errorf("silver_bar = %d, want 0 (the alternative covering the full quantity)", got)
+ }
+ if got := p.CountItem("gold_bar"); got != 1 {
+ t.Errorf("gold_bar = %d, want 1 (the short alternative must be untouched)", got)
+ }
+ if got := p.CountItem("flux"); got != 0 {
+ t.Errorf("flux = %d, want 0", got)
+ }
+}
+
+func TestConsumeIngredientsNeverPartiallyConsumes(t *testing.T) {
+ c := &item.CraftDef{Ingredients: []item.IngredientEntry{
+ {Items: []string{"gold_bar"}, Quantity: 1},
+ {Items: []string{"silver_bar"}, Quantity: 2},
+ }}
+ p := invOf(t, map[string]int{"gold_bar": 1, "silver_bar": 1})
+
+ // The second entry cannot resolve: NOTHING may be removed.
+ if consumeIngredients(c, p.CountItem, p.RemoveItem) {
+ t.Fatal("consumeIngredients succeeded with a missing ingredient")
+ }
+ if got := p.CountItem("gold_bar"); got != 1 {
+ t.Errorf("gold_bar = %d, want 1 (nothing may be consumed on a failed resolve)", got)
+ }
+}
+
+func TestCollectByproductsMatchesConsumedAlternative(t *testing.T) {
+ g := &Game{}
+ craft := &item.CraftDef{Ingredients: []item.IngredientEntry{
+ {Items: []string{"bucket_of_water", "jug_of_water"}, Quantity: 1, Byproducts: []string{"empty_bucket", "empty_jug"}},
+ }}
+
+ // Only the jug is present: the empty jug returns.
+ p := invOf(t, map[string]int{"jug_of_water": 1})
+ if got := g.collectByproducts(p, &item.ItemDef{Craft: item.CraftList{*craft}}); len(got) != 1 || got[0] != "empty_jug" {
+ t.Errorf("byproducts = %v, want [empty_jug]", got)
+ }
+
+ // Both present: consumption picks the FIRST alternative (the bucket),
+ // so the byproduct must be the empty bucket.
+ p = invOf(t, map[string]int{"bucket_of_water": 1, "jug_of_water": 1})
+ if got := g.collectByproducts(p, &item.ItemDef{Craft: item.CraftList{*craft}}); len(got) != 1 || got[0] != "empty_bucket" {
+ t.Errorf("byproducts = %v, want [empty_bucket]", got)
+ }
+}
diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go
index 090214f..4f013e7 100644
--- a/internal/game/production_menu.go
+++ b/internal/game/production_menu.go
@@ -19,14 +19,14 @@ func (g *Game) handleRecipeChoice(sess *net.Session, input string) {
menuData := sess.PendingMenu
if len(menuData) == 0 {
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.reprompt(sess)
return
}
if input == "" {
sess.PendingMenu = nil
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
sess.WriteLine("Never mind.")
g.reprompt(sess)
return
@@ -34,7 +34,7 @@ func (g *Game) handleRecipeChoice(sess *net.Session, input string) {
if choice, err := strconv.Atoi(input); err == nil {
sess.PendingMenu = nil
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
if choice <= 0 || choice > len(menuData) {
sess.WriteLine("Never mind.")
g.reprompt(sess)
@@ -61,13 +61,13 @@ func (g *Game) handleRecipeChoice(sess *net.Session, input string) {
}
if matchIdx >= 0 {
sess.PendingMenu = nil
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
g.dispatchMenuEntry(sess, menuData[matchIdx])
return
}
sess.PendingMenu = nil
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
sess.WriteLine("Never mind.")
g.reprompt(sess)
}
@@ -196,7 +196,7 @@ func (g *Game) showProductionTable(sess *net.Session, p *player.Player, items []
sess.PendingSkill = skill
sess.PendingLastFlag = lastFlagKey
sess.PendingBackground = background
- sess.State = net.StateProductChoice
+ sess.SetState(net.StateProductChoice)
}
func (g *Game) handleProductChoice(sess *net.Session, input string) {
@@ -209,7 +209,7 @@ func (g *Game) handleProductChoice(sess *net.Session, input string) {
sess.PendingSkill = ""
sess.PendingLastFlag = ""
sess.PendingBackground = false
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
input = strings.TrimSpace(input)
@@ -286,7 +286,7 @@ func itemIDs(defs []*item.ItemDef) []string {
func (g *Game) promptHowMany(sess *net.Session, itemID string) {
sess.PendingItemID = itemID
- sess.State = net.StateHowMany
+ sess.SetState(net.StateHowMany)
sess.Write("How many (return for all)?: ")
}
@@ -378,7 +378,7 @@ func (g *Game) showRecipeChoice(sess *net.Session, p *player.Player, items []*it
g.promptHowMany(sess, items[0].ID)
return
}
- sess.State = net.StateRecipeChoice
+ sess.SetState(net.StateRecipeChoice)
sess.PendingMenu = itemMenuData(itemIDs(items))
var names []string
for _, item := range items {
@@ -391,7 +391,7 @@ func (g *Game) handleHowMany(sess *net.Session, input string) {
p := sess.Player
itemID := sess.PendingItemID
sess.PendingItemID = ""
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
input = strings.TrimSpace(input)
diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go
index f905fca..50f9ac8 100644
--- a/internal/game/quit_casino_test.go
+++ b/internal/game/quit_casino_test.go
@@ -204,7 +204,7 @@ func TestQuitNowForfeitsOnlyAtLogout(t *testing.T) {
if _, playing := g.Casino.Participation("bob"); !playing {
t.Fatal("quit now removed the wrong player")
}
- if sess.Player != nil || sess.State != net.StateMenu {
+ if sess.Player != nil || sess.State() != net.StateMenu {
t.Fatal("quit now did not log the player out")
}
if _, stillLoggedIn := g.loggedInChars["alice"]; stillLoggedIn {
@@ -257,7 +257,7 @@ func TestQuitNowAtMachineHoldsUntilLogout(t *testing.T) {
if _, playing := g.Casino.Participation("alice"); playing {
t.Fatal("quit now did not free the machine at the logout tick")
}
- if sess.Player != nil || sess.State != net.StateMenu {
+ if sess.Player != nil || sess.State() != net.StateMenu {
t.Fatal("quit now did not log the player out")
}
}
diff --git a/internal/game/quit_state_race_test.go b/internal/game/quit_state_race_test.go
new file mode 100644
index 0000000..68f4770
--- /dev/null
+++ b/internal/game/quit_state_race_test.go
@@ -0,0 +1,32 @@
+package game
+
+// Regression: network input may arrive on a connection goroutine at any
+// moment, including while the tick-driven logout countdown (owner goroutine)
+// transitions the session to the character menu. The dispatch read
+// sess.State as a plain field, which the race detector flagged; the state is
+// now an atomic on net.Session. This test drives both sides concurrently and
+// must run clean under -race.
+
+import (
+ "testing"
+ "time"
+)
+
+func TestInputDuringLogoutCountdownIsRaceFree(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ h.seedCharacter("alice", "alice")
+ sess, _ := h.connect("alice")
+
+ h.g.RunOnOwner(func() {
+ h.g.beginQuitCountdown(sess)
+ })
+
+ // Pump input while the countdown ticks toward the state change to the
+ // character menu (and past it, when the countdown completes early).
+ deadline := time.Now().Add(100 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ h.g.HandleSessionOnOwner(sess, "notacommand")
+ }
+ h.barrier()
+}
diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go
index 3d21390..41170b7 100644
--- a/internal/game/readiness_regression_test.go
+++ b/internal/game/readiness_regression_test.go
@@ -52,7 +52,8 @@ func TestReservedPickupDoesNotDestroyItems(t *testing.T) {
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n"))
- sess := &net.Session{Conn: newChanConn(), Player: p, State: net.StateGame}
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
h.g.RunOnOwner(func() {
h.g.ItemStore.Reload(h.dir)
if _, err := h.g.ItemStore.Load("chips"); err != nil {
diff --git a/internal/game/shutdown_admission_test.go b/internal/game/shutdown_admission_test.go
index a7e7ee2..db31648 100644
--- a/internal/game/shutdown_admission_test.go
+++ b/internal/game/shutdown_admission_test.go
@@ -17,7 +17,7 @@ func TestShutdownRejectsSessionInput(t *testing.T) {
h.seedAccount("alice")
h.seedCharacter("alice", "alice")
sess, _ := h.connect("alice")
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
h.g.setShuttingDown()
h.g.stopOwner()
@@ -28,7 +28,7 @@ func TestShutdownRejectsSessionInput(t *testing.T) {
// A durable mutation gives an unambiguous admission check: description
// input would rewrite the character.
- sess.State = net.StateChangeDesc
+ sess.SetState(net.StateChangeDesc)
h.g.HandleSessionOnOwner(sess, "Input after final save")
if sess.Player.Description == "Input after final save" {
t.Error("session input still mutates character after shutdown/owner stop")
@@ -40,13 +40,13 @@ func TestShutdownRejectsInputDuringDrain(t *testing.T) {
h.seedAccount("alice")
h.seedCharacter("alice", "alice")
sess, _ := h.connect("alice")
- sess.State = net.StateGame
+ sess.SetState(net.StateGame)
h.g.setShuttingDown()
// During the drain window the owner is still alive, but new input is
// already rejected: only work accepted before shutdown executes.
h.g.HandleSessionOnOwner(sess, "look")
- sess.State = net.StateChangeDesc
+ sess.SetState(net.StateChangeDesc)
h.g.HandleSessionOnOwner(sess, "Input during drain")
if sess.Player.Description == "Input during drain" {
t.Error("session input accepted after shutdown began")
diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go
index 038d3fd..2dbde9f 100644
--- a/internal/game/sys_triggers.go
+++ b/internal/game/sys_triggers.go
@@ -206,7 +206,7 @@ func (g *Game) completePlayerSequence(seq *sequence, p *player.Player) {
p.PendingSequence = nil
g.saveCharacter(p)
}
- if sess := seq.sess; sess != nil && sess.State == net.StateGame {
+ if sess := seq.sess; sess != nil && sess.State() == net.StateGame {
g.writePrompt(sess)
}
}
diff --git a/internal/net/server.go b/internal/net/server.go
index 66d31e3..78eceb8 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -57,7 +57,7 @@ const (
type Session struct {
Conn Conn
- State SessionState
+ state atomic.Int32
Account *player.Account
Player *player.Player
PendingChar string
@@ -116,6 +116,16 @@ type Session struct {
seq uint64
}
+// State returns the session's current protocol state. Safe on any
+// goroutine: the owner writes the state while connection goroutines read
+// it in the HandleSessionOnOwner dispatch, so a plain field was a data
+// race (reproduced by the logout-countdown input regression test).
+func (sess *Session) State() SessionState { return SessionState(sess.state.Load()) }
+
+// SetState transitions the session to a new protocol state.
+// Owner goroutine only.
+func (sess *Session) SetState(s SessionState) { sess.state.Store(int32(s)) }
+
// SetAuthName records the account name offered for password entry.
// Owner goroutine only.
func (sess *Session) SetAuthName(name string) {
@@ -598,9 +608,9 @@ func (s *Server) serveTelnet() {
return
}
sess := &Session{
- Conn: newTCPConn(conn),
- State: StateAccountName,
+ Conn: newTCPConn(conn),
}
+ sess.SetState(StateAccountName)
// Queued writes like the websocket transport: a dedicated writer
// goroutine performs the I/O with per-message deadlines, so a slow
// or stalled telnet client can never block game code.
@@ -621,9 +631,9 @@ func (s *Server) serveTelnetTLS() {
return
}
sess := &Session{
- Conn: newTCPConn(conn),
- State: StateAccountName,
+ Conn: newTCPConn(conn),
}
+ sess.SetState(StateAccountName)
sess.StartWriter()
s.hub.Add(sess)
go s.handleSession(sess, s.handler)
diff --git a/internal/net/web.go b/internal/net/web.go
index 233928f..8353c8d 100644
--- a/internal/net/web.go
+++ b/internal/net/web.go
@@ -61,9 +61,9 @@ func newHTTPMux(s *Server) *http.ServeMux {
return
}
sess := &Session{
- Conn: newWSConn(ws),
- State: StateAccountName,
+ Conn: newWSConn(ws),
}
+ sess.SetState(StateAccountName)
sess.StartWriter()
s.hub.Add(sess)
go s.handleSession(sess, s.handler)