package game import ( "fmt" "log" "sort" "strconv" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/ui" ) func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) { g.doBank(sess) } func (g *Game) handleBankInput(sess *net.Session, input string) { if sess.Player == nil { sess.SetState(net.StateGame) g.writePrompt(sess) return } input = strings.TrimSpace(input) parts := strings.Fields(strings.ToLower(input)) if len(parts) == 0 { g.showBankBrowse(sess) return } cmd := parts[0] switch cmd { case "deposit": if len(parts) < 2 { sess.WriteLine("Deposit what?") } else if parts[1] == "credits" { g.doBankDepositCredits(sess, parts) } else if parts[1] == "all" { g.doBankDepositAll(sess) } else { g.doBankDeposit(sess, strings.Join(parts[1:], " ")) } case "withdraw": if len(parts) < 2 { sess.WriteLine("Withdraw what?") } else if parts[1] == "credits" { g.doBankWithdrawCredits(sess, parts) } else { g.doBankWithdraw(sess, strings.Join(parts[1:], " ")) } case "browse", "list": g.showBankBrowse(sess) case "leave", "bye", "exit", "quit": g.leaveBank(sess) return default: sess.WriteLine("Commands: deposit , deposit credits , deposit all, withdraw , withdraw credits , browse, leave") } g.writeBankPrompt(sess) } func (g *Game) writeBankPrompt(sess *net.Session) { sess.WritePrompt(g.colorize(sess, "dialog", "Bank> ")) } func (g *Game) showBankBrowse(sess *net.Session) { p := sess.Player unicode := p.OptionBool("unicode") t := ui.Table{ Title: "Bank Contents", Columns: []string{"#", "Item", "Quantity"}, } type bankEntry struct { slot int pos int name string id string qty int } var entries []bankEntry for _, i := range p.BankSlots() { slot := p.BankSlot(i) if slot == nil { continue } def, _ := g.ItemStore.Load(slot.ItemID) itemName := slot.ItemID if def != nil { itemName = def.Name } // pos is the 1-based display position; it is also the number the // withdraw command accepts, so gaps in bank slot numbering can no // longer make a displayed number select the wrong item. entries = append(entries, bankEntry{slot: i, pos: len(entries) + 1, name: itemName, id: slot.ItemID, qty: slot.Quantity}) } if p.Crypto.Balance < 0 { sess.WriteLine(g.colorize(sess, "casino_result_loss", fmt.Sprintf("Crypto Exchange debt: %d credits. Future credit deposits repay this first.", -p.Crypto.Balance))) } if len(entries) == 0 && p.BankedCredits <= 0 { sess.WriteLine("Your bank is empty.") return } sort.Slice(entries, func(i, j int) bool { return entries[i].slot < entries[j].slot }) for _, e := range entries { def, _ := g.ItemStore.Load(e.id) t.Rows = append(t.Rows, []string{ fmt.Sprintf("%d", e.pos), g.itemColorize(sess, def, e.name), fmt.Sprintf("%d", e.qty), }) } for _, line := range t.Render(unicode, p.OptionInt("wrap_width")) { sess.WriteLine(line) } if p.BankedCredits > 0 { sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("Banked credits: %s", formatInt(p.BankedCredits)))) } } func (g *Game) doBankDeposit(sess *net.Session, input string) { p := sess.Player matches := g.findInventoryMatches(input, p) if len(matches) == 0 { sess.WriteLine("You don't have that item.") return } // Multiple slots often hold the same item name (stacking does not always // merge), so only treat distinct names as ambiguous; showWhichOne dedups // by name and would otherwise print a one-line prompt with no valid reply. if len(matches) > 1 && len(uniqueItemNames(matches)) > 1 { g.showWhichOne(sess, matches) return } match := matches[0] slot := p.InvSlot(match.Slot) if slot == nil { return } itemID := slot.ItemID qty := slot.Quantity def, _ := g.ItemStore.Load(itemID) p.SetInvSlot(match.Slot, nil) bankIdx := p.NextBankSlot() p.SetBankSlot(bankIdx, &player.InventorySlot{ ItemID: itemID, Quantity: qty, Quality: slot.Quality, MaxQuality: slot.MaxQuality, }) g.saveCharacter(p) displayName := itemID if def != nil { displayName = def.Name } itemColor := g.itemColorize(sess, def, displayName) if qty > 1 { sess.WriteLine(fmt.Sprintf("You deposit %d %ss.", qty, itemColor)) } else { sess.WriteLine(fmt.Sprintf("You deposit a %s.", itemColor)) } } func (g *Game) doBankDepositAll(sess *net.Session) { p := sess.Player var deposited int for i := 0; i < player.MaxInventorySlots; i++ { slot := p.InvSlot(i) if slot == nil { continue } bankIdx := p.NextBankSlot() p.SetBankSlot(bankIdx, &player.InventorySlot{ ItemID: slot.ItemID, Quantity: slot.Quantity, Quality: slot.Quality, MaxQuality: slot.MaxQuality, }) p.SetInvSlot(i, nil) deposited++ } if deposited == 0 { sess.WriteLine("Your inventory is already empty.") return } g.saveCharacter(p) sess.WriteLine(fmt.Sprintf("You deposit %d item%s.", deposited, plural(deposited))) } func (g *Game) doBankWithdraw(sess *net.Session, input string) { p := sess.Player // No upfront capacity check here: withdrawBankItem merges stackable items // into existing stacks first and only needs a free slot otherwise. type entryRef struct { slot int pos int name string id string qty int } var entries []entryRef for _, i := range p.BankSlots() { bs := p.BankSlot(i) if bs == nil { continue } def, _ := g.ItemStore.Load(bs.ItemID) itemName := bs.ItemID if def != nil { itemName = def.Name } entries = append(entries, entryRef{slot: i, pos: len(entries) + 1, name: itemName, id: bs.ItemID, qty: bs.Quantity}) } // Only a pure number selects a bank slot by position: parseChoiceIndex // accepts a leading integer and ignores trailing text, so "withdraw 2 // chips" would otherwise withdraw display entry #2 instead of the named // item. if idx, err := strconv.Atoi(strings.TrimSpace(input)); err == nil && idx > 0 && idx <= len(entries) { e := entries[idx-1] g.withdrawBankItem(sess, p, e.slot) return } var matches []entryRef lower := strings.ToLower(input) for _, e := range entries { if behavior.WordPrefixMatch(e.name, lower) { matches = append(matches, e) } } if len(matches) == 1 { g.withdrawBankItem(sess, p, matches[0].slot) return } // Only fall back to fuzzy MatchesName when the prefix pass found nothing; // running it unconditionally appended the same entries twice and printed a // duplicated ambiguity list. if len(matches) == 0 { for _, e := range entries { def, _ := g.ItemStore.Load(e.id) if def != nil && def.MatchesName(input) { matches = append(matches, e) } } } if len(matches) == 0 { sess.WriteLine("That item isn't in your bank. Use 'browse' to see your bank contents.") return } if len(matches) > 1 { var names []string for _, m := range matches { names = append(names, m.name) } sess.WriteLine("That's ambiguous, which one?") for _, n := range names { sess.WriteLine(fmt.Sprintf(" - %s", n)) } return } g.withdrawBankItem(sess, p, matches[0].slot) } func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx int) { bs := p.BankSlot(bankSlotIdx) if bs == nil { return } def, _ := g.ItemStore.Load(bs.ItemID) // Stackable withdrawals merge into a compatible stack first; a free slot // is only required when the player holds none of the item. Compatibility // means the exact same item instance: merging two different-quality // stacks would silently destroy the withdrawn stack's properties. if def != nil && def.Stackable { for i := 0; i < player.MaxInventorySlots; i++ { if slot := p.InvSlot(i); slot != nil && slot.ItemID == bs.ItemID && slot.Quality == bs.Quality && slot.MaxQuality == bs.MaxQuality { slot.Quantity += bs.Quantity p.SetBankSlot(bankSlotIdx, nil) g.saveCharacter(p) displayName := bs.ItemID if def != nil { displayName = def.Name } itemColor := g.itemColorize(sess, def, displayName) sess.WriteLine(fmt.Sprintf("You withdraw %d %ss.", bs.Quantity, itemColor)) return } } } freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Your inventory is full.") return } itemID := bs.ItemID qty := bs.Quantity if def != nil && def.Stackable { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty, Quality: bs.Quality, MaxQuality: bs.MaxQuality}) } else { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1, Quality: bs.Quality, MaxQuality: bs.MaxQuality}) if qty > 1 { bs.Quantity-- g.saveCharacter(p) displayName := itemID if def != nil { displayName = def.Name } itemColor := g.itemColorize(sess, def, displayName) sess.WriteLine(fmt.Sprintf("You withdraw a %s.", itemColor)) return } } p.SetBankSlot(bankSlotIdx, nil) g.saveCharacter(p) displayName := itemID if def != nil { displayName = def.Name } itemColor := g.itemColorize(sess, def, displayName) if qty > 1 { sess.WriteLine(fmt.Sprintf("You withdraw %d %ss.", qty, itemColor)) } else { sess.WriteLine(fmt.Sprintf("You withdraw a %s.", itemColor)) } } func (g *Game) doBankDepositCredits(sess *net.Session, parts []string) { p := sess.Player if len(parts) < 3 { sess.WriteLine("Usage: deposit credits ") return } amount, err := strconv.Atoi(parts[2]) if err != nil || amount <= 0 { sess.WriteLine("Invalid amount.") return } if p.Credits < amount { sess.WriteLine("You don't have that many credits.") return } var seized int64 if err := g.cryptoTransaction(p, func() error { // Seize from the incoming deposit before the bank cap check. This also // permits repayment when the bank itself is already at the balance cap. seized = min(int64(amount), max(int64(0), -p.Crypto.Balance)) bank, err := addCreditsChecked(p.BankedCredits, amount-int(seized)) if err != nil { return err } p.Credits -= amount p.BankedCredits = bank p.Crypto.Balance += seized return nil }); err != nil { sess.WriteLine("The bank terminal could not record the transfer; nothing was moved.") return } if seized > 0 { sess.WriteLine(fmt.Sprintf("The Crypto Exchange seized %d deposited credits to repay debt.", seized)) } sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("You deposit %s credits. (Banked: %s)", formatInt(amount), formatInt(p.BankedCredits)))) g.showBankBrowse(sess) } func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) { p := sess.Player if len(parts) < 3 { sess.WriteLine("Usage: withdraw credits ") return } amount, err := strconv.Atoi(parts[2]) if err != nil || amount <= 0 { sess.WriteLine("Invalid amount.") return } if p.Crypto.Balance < 0 { sess.WriteLine("Your bank credits are subject to Crypto Exchange debt. Repay the debt before withdrawing.") return } if p.BankedCredits < amount { sess.WriteLine("You don't have that many banked credits.") return } // The withdrawal must respect the shared economy cap: banked credits // are not part of the on-hand balance, so the sum can exceed it. if _, capErr := addCreditsChecked(p.Credits, amount); capErr != nil { sess.WriteLine("Your wallet cannot hold that many credits.") return } creditsBefore, bankedBefore := p.Credits, p.BankedCredits p.BankedCredits -= amount p.Credits += amount if !g.persistBankCredits(p, creditsBefore, bankedBefore) { sess.WriteLine("The bank terminal could not record the transfer; nothing was moved.") return } sess.WriteLine(g.colorize(sess, "currency_pickup", fmt.Sprintf("You withdraw %s credits.", formatInt(amount)))) g.showBankBrowse(sess) } // persistBankCredits makes a credit move between the on-hand wallet and the // bank durable, reverting it in memory when the save fails. A best-effort // save here would leave the in-memory balance ahead of the durable file, so // a wager taken from that phantom balance would be charged against money the // player does not durably have. Reports whether the move may be announced. func (g *Game) persistBankCredits(p *player.Player, creditsBefore, bankedBefore int) bool { if err := (casinoWallet{g: g, p: p}).save("bank credit transfer"); err != nil { log.Printf("bank credit transfer %s: %v", p.Name, err) p.Credits = creditsBefore p.BankedCredits = bankedBefore return false } return true } func (g *Game) canUseBank(p *player.Player) bool { instances := g.World.FindObjInstances(p.RoomID, "bank booth") return len(instances) > 0 } func (g *Game) doBank(sess *net.Session) { p := sess.Player if !g.canUseBank(p) { sess.WriteLine("There is no bank here.") return } 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.SetState(net.StateGame) g.writePrompt(sess) }