diff options
| author | historia <[not public]> | 2026-09-16 21:35:55 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-16 21:35:55 -0400 |
| commit | fea2cc6839ec3ee17940a2dea69622dc6f4deabf (patch) | |
| tree | 9d893525ca20d2cc920e9e866f84f71b605d8ae9 /internal | |
| parent | 35ddd0ad2215760bb1d3dc7724480f179ea33fa6 (diff) | |
| download | thehouseoficarus-fea2cc6839ec3ee17940a2dea69622dc6f4deabf.tar.gz | |
fix: unify crafting ingredient resolution; fix superheat alternatives, quantity gates, and dropped combine byproducts
Diffstat (limited to 'internal')
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) |
