diff options
| author | historia <[not public]> | 2026-07-01 16:37:45 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-07-01 16:37:45 -0400 |
| commit | e6b4cb9f5816c6ee239233bc85f74ee446a161c2 (patch) | |
| tree | 1c3bf71c8b7f095baae989afe5e33183a7ed3929 /internal/game/cmd_shop.go | |
| parent | 649ef4b7416ce7053145878841fc9b0bff2f5fec (diff) | |
| download | thehouseoficarus-e6b4cb9f5816c6ee239233bc85f74ee446a161c2.tar.gz | |
feat: shop system overhauled, gui conversation tree editor overhauled
Diffstat (limited to 'internal/game/cmd_shop.go')
| -rw-r--r-- | internal/game/cmd_shop.go | 525 |
1 files changed, 244 insertions, 281 deletions
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index 69de750..e47c84d 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -8,39 +8,72 @@ import ( "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" "thehouseoficarus/internal/ui" + "thehouseoficarus/internal/world" ) -func (g *Game) handleShopInput(sess *net.Session, input string) { - if sess.Player == nil { - sess.State = net.StateGame - g.writePrompt(sess) +// ---- Command entry points ---- + +func (g *Game) executeShopList(sess *net.Session, args []string, rawInput string) { + p := sess.Player + if p == nil { return } - - input = strings.TrimSpace(input) - parts := strings.Fields(strings.ToLower(input)) - if len(parts) == 0 { - g.showShopBrowse(sess) + mob := g.resolveShopMob(sess, p, strings.Join(args, " ")) + if mob == nil { return } + g.showShopInventory(sess, mob) +} - cmd := parts[0] +func (g *Game) executeShopBuy(sess *net.Session, args []string, rawInput string) { + p := sess.Player + if p == nil { + return + } + itemArgs, mobName := splitOnKeyword(args, "from") + qty, itemName, all := parseShopQty(itemArgs) + if itemName == "" { + sess.WriteLine("Buy what? Use 'list' to see what's for sale.") + return + } + mob := g.resolveShopMob(sess, p, mobName) + if mob == nil { + return + } + g.doShopBuy(sess, mob, itemName, qty, all) +} - switch cmd { - case "buy": - g.cmdShopBuy(sess, parts[1:]) - case "sell": - g.cmdShopSell(sess, parts[1:]) - case "browse", "list": - g.showShopBrowse(sess) - case "leave", "bye", "exit", "quit": - g.leaveShop(sess) +func (g *Game) executeShopSell(sess *net.Session, args []string, rawInput string) { + p := sess.Player + if p == nil { + return + } + itemArgs, mobName := splitOnKeyword(args, "to") + qty, itemName, all := parseShopQty(itemArgs) + if itemName == "" { + sess.WriteLine("Sell what?") + return + } + mob := g.resolveShopMob(sess, p, mobName) + if mob == nil { return - default: - sess.WriteLine("Commands: buy <item>, sell <item>, browse, leave") } - g.writeShopPrompt(sess) + g.doShopSell(sess, mob, itemName, qty, all) +} + +// ---- Helpers ---- + +// splitOnKeyword splits args around the first standalone keyword (e.g. "from", +// "to"), returning the words before it and the joined words after it. +func splitOnKeyword(args []string, kw string) (before []string, after string) { + for i, a := range args { + if strings.EqualFold(a, kw) { + return args[:i], strings.Join(args[i+1:], " ") + } + } + return args, "" } func parseShopQty(args []string) (int, string, bool) { @@ -48,8 +81,7 @@ func parseShopQty(args []string) (int, string, bool) { return 1, "", false } if strings.EqualFold(args[0], "all") { - rest := strings.Join(args[1:], " ") - return 0, rest, true + return 0, strings.Join(args[1:], " "), true } if n, err := strconv.Atoi(args[0]); err == nil && n > 0 { return n, strings.Join(args[1:], " "), false @@ -57,63 +89,100 @@ func parseShopQty(args []string) (int, string, bool) { return 1, strings.Join(args, " "), false } -func (g *Game) cmdShopBuy(sess *net.Session, args []string) { - cfg := g.shopConfig(sess) - if cfg == nil { - return +// resolveShopMob finds the shop-owning mob in the player's room. If name is +// empty and exactly one shop exists it is returned; multiple shops prompt +// "Which shop?". A non-empty name matches a specific mob. Errors are written to +// the session and nil is returned when resolution fails. +func (g *Game) resolveShopMob(sess *net.Session, p *player.Player, name string) *world.MobInstance { + var shops []*world.MobInstance + for _, m := range g.MobStore.MobsInRoom(p.RoomID) { + if m.HasShop() { + shops = append(shops, m) + } } - - qty, itemName, all := parseShopQty(args) - if itemName == "" { - sess.WriteLine("Buy what? Use 'browse' to see available items.") - return + if len(shops) == 0 { + sess.WriteLine("There is no shop here.") + return nil } - g.doShopBuy(sess, itemName, qty, all) -} - -func (g *Game) cmdShopSell(sess *net.Session, args []string) { - cfg := g.shopConfig(sess) - if cfg == nil { - return + name = strings.TrimSpace(name) + if name != "" { + var matched []*world.MobInstance + for _, m := range shops { + if m.MatchQuality(name) > world.MatchNone { + matched = append(matched, m) + } + } + if len(matched) == 0 { + sess.WriteLine(fmt.Sprintf("There is no shop here run by \"%s\".", name)) + return nil + } + if len(matched) > 1 { + g.promptWhichShop(sess, matched) + return nil + } + return matched[0] } - qty, itemName, all := parseShopQty(args) - if itemName == "" { - sess.WriteLine("Sell what?") - return + if len(shops) == 1 { + return shops[0] } - - g.doShopSell(sess, itemName, qty, all) -} - -func (g *Game) writeShopPrompt(sess *net.Session) { - sess.WritePrompt(g.colorize(sess, "dialog", "> ")) + g.promptWhichShop(sess, shops) + return nil } -func (g *Game) shopConfig(sess *net.Session) *behavior.ShopConfig { - cfg := sess.Shop - return cfg +func (g *Game) promptWhichShop(sess *net.Session, shops []*world.MobInstance) { + sess.WriteLine("Which shop? Try again naming one of:") + for _, m := range shops { + sess.WriteLine(" - " + m.Name) + } } -type shopMatch struct { - si *behavior.ShopItem - name string - def *item.ItemDef +type shopCandidate struct { + itemID string + def *item.ItemDef + name string + stock int } -func (g *Game) showShopBrowse(sess *net.Session) { - cfg := g.shopConfig(sess) - if cfg == nil { - return +// shopCandidates lists everything the shop currently offers for sale: its +// configured items (even when out of stock) plus any dynamically-held items +// with stock remaining. +func (g *Game) shopCandidates(mob *world.MobInstance) []shopCandidate { + stock := g.MobStore.ShopStockSnapshot(mob.InstanceID) + seen := map[string]bool{} + var out []shopCandidate + add := func(id string) { + if id == "" || seen[id] { + return + } + seen[id] = true + def, _ := g.ItemStore.Load(id) + name := id + if def != nil { + name = def.Name + } + out = append(out, shopCandidate{itemID: id, def: def, name: name, stock: stock[id]}) + } + for i := range mob.Shop.Items { + add(mob.Shop.Items[i].ItemID) } + for id, n := range stock { + if n > 0 { + add(id) + } + } + return out +} - if cfg.Message != "" { - sess.WriteLine(fmt.Sprintf("%s", g.colorize(sess, "dialog", cfg.Message))) +func (g *Game) showShopInventory(sess *net.Session, mob *world.MobInstance) { + if mob.Shop.Message != "" { + sess.WriteLine(g.colorize(sess, "dialog", mob.Shop.Message)) } - if len(cfg.Items) == 0 { - sess.WriteLine("This shop has nothing for sale.") + candidates := g.shopCandidates(mob) + if len(candidates) == 0 { + sess.WriteLine(fmt.Sprintf("%s has nothing for sale.", mob.Name)) return } @@ -125,199 +194,113 @@ func (g *Game) showShopBrowse(sess *net.Session) { } t := ui.Table{ - Title: "Shop Inventory", - Columns: []string{"Item", "Buy Price", "Sell Price"}, + Title: mob.Name, + Columns: []string{"Item", "Stock", "Buy", "Sell"}, } - - for _, si := range cfg.Items { - def, _ := g.ItemStore.Load(si.ItemID) - itemName := si.ItemID - if def != nil { - itemName = def.Name + for _, c := range candidates { + value := 0 + if c.def != nil { + value = c.def.Value } - buyStr := fmt.Sprintf("%d cr", si.BuyPrice) sellStr := "\u2014" - if si.SellPrice > 0 { - sellStr = fmt.Sprintf("%d cr", si.SellPrice) + if mob.Shop.FindItem(c.itemID) != nil || mob.Shop.Buys() { + sellStr = fmt.Sprintf("%d cr", mob.Shop.SellPrice(value, c.stock)) } t.Rows = append(t.Rows, []string{ - g.itemColorize(sess, def, itemName), - g.colorize(sess, "credits_pickup", buyStr), + g.itemColorize(sess, c.def, c.name), + fmt.Sprintf("%d", c.stock), + g.colorize(sess, "credits_pickup", fmt.Sprintf("%d cr", value)), g.colorize(sess, "credits_pickup", sellStr), }) } - for _, line := range t.Render(unicode, wrapWidth) { sess.WriteLine(line) } } -func (g *Game) findShopMatches(input string, cfg *behavior.ShopConfig) []shopMatch { - var matches []shopMatch - for i := range cfg.Items { - si := &cfg.Items[i] - itemName := si.ItemID - def, _ := g.ItemStore.Load(si.ItemID) - if def != nil { - itemName = def.Name +func (g *Game) doShopBuy(sess *net.Session, mob *world.MobInstance, input string, qty int, all bool) { + var matches []shopCandidate + for _, c := range g.shopCandidates(mob) { + if behavior.WordPrefixMatch(input, c.name) { + matches = append(matches, c) } - if behavior.WordPrefixMatch(input, itemName) { - matches = append(matches, shopMatch{si: si, name: itemName, def: def}) - } - } - return matches -} - -func (g *Game) doShopBuy(sess *net.Session, input string, qty int, all bool) { - cfg := g.shopConfig(sess) - if cfg == nil { - return } - - p := sess.Player - - matches := g.findShopMatches(input, cfg) if len(matches) == 0 { - sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.") + sess.WriteLine("That item isn't for sale here. Use 'list' to see the inventory.") return } - if len(matches) > 1 { sess.WriteLine("That's ambiguous, which one?") for _, m := range matches { - sess.WriteLine(fmt.Sprintf(" - %s", g.itemColorize(sess, m.def, m.name))) + sess.WriteLine(" - " + g.itemColorize(sess, m.def, m.name)) } return } - si := matches[0].si - def := matches[0].def - - actualQty := qty - if all || qty == 0 { - affordable := p.Credits / si.BuyPrice - if def != nil && def.Stackable { - actualQty = affordable - } else { - freeSlots := p.FreeSlots() - actualQty = affordable - if actualQty > freeSlots { - actualQty = freeSlots - } - } + c := matches[0] + p := sess.Player + price := 0 + if c.def != nil { + price = c.def.Value } - if actualQty <= 0 { - if p.Credits < si.BuyPrice { - sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) - } else { - sess.WriteLine("Your inventory is full.") - } + stock := g.MobStore.ShopStockOf(mob.InstanceID, c.itemID) + if stock <= 0 { + sess.WriteLine(fmt.Sprintf("%s is out of stock.", g.itemColorize(sess, c.def, c.name))) return } - g.buyShopItem(sess, *si, actualQty) -} - -func (g *Game) buyShopItem(sess *net.Session, si behavior.ShopItem, qty int) { - p := sess.Player - - totalCost := si.BuyPrice * qty - if p.Credits < totalCost { - qty = p.Credits / si.BuyPrice - if qty <= 0 { - sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) - return - } - totalCost = si.BuyPrice * qty + const unlimited = 1 << 30 + want := qty + if all || qty == 0 { + want = unlimited } - - def, _ := g.ItemStore.Load(si.ItemID) - displayName := si.ItemID - if def != nil { - displayName = def.Name + if want > stock { + want = stock } - - if def != nil && def.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == si.ItemID { - p.Credits -= totalCost - slot.Quantity += qty - g.AccountStore.SaveCharacter(p) - itemColor := g.itemColorize(sess, def, displayName) - if qty == 1 { - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) - } else { - sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) - } - return - } - } - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - return - } - - p.Credits -= totalCost - p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, qty)) - g.AccountStore.SaveCharacter(p) - - itemColor := g.itemColorize(sess, def, displayName) - if qty == 1 { - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) - } else { - sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) - } - return + capacity := g.shopInvCapacity(p, c.itemID, c.def) + if want > capacity { + want = capacity } - - freeSlots := p.FreeSlots() - if qty > freeSlots { - if freeSlots == 0 { - sess.WriteLine("Your inventory is full.") - return - } - qty = freeSlots - totalCost = si.BuyPrice * qty - if p.Credits < totalCost { - qty = p.Credits / si.BuyPrice - totalCost = si.BuyPrice * qty + if price > 0 { + if afford := p.Credits / price; want > afford { + want = afford } } - if qty <= 0 { - if p.Credits < si.BuyPrice { - sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) - } else { + if want <= 0 { + switch { + case capacity <= 0: sess.WriteLine("Your inventory is full.") + case price > 0 && p.Credits < price: + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", price, p.Credits)) + default: + sess.WriteLine("You can't buy that right now.") } return } - p.Credits -= totalCost - for j := 0; j < qty; j++ { - freeSlot := p.FirstFreeSlot() - p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, 1)) + taken := g.MobStore.ShopTake(mob.InstanceID, c.itemID, want) + if taken <= 0 { + sess.WriteLine(fmt.Sprintf("%s is out of stock.", g.itemColorize(sess, c.def, c.name))) + return } + + total := price * taken + p.Credits -= total + g.giveShopItems(p, c.itemID, taken, c.def) g.AccountStore.SaveCharacter(p) - itemColor := g.itemColorize(sess, def, displayName) - if qty == 1 { - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + itemColor := g.itemColorize(sess, c.def, c.name) + credits := g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", total)) + if taken == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, credits)) } else { - sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", taken, itemColor, credits)) } } -func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) { - cfg := g.shopConfig(sess) - if cfg == nil { - return - } - +func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input string, qty int, all bool) { p := sess.Player matches := g.findInventoryMatches(input, p) @@ -325,35 +308,28 @@ func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) { sess.WriteLine("You don't have that item.") return } - if len(matches) > 1 { sess.WriteLine("That's ambiguous, which one?") for _, m := range matches { def, _ := g.ItemStore.Load(m.ID) - sess.WriteLine(fmt.Sprintf(" - %s", g.itemColorize(sess, def, m.Name))) + sess.WriteLine(" - " + g.itemColorize(sess, def, m.Name)) } return } match := matches[0] + def, _ := g.ItemStore.Load(match.ID) + displayName := match.Name + itemColor := g.itemColorize(sess, def, displayName) - var shopItem *behavior.ShopItem - for i := range cfg.Items { - si := &cfg.Items[i] - if si.ItemID == match.ID && si.SellPrice > 0 { - shopItem = si - break - } + if mob.Shop.FindItem(match.ID) == nil && !mob.Shop.Buys() { + sess.WriteLine(fmt.Sprintf("%s doesn't want to buy %s.", mob.Name, itemColor)) + return } - if shopItem == nil { - def, _ := g.ItemStore.Load(match.ID) - displayName := match.ID - if def != nil { - displayName = def.Name - } - sess.WriteLine(fmt.Sprintf("The shop doesn't want to buy %s.", g.itemColorize(sess, def, displayName))) - return + value := 0 + if def != nil { + value = def.Value } totalInInv := p.CountItem(match.ID) @@ -363,79 +339,66 @@ func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) { } else if actualQty > totalInInv { actualQty = totalInInv } - if actualQty <= 0 { sess.WriteLine("You don't have that item.") return } + stock := g.MobStore.ShopStockOf(mob.InstanceID, match.ID) + total := 0 + for k := 0; k < actualQty; k++ { + total += mob.Shop.SellPrice(value, stock+k) + } + p.RemoveItem(match.ID, actualQty) - p.Credits += shopItem.SellPrice * actualQty + p.Credits += total + g.MobStore.ShopAdd(mob.InstanceID, match.ID, actualQty) g.AccountStore.SaveCharacter(p) - def, _ := g.ItemStore.Load(match.ID) - displayName := match.Name - itemColor := g.itemColorize(sess, def, displayName) + credits := g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", total)) if actualQty == 1 { - sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice)))) + sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, credits)) } else { - totalCredits := shopItem.SellPrice * actualQty - sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCredits)))) + sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, credits)) } } -func (g *Game) leaveShop(sess *net.Session) { - sess.Shop = nil - p := sess.Player - if p == nil || p.Action == nil || p.Action.Type != "talk" { - sess.State = net.StateGame - if p != nil { - g.cancelAction(p) +// shopInvCapacity reports how many of an item the player can still carry. +func (g *Game) shopInvCapacity(p *player.Player, itemID string, def *item.ItemDef) int { + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID { + return 1 << 30 + } } - g.writePrompt(sess) - return - } - - td, ok := p.Action.Data.(*behavior.TalkData) - if !ok || td.Cfg == nil { - sess.State = net.StateGame - g.cancelAction(p) - g.writePrompt(sess) - return + if p.FirstFreeSlot() != -1 { + return 1 << 30 + } + return 0 } + return p.FreeSlots() +} - node, ok := td.Cfg.Nodes[td.Node] - if !ok { - sess.State = net.StateGame - g.cancelAction(p) - g.writePrompt(sess) +// giveShopItems places qty of an item into the player's inventory, stacking when +// possible. Callers must ensure capacity via shopInvCapacity first. +func (g *Game) giveShopItems(p *player.Player, itemID string, qty int, def *item.ItemDef) { + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID { + slot.Quantity += qty + return + } + } + if slot := p.FirstFreeSlot(); slot != -1 { + p.SetInvSlot(slot, g.newInventorySlot(itemID, qty)) + } return } - - for { - visible := g.visibleOptions(sess, node.Options) - if len(visible) > 0 { - for i, opt := range visible { - sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text)) - } - sess.State = net.StateTalk - g.reprompt(sess) + for j := 0; j < qty; j++ { + slot := p.FirstFreeSlot() + if slot == -1 { return } - - if node.Goto != "" { - next, ok := td.Cfg.Nodes[node.Goto] - if !ok { - break - } - td.Node = node.Goto - node = next - } else { - break - } + p.SetInvSlot(slot, g.newInventorySlot(itemID, 1)) } - - sess.State = net.StateGame - g.cancelAction(p) - g.writePrompt(sess) } |
