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/game | |
| 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/game')
41 files changed, 554 insertions, 192 deletions
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) } } |
