package game import ( "fmt" "strconv" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/item" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/ui" "thehouseoficarus/internal/world" ) // ---- Command entry points ---- func (g *Game) executeShopList(sess *net.Session, args []string, rawInput string) { p := sess.Player if p == nil { return } mob := g.resolveShopMob(sess, p, strings.Join(args, " ")) if mob == nil { return } g.showShopInventory(sess, mob) } 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) } 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 } 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) { if len(args) == 0 { return 1, "", false } if strings.EqualFold(args[0], "all") { 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 } return 1, strings.Join(args, " "), false } // 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) } } if len(shops) == 0 { sess.WriteLine("There is no shop here.") return nil } 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] } if len(shops) == 1 { return shops[0] } g.promptWhichShop(sess, shops) return nil } 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 shopCandidate struct { itemID string def *item.ItemDef name string stock int } // 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 } func (g *Game) showShopInventory(sess *net.Session, mob *world.MobInstance) { if mob.Shop.Message != "" { sess.WriteLine(g.colorize(sess, "dialog", mob.Shop.Message)) } candidates := g.shopCandidates(mob) if len(candidates) == 0 { sess.WriteLine(fmt.Sprintf("%s has nothing for sale.", mob.Name)) return } unicode := true wrapWidth := 0 if p := sess.Player; p != nil { unicode = p.OptionBool("unicode") wrapWidth = p.OptionInt("wrap_width") } t := ui.Table{ Title: mob.Name, Columns: []string{"Item", "Stock", "Buy", "Sell"}, } for _, c := range candidates { value := 0 if c.def != nil { value = c.def.Value } sellStr := "\u2014" 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, c.def, c.name), fmt.Sprintf("%d", c.stock), g.colorize(sess, "currency_pickup", fmt.Sprintf("%d cr", value)), g.colorize(sess, "currency_pickup", sellStr), }) } for _, line := range t.Render(unicode, wrapWidth) { sess.WriteLine(line) } } 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 len(matches) == 0 { 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(" - " + g.itemColorize(sess, m.def, m.name)) } return } c := matches[0] p := sess.Player price := 0 if c.def != nil { price = c.def.Value } 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 } const unlimited = 1 << 30 want := qty if all || qty == 0 { want = unlimited } if want > stock { want = stock } capacity := g.shopInvCapacity(p, c.itemID, c.def) if want > capacity { want = capacity } if price > 0 { if afford := p.Credits / price; want > afford { want = afford } } 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 } 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, c.def, c.name) credits := g.colorize(sess, "currency_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.", taken, itemColor, credits)) } } func (g *Game) doShopSell(sess *net.Session, mob *world.MobInstance, input string, qty int, all bool) { p := sess.Player matches := g.findInventoryMatches(input, p) if len(matches) == 0 { 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(" - " + 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) 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 } value := 0 if def != nil { value = def.Value } totalInInv := p.CountItem(match.ID) actualQty := qty if all || qty == 0 { actualQty = totalInInv } 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 += total g.MobStore.ShopAdd(mob.InstanceID, match.ID, actualQty) g.AccountStore.SaveCharacter(p) credits := g.colorize(sess, "currency_pickup", fmt.Sprintf("%d", total)) if actualQty == 1 { sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, credits)) } else { sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, credits)) } } // 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 } } if p.FirstFreeSlot() != -1 { return 1 << 30 } return 0 } return p.FreeSlots() } // 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 j := 0; j < qty; j++ { slot := p.FirstFreeSlot() if slot == -1 { return } p.SetInvSlot(slot, g.newInventorySlot(itemID, 1)) } }