aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-01 16:37:45 -0400
committerhistoria <[not public]>2026-07-01 16:37:45 -0400
commite6b4cb9f5816c6ee239233bc85f74ee446a161c2 (patch)
tree1c3bf71c8b7f095baae989afe5e33183a7ed3929 /internal/game
parent649ef4b7416ce7053145878841fc9b0bff2f5fec (diff)
downloadthehouseoficarus-e6b4cb9f5816c6ee239233bc85f74ee446a161c2.tar.gz
feat: shop system overhauled, gui conversation tree editor overhauled
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_steal.go10
-rw-r--r--internal/game/act_talk.go53
-rw-r--r--internal/game/cmd_registry.go284
-rw-r--r--internal/game/cmd_shop.go525
-rw-r--r--internal/game/game.go6
5 files changed, 411 insertions, 467 deletions
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 1c76474..f5d3dbf 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -28,7 +28,7 @@ func (g *Game) executeSteal(sess *net.Session, args []string, rawInput string) {
var stallGuardTalk = &behavior.TalkConfig{
Nodes: map[string]behavior.TalkNode{
"start": {
- Message: behavior.MessageList{"Caught you red-handed! You have three options, thief."},
+ Messages: []string{"Caught you red-handed! You have three options, thief."},
Options: []behavior.TalkOption{
{Text: "\"I'll pay a fine. (500 credits)\"", Goto: "bribe", Condition: &behavior.Condition{MinCredits: 500}},
{Text: "\"Take me to jail.\"", Goto: "jail"},
@@ -36,17 +36,17 @@ var stallGuardTalk = &behavior.TalkConfig{
},
},
"bribe": {
- Message: behavior.MessageList{"Smart choice. Hand over 500 credits and we'll forget this happened."},
- Action: &behavior.NodeAction{Cost: 500},
+ Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."},
+ Action: &behavior.NodeAction{Credits: -500},
Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
},
"jail": {
- Message: behavior.MessageList{"Off to the detention cell with you!"},
+ Messages: []string{"Off to the detention cell with you!"},
Action: &behavior.NodeAction{Teleport: 162},
Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
- Message: behavior.MessageList{"Then defend yourself!"},
+ Messages: []string{"Then defend yourself!"},
Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
},
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index bd5a091..7cfdf7c 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -58,11 +58,6 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object
}
func (g *Game) handleNodeAction(sess *net.Session, na *behavior.NodeAction) (intercepted bool) {
- if na.Shop != nil {
- g.applyNodeAction(sess, na)
- g.enterShop(sess, na.Shop)
- return true
- }
g.applyNodeAction(sess, na)
if v, ok := g.Flags.Get("guard_hostile"); ok && v != nil {
@@ -97,28 +92,24 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
for {
if node.Condition == nil || g.checkCondition(sess, node.Condition) {
- if len(node.Sequence) > 0 {
+ if len(node.Messages) > 0 {
if node.Action != nil {
if g.handleNodeAction(sess, node.Action) {
return
}
}
td.SeqIndex = 0
- msg := node.Sequence[0]
+ msg := node.Messages[0]
if msg != "" {
sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
td.SeqIndex = 1
- if td.SeqIndex < len(node.Sequence) {
+ if td.SeqIndex < len(node.Messages) {
sess.State = net.StateTalkSequence
sess.Write(g.colorize(sess, "dialog_options", "[enter to continue]\n"))
return
}
} else {
- msg := node.Message.Pick()
- if msg != "" {
- sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
- }
if node.Action != nil {
if g.handleNodeAction(sess, node.Action) {
return
@@ -301,14 +292,14 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
return
}
dialogSpec := g.resolveColor(sess, "dialog")
- if td.SeqIndex < len(node.Sequence) {
- msg := node.Sequence[td.SeqIndex]
+ if td.SeqIndex < len(node.Messages) {
+ msg := node.Messages[td.SeqIndex]
if msg != "" {
sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
td.SeqIndex++
}
- if td.SeqIndex >= len(node.Sequence) {
+ if td.SeqIndex >= len(node.Messages) {
g.finishSequence(sess, node)
return
}
@@ -341,28 +332,12 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
}
}
-func (g *Game) enterShop(sess *net.Session, cfg *behavior.ShopConfig) {
- sess.Shop = cfg
- sess.State = net.StateShop
- g.showShopBrowse(sess)
- g.writeShopPrompt(sess)
-}
-
func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
p := sess.Player
if p == nil {
return
}
- if na.AssignTask {
- g.assignAssassinTask(sess, p)
- }
- if na.SkipTask {
- g.skipAssassinTask(sess, p)
- }
- if na.ExtendTask {
- g.extendAssassinTask(sess, p)
- }
if na.ReputationCost > 0 {
rep := getPlayerFlagInt(p, "assassin_reputation")
if rep < na.ReputationCost {
@@ -414,15 +389,19 @@ func (g *Game) applyNodeAction(sess *net.Session, na *behavior.NodeAction) {
g.AccountStore.SaveCharacter(p)
sess.WriteLine(fmt.Sprintf("You regain %d hitpoints.", na.Heal))
}
- if na.Cost > 0 {
- if p.Credits >= na.Cost {
- p.Credits -= na.Cost
+ if na.Credits != 0 {
+ if na.Credits < 0 {
+ if p.Credits >= -na.Credits {
+ p.Credits += na.Credits
+ g.AccountStore.SaveCharacter(p)
+ } else {
+ sess.WriteLine(fmt.Sprintf("You don't have enough credits. (Need %d, have %d)", -na.Credits, p.Credits))
+ }
+ } else {
+ p.Credits += na.Credits
g.AccountStore.SaveCharacter(p)
}
}
- if na.Sawmill {
- g.processSawmill(sess, p)
- }
if na.ApsNode {
g.setPlayerFlag(p, "aps_node_"+strconv.Itoa(p.RoomID), true)
}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index bc77936..1247fc3 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -15,147 +15,151 @@ type commandDef struct {
}
var commandRegistry = map[string]commandDef{
- "get": {(*Game).executeGet, ClassActive},
- "take": {(*Game).executeGet, ClassActive},
- "grab": {(*Game).executeGet, ClassActive},
- "pick": {(*Game).executeGet, ClassActive},
- "drop": {(*Game).executeDrop, ClassActive},
- "attack": {(*Game).executeAttack, ClassActive},
- "kill": {(*Game).executeAttack, ClassActive},
- "work": {(*Game).executeWork, ClassActive},
- "style": {(*Game).executeStyle, ClassInstant},
- "look": {(*Game).executeLook, ClassInstant},
- "l": {(*Game).executeLook, ClassInstant},
- "north": {(*Game).executeMove, ClassActive},
- "n": {(*Game).executeMove, ClassActive},
- "south": {(*Game).executeMove, ClassActive},
- "s": {(*Game).executeMove, ClassActive},
- "east": {(*Game).executeMove, ClassActive},
- "e": {(*Game).executeMove, ClassActive},
- "west": {(*Game).executeMove, ClassActive},
- "w": {(*Game).executeMove, ClassActive},
- "northeast": {(*Game).executeMove, ClassActive},
- "ne": {(*Game).executeMove, ClassActive},
- "northwest": {(*Game).executeMove, ClassActive},
- "nw": {(*Game).executeMove, ClassActive},
- "southeast": {(*Game).executeMove, ClassActive},
- "se": {(*Game).executeMove, ClassActive},
- "southwest": {(*Game).executeMove, ClassActive},
- "sw": {(*Game).executeMove, ClassActive},
- "up": {(*Game).executeMove, ClassActive},
- "u": {(*Game).executeMove, ClassActive},
- "down": {(*Game).executeMove, ClassActive},
- "d": {(*Game).executeMove, ClassActive},
- "say": {(*Game).executeSay, ClassInstant},
- "global": {(*Game).executeGlobal, ClassInstant},
- "who": {(*Game).executeWho, ClassInstant},
- "sc": {(*Game).executeScore, ClassInstant},
- "score": {(*Game).executeScore, ClassInstant},
- "skills": {(*Game).executeSkills, ClassInstant},
- "sk": {(*Game).executeSkills, ClassInstant},
- "task": {(*Game).executeTask, ClassInstant},
- "stats": {(*Game).executeStats, ClassInstant},
- "tech": {(*Game).executeTech, ClassInstant},
- "t": {(*Game).executeTech, ClassInstant},
- "i": {(*Game).executeInventory, ClassInstant},
- "inv": {(*Game).executeInventory, ClassInstant},
- "inventory": {(*Game).executeInventory, ClassInstant},
- "quit": {(*Game).executeQuit, ClassActive},
- "description": {(*Game).executeDescription, ClassInstant},
- "desc": {(*Game).executeDescription, ClassInstant},
- "option": {(*Game).executeOption, ClassInstant},
- "options": {(*Game).executeOption, ClassInstant},
- "color": {(*Game).executeColor, ClassInstant},
- "colors": {(*Game).executeColor, ClassInstant},
- "colortable": {(*Game).executeColortable, ClassInstant},
- "prompt": {(*Game).executePrompt, ClassInstant},
- "exits": {(*Game).executeExits, ClassInstant},
- "map": {(*Game).executeMap, ClassInstant},
- "symbol": {(*Game).executeSymbol, ClassInstant},
- "queued": {(*Game).executeQueued, ClassInstant},
- "help": {(*Game).executeHelp, ClassInstant},
- "mine": {(*Game).executeGather, ClassActive},
- "chop": {(*Game).executeGather, ClassActive},
- "fish": {(*Game).executeGather, ClassActive},
- "cut": {(*Game).executeGather, ClassActive},
- "pull": {(*Game).executeUse, ClassActive},
- "push": {(*Game).executeUse, ClassActive},
- "use": {(*Game).executeUse, ClassActive},
- "cook": {(*Game).executeCook, ClassActive},
- "smelt": {(*Game).executeSmelt, ClassActive},
- "smith": {(*Game).executeSmith, ClassActive},
- "craft": {(*Game).executeCraft, ClassActive},
- "eat": {(*Game).executeEat, ClassFree},
- "drink": {(*Game).executeDrink, ClassFree},
- "fletch": {(*Game).executeFletch, ClassFree},
- "clean": {(*Game).executeClean, ClassFree},
- "mix": {(*Game).executeMix, ClassActive},
- "id": {(*Game).executeIdentify, ClassActive},
- "identify": {(*Game).executeIdentify, ClassActive},
- "autotrigger": {(*Game).executeAutotrigger, ClassInstant},
- "auto": {(*Game).executeAutotrigger, ClassInstant},
- "mods": {(*Game).executeMods, ClassInstant},
- "modlist": {(*Game).executeMods, ClassInstant},
- "mod": {(*Game).executeMods, ClassInstant},
- "modules": {(*Game).executeMods, ClassInstant},
- "module": {(*Game).executeMods, ClassInstant},
- "sneak": {(*Game).executeSneak, ClassInstant},
- "trigger": {(*Game).executeTrigger, ClassActive},
- "home": {(*Game).executeHome, ClassInstant},
- "recall": {(*Game).executeHome, ClassInstant},
- "talk": {(*Game).executeTalk, ClassActive},
- "speak": {(*Game).executeTalk, ClassActive},
- "ask": {(*Game).executeTalk, ClassActive},
- "burn": {(*Game).executeBurn, ClassActive},
- "stoke": {(*Game).executeStoke, ClassActive},
- "alias": {(*Game).executeAlias, ClassInstant},
- "unalias": {(*Game).executeUnalias, ClassInstant},
- "search": {(*Game).executeSearch, ClassActive},
- "steal": {(*Game).executeSteal, ClassActive},
- "thieve": {(*Game).executeSteal, ClassActive},
- "plant": {(*Game).executePlant, ClassActive},
- "harvest": {(*Game).executeHarvest, ClassActive},
- "rake": {(*Game).executeRake, ClassActive},
- "water": {(*Game).executeWater, ClassActive},
- "cure": {(*Game).executeCure, ClassActive},
- "inspect": {(*Game).executeInspect, ClassInstant},
- "walk": {(*Game).executeWalk, ClassActive},
- "bank": {(*Game).executeBank, ClassActive},
- "construct": {(*Game).executeConstruct, ClassActive},
- "make": {(*Game).executeConstruct, ClassActive},
- "jack": {(*Game).executeJack, ClassActive},
- "jackin": {(*Game).executeJack, ClassActive},
- "eq": {(*Game).executeWear, ClassFree},
- "equipment": {(*Game).executeWear, ClassFree},
- "equip": {(*Game).executeWear, ClassFree},
- "wear": {(*Game).executeWear, ClassFree},
- "wield": {(*Game).executeWear, ClassFree},
- "remove": {(*Game).executeRemove, ClassFree},
- "unwear": {(*Game).executeRemove, ClassFree},
- "unwield": {(*Game).executeRemove, ClassFree},
- "aps": {(*Game).executeAps, ClassActive},
- "verbs": {(*Game).executeVerbs, ClassInstant},
- "what": {(*Game).executeVerbs, ClassInstant},
- "syntax": {(*Game).executeVerbs, ClassInstant},
- "commands": {(*Game).executeVerbs, ClassInstant},
- "stop": {(*Game).executeStop, ClassInstant},
- "hide": {(*Game).executeHide, ClassActive},
- "safespot": {(*Game).executeHide, ClassActive},
- "unhide": {(*Game).executeUnhide, ClassActive},
- "unsafespot": {(*Game).executeUnhide, ClassActive},
- "goto": {(*Game).executeGoto, ClassInstant},
- "summon": {(*Game).executeSummon, ClassInstant},
- "dig": {(*Game).executeDig, ClassInstant},
- "setflag": {(*Game).executeSetFlag, ClassInstant},
+ "get": {(*Game).executeGet, ClassActive},
+ "take": {(*Game).executeGet, ClassActive},
+ "grab": {(*Game).executeGet, ClassActive},
+ "pick": {(*Game).executeGet, ClassActive},
+ "drop": {(*Game).executeDrop, ClassActive},
+ "attack": {(*Game).executeAttack, ClassActive},
+ "kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
+ "style": {(*Game).executeStyle, ClassInstant},
+ "look": {(*Game).executeLook, ClassInstant},
+ "l": {(*Game).executeLook, ClassInstant},
+ "north": {(*Game).executeMove, ClassActive},
+ "n": {(*Game).executeMove, ClassActive},
+ "south": {(*Game).executeMove, ClassActive},
+ "s": {(*Game).executeMove, ClassActive},
+ "east": {(*Game).executeMove, ClassActive},
+ "e": {(*Game).executeMove, ClassActive},
+ "west": {(*Game).executeMove, ClassActive},
+ "w": {(*Game).executeMove, ClassActive},
+ "northeast": {(*Game).executeMove, ClassActive},
+ "ne": {(*Game).executeMove, ClassActive},
+ "northwest": {(*Game).executeMove, ClassActive},
+ "nw": {(*Game).executeMove, ClassActive},
+ "southeast": {(*Game).executeMove, ClassActive},
+ "se": {(*Game).executeMove, ClassActive},
+ "southwest": {(*Game).executeMove, ClassActive},
+ "sw": {(*Game).executeMove, ClassActive},
+ "up": {(*Game).executeMove, ClassActive},
+ "u": {(*Game).executeMove, ClassActive},
+ "down": {(*Game).executeMove, ClassActive},
+ "d": {(*Game).executeMove, ClassActive},
+ "say": {(*Game).executeSay, ClassInstant},
+ "global": {(*Game).executeGlobal, ClassInstant},
+ "who": {(*Game).executeWho, ClassInstant},
+ "sc": {(*Game).executeScore, ClassInstant},
+ "score": {(*Game).executeScore, ClassInstant},
+ "skills": {(*Game).executeSkills, ClassInstant},
+ "sk": {(*Game).executeSkills, ClassInstant},
+ "task": {(*Game).executeTask, ClassInstant},
+ "stats": {(*Game).executeStats, ClassInstant},
+ "tech": {(*Game).executeTech, ClassInstant},
+ "t": {(*Game).executeTech, ClassInstant},
+ "i": {(*Game).executeInventory, ClassInstant},
+ "inv": {(*Game).executeInventory, ClassInstant},
+ "inventory": {(*Game).executeInventory, ClassInstant},
+ "quit": {(*Game).executeQuit, ClassActive},
+ "description": {(*Game).executeDescription, ClassInstant},
+ "desc": {(*Game).executeDescription, ClassInstant},
+ "option": {(*Game).executeOption, ClassInstant},
+ "options": {(*Game).executeOption, ClassInstant},
+ "color": {(*Game).executeColor, ClassInstant},
+ "colors": {(*Game).executeColor, ClassInstant},
+ "colortable": {(*Game).executeColortable, ClassInstant},
+ "prompt": {(*Game).executePrompt, ClassInstant},
+ "exits": {(*Game).executeExits, ClassInstant},
+ "map": {(*Game).executeMap, ClassInstant},
+ "symbol": {(*Game).executeSymbol, ClassInstant},
+ "queued": {(*Game).executeQueued, ClassInstant},
+ "help": {(*Game).executeHelp, ClassInstant},
+ "mine": {(*Game).executeGather, ClassActive},
+ "chop": {(*Game).executeGather, ClassActive},
+ "fish": {(*Game).executeGather, ClassActive},
+ "cut": {(*Game).executeGather, ClassActive},
+ "pull": {(*Game).executeUse, ClassActive},
+ "push": {(*Game).executeUse, ClassActive},
+ "use": {(*Game).executeUse, ClassActive},
+ "cook": {(*Game).executeCook, ClassActive},
+ "smelt": {(*Game).executeSmelt, ClassActive},
+ "smith": {(*Game).executeSmith, ClassActive},
+ "craft": {(*Game).executeCraft, ClassActive},
+ "eat": {(*Game).executeEat, ClassFree},
+ "drink": {(*Game).executeDrink, ClassFree},
+ "fletch": {(*Game).executeFletch, ClassFree},
+ "clean": {(*Game).executeClean, ClassFree},
+ "mix": {(*Game).executeMix, ClassActive},
+ "id": {(*Game).executeIdentify, ClassActive},
+ "identify": {(*Game).executeIdentify, ClassActive},
+ "autotrigger": {(*Game).executeAutotrigger, ClassInstant},
+ "auto": {(*Game).executeAutotrigger, ClassInstant},
+ "mods": {(*Game).executeMods, ClassInstant},
+ "modlist": {(*Game).executeMods, ClassInstant},
+ "mod": {(*Game).executeMods, ClassInstant},
+ "modules": {(*Game).executeMods, ClassInstant},
+ "module": {(*Game).executeMods, ClassInstant},
+ "sneak": {(*Game).executeSneak, ClassInstant},
+ "trigger": {(*Game).executeTrigger, ClassActive},
+ "home": {(*Game).executeHome, ClassInstant},
+ "recall": {(*Game).executeHome, ClassInstant},
+ "talk": {(*Game).executeTalk, ClassActive},
+ "speak": {(*Game).executeTalk, ClassActive},
+ "ask": {(*Game).executeTalk, ClassActive},
+ "burn": {(*Game).executeBurn, ClassActive},
+ "stoke": {(*Game).executeStoke, ClassActive},
+ "alias": {(*Game).executeAlias, ClassInstant},
+ "unalias": {(*Game).executeUnalias, ClassInstant},
+ "search": {(*Game).executeSearch, ClassActive},
+ "steal": {(*Game).executeSteal, ClassActive},
+ "thieve": {(*Game).executeSteal, ClassActive},
+ "plant": {(*Game).executePlant, ClassActive},
+ "harvest": {(*Game).executeHarvest, ClassActive},
+ "rake": {(*Game).executeRake, ClassActive},
+ "water": {(*Game).executeWater, ClassActive},
+ "cure": {(*Game).executeCure, ClassActive},
+ "inspect": {(*Game).executeInspect, ClassInstant},
+ "walk": {(*Game).executeWalk, ClassActive},
+ "bank": {(*Game).executeBank, ClassActive},
+ "list": {(*Game).executeShopList, ClassInstant},
+ "browse": {(*Game).executeShopList, ClassInstant},
+ "buy": {(*Game).executeShopBuy, ClassInstant},
+ "sell": {(*Game).executeShopSell, ClassInstant},
+ "construct": {(*Game).executeConstruct, ClassActive},
+ "make": {(*Game).executeConstruct, ClassActive},
+ "jack": {(*Game).executeJack, ClassActive},
+ "jackin": {(*Game).executeJack, ClassActive},
+ "eq": {(*Game).executeWear, ClassFree},
+ "equipment": {(*Game).executeWear, ClassFree},
+ "equip": {(*Game).executeWear, ClassFree},
+ "wear": {(*Game).executeWear, ClassFree},
+ "wield": {(*Game).executeWear, ClassFree},
+ "remove": {(*Game).executeRemove, ClassFree},
+ "unwear": {(*Game).executeRemove, ClassFree},
+ "unwield": {(*Game).executeRemove, ClassFree},
+ "aps": {(*Game).executeAps, ClassActive},
+ "verbs": {(*Game).executeVerbs, ClassInstant},
+ "what": {(*Game).executeVerbs, ClassInstant},
+ "syntax": {(*Game).executeVerbs, ClassInstant},
+ "commands": {(*Game).executeVerbs, ClassInstant},
+ "stop": {(*Game).executeStop, ClassInstant},
+ "hide": {(*Game).executeHide, ClassActive},
+ "safespot": {(*Game).executeHide, ClassActive},
+ "unhide": {(*Game).executeUnhide, ClassActive},
+ "unsafespot": {(*Game).executeUnhide, ClassActive},
+ "goto": {(*Game).executeGoto, ClassInstant},
+ "summon": {(*Game).executeSummon, ClassInstant},
+ "dig": {(*Game).executeDig, ClassInstant},
+ "setflag": {(*Game).executeSetFlag, ClassInstant},
"setplayerflag": {(*Game).executeSetPlayerFlag, ClassInstant},
- "reload": {(*Game).executeReload, ClassInstant},
- "shutdown": {(*Game).executeShutdown, ClassInstant},
- "room": {(*Game).executeRoom, ClassInstant},
- "swapid": {(*Game).executeSwapID, ClassInstant},
- "undig": {(*Game).executeUndig, ClassInstant},
- "close": {(*Game).executeClose, ClassInstant},
- "god": {(*Game).executeGod, ClassInstant},
- "ungod": {(*Game).executeUnGod, ClassInstant},
+ "reload": {(*Game).executeReload, ClassInstant},
+ "shutdown": {(*Game).executeShutdown, ClassInstant},
+ "room": {(*Game).executeRoom, ClassInstant},
+ "swapid": {(*Game).executeSwapID, ClassInstant},
+ "undig": {(*Game).executeUndig, ClassInstant},
+ "close": {(*Game).executeClose, ClassInstant},
+ "god": {(*Game).executeGod, ClassInstant},
+ "ungod": {(*Game).executeUnGod, ClassInstant},
}
func classifyCommand(cmd string) CommandClass {
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)
}
diff --git a/internal/game/game.go b/internal/game/game.go
index c30f9aa..4cd3b30 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -56,14 +56,14 @@ type Game struct {
queue *CommandQueue
safespot *SafespotManager
ValidationConfig config.ValidationConfig
- StartingRoom int
+ StartingRoom int
// Per-tick / per-session runtime bookkeeping.
charsMu sync.Mutex
loggedInChars map[string]*net.Session
restTimers map[string]uint64
guardWatchTimers map[string]int
- hackingStates map[string]*hacking.Session
+ hackingStates map[string]*hacking.Session
pendingDepletions []pendingDepletion
farmTickCounter int
enterMu sync.Mutex
@@ -162,8 +162,6 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleDescChange(sess, input)
case net.StateTalk, net.StateTalkSequence:
g.handleTalkInput(sess, input)
- case net.StateShop:
- g.handleShopInput(sess, input)
case net.StateBank:
g.handleBankInput(sess, input)
case net.StateDropAllConfirm: