aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/behavior/behavior.go5
-rw-r--r--internal/behavior/types.go28
-rw-r--r--internal/game/act_steal.go14
-rw-r--r--internal/game/act_talk.go119
-rw-r--r--internal/game/cmd_shop.go42
-rw-r--r--internal/game/look_entities.go11
-rw-r--r--internal/game/look_target.go49
-rw-r--r--internal/object/object.go1
-rw-r--r--internal/validate/checks.go27
-rw-r--r--internal/world/mob.go10
10 files changed, 221 insertions, 85 deletions
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index 57b2a7b..f81c73c 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -30,16 +30,17 @@ type TalkConfig struct {
}
type TalkNode struct {
- Message string `yaml:"message"`
+ Message MessageList `yaml:"message"`
Options []TalkOption `yaml:"options"`
Action *NodeAction `yaml:"action"`
+ Next string `yaml:"next,omitempty"`
}
type TalkOption struct {
Text string `yaml:"text"`
Goto string `yaml:"goto"`
- End bool `yaml:"end"`
Condition *Condition `yaml:"condition"`
+ Action *NodeAction `yaml:"action,omitempty"`
}
type NodeAction struct {
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index 3c6e290..f8e65f7 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -1,6 +1,11 @@
package behavior
-import "strings"
+import (
+ "math/rand"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
func WordPrefixMatch(input, name string) bool {
inputWords := strings.Fields(strings.ToLower(input))
@@ -186,3 +191,24 @@ type DropEntry struct {
type DropTableDef struct {
Drops []DropEntry `yaml:"drops"`
}
+
+type MessageList []string
+
+func (ml *MessageList) UnmarshalYAML(value *yaml.Node) error {
+ if value.Kind == yaml.ScalarNode {
+ var s string
+ if err := value.Decode(&s); err != nil {
+ return err
+ }
+ *ml = MessageList{s}
+ return nil
+ }
+ return value.Decode((*[]string)(ml))
+}
+
+func (ml MessageList) Pick() string {
+ if len(ml) == 0 {
+ return ""
+ }
+ return ml[rand.Intn(len(ml))]
+}
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 6f67944..7371782 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: "Caught you red-handed! You have three options, thief.",
+ Message: behavior.MessageList{"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,19 +36,19 @@ var stallGuardTalk = &behavior.TalkConfig{
},
},
"bribe": {
- Message: "Smart choice. Hand over 500 credits and we'll forget this happened.",
+ Message: behavior.MessageList{"Smart choice. Hand over 500 credits and we'll forget this happened."},
Action: &behavior.NodeAction{Cost: 500},
- Options: []behavior.TalkOption{{Text: "\"Fine, take it.\"", End: true}},
+ Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}},
},
"jail": {
- Message: "Off to the detention cell with you!",
+ Message: behavior.MessageList{"Off to the detention cell with you!"},
Action: &behavior.NodeAction{Teleport: 162},
- Options: []behavior.TalkOption{{Text: "(You are dragged away)", End: true}},
+ Options: []behavior.TalkOption{{Text: "(You are dragged away)"}},
},
"fight": {
- Message: "Then defend yourself!",
+ Message: behavior.MessageList{"Then defend yourself!"},
Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}},
- Options: []behavior.TalkOption{{Text: "(The guard attacks!)", End: true}},
+ Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}},
},
},
}
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index e9047b2..f54290a 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -6,6 +6,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
@@ -52,51 +53,99 @@ 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 {
+ g.Flags.Delete("guard_hostile")
+ p := sess.Player
+ if p != nil && p.Action != nil {
+ if td, ok := p.Action.Data.(*behavior.TalkData); ok && td.StealGuard {
+ guardMob := g.findGuardInRoom(p.RoomID, "guard")
+ if guardMob != nil {
+ sess.State = net.StateGame
+ g.cancelAction(p)
+ guardMob.Protected = false
+ g.startCombat(sess, p, guardMob)
+ }
+ return true
+ }
+ }
+ }
+ return false
+}
+
func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
- sess.WriteLine(fmt.Sprintf("%s", g.colorize(sess, "dialog", node.Message)))
+ p := sess.Player
+ td, _ := p.Action.Data.(*behavior.TalkData)
+
+ dialogSpec := g.resolveColor(sess, "dialog")
+ msg := node.Message.Pick()
+ if msg != "" {
+ sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ }
if node.Action != nil {
- if node.Action.Shop != nil {
- g.applyNodeAction(sess, node.Action)
- g.enterShop(sess, node.Action.Shop)
+ if g.handleNodeAction(sess, node.Action) {
+ return
+ }
+ }
+
+ cfg := td.Cfg
+ 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
+ sess.Write("\nChoice: ")
return
}
- g.applyNodeAction(sess, node.Action)
-
- if v, ok := g.Flags.Get("guard_hostile"); ok && v != nil {
- g.Flags.Delete("guard_hostile")
- p := sess.Player
- if p != nil && p.Action != nil {
- if td, ok := p.Action.Data.(*behavior.TalkData); ok && td.StealGuard {
- guardMob := g.findGuardInRoom(p.RoomID, "guard")
- if guardMob != nil {
- sess.State = net.StateGame
- g.cancelAction(p)
- guardMob.Protected = false
- g.startCombat(sess, p, guardMob)
- }
+
+ if node.Next != "" && cfg != nil {
+ next, ok := cfg.Nodes[node.Next]
+ if !ok {
+ break
+ }
+ if td != nil {
+ td.Node = node.Next
+ }
+ node = next
+
+ msg := node.Message.Pick()
+ if msg != "" {
+ sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ }
+
+ if node.Action != nil {
+ if g.handleNodeAction(sess, node.Action) {
return
}
}
+ } else {
+ break
}
}
- if len(node.Options) == 0 {
- sess.State = net.StateGame
- g.writePrompt(sess)
- return
- }
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+}
- visible := 0
- for _, opt := range node.Options {
+func (g *Game) visibleOptions(sess *net.Session, options []behavior.TalkOption) []behavior.TalkOption {
+ var visible []behavior.TalkOption
+ for _, opt := range options {
if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) {
continue
}
- visible++
- sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text))
+ visible = append(visible, opt)
}
- sess.State = net.StateTalk
- sess.Write("\nChoice: ")
+ return visible
}
func (g *Game) handleTalkInput(sess *net.Session, input string) {
@@ -108,6 +157,9 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
}
input = strings.TrimSpace(input)
+ if input == "" {
+ input = "1"
+ }
lower := strings.ToLower(input)
if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" {
g.cancelAction(p)
@@ -168,11 +220,10 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
return
}
- if chosen.End {
- g.cancelAction(p)
- sess.State = net.StateGame
- g.writePrompt(sess)
- return
+ if chosen.Action != nil {
+ if g.handleNodeAction(sess, chosen.Action) {
+ return
+ }
}
if chosen.Goto != "" {
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index 51d0ab1..d1b0e9c 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -401,10 +401,8 @@ func (g *Game) leaveShop(sess *net.Session) {
g.writePrompt(sess)
return
}
- nodeKey := td.Node
- cfg := td.Cfg
- node, ok := cfg.Nodes[nodeKey]
+ node, ok := td.Cfg.Nodes[td.Node]
if !ok {
sess.State = net.StateGame
g.cancelAction(p)
@@ -412,20 +410,30 @@ func (g *Game) leaveShop(sess *net.Session) {
return
}
- sess.State = net.StateTalk
- visible := 0
- for _, opt := range node.Options {
- if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) {
- continue
+ 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
+ sess.Write("\nChoice: ")
+ return
+ }
+
+ if node.Next != "" {
+ next, ok := td.Cfg.Nodes[node.Next]
+ if !ok {
+ break
+ }
+ td.Node = node.Next
+ node = next
+ } else {
+ break
}
- visible++
- sess.WriteLine(fmt.Sprintf(" %d. %s", visible, opt.Text))
- }
- if visible == 0 {
- sess.State = net.StateGame
- g.cancelAction(p)
- g.writePrompt(sess)
- return
}
- sess.Write("\nChoice: ")
+
+ sess.State = net.StateGame
+ g.cancelAction(p)
+ g.writePrompt(sess)
}
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index f9412d4..915317f 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -59,9 +59,14 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo
if m.Protected {
mobColor = "protected_mob"
}
- mobLevel := mobCombatLevel(m)
- levelStr := g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel))
- lines = append(lines, fmt.Sprintf("%s %s%s%s", g.colorize(sess, mobColor, displayName), levelStr, hp, desc))
+ var levelStr string
+ if m.Protected {
+ levelStr = ""
+ } else {
+ mobLevel := mobCombatLevel(m)
+ levelStr = " " + g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel))
+ }
+ lines = append(lines, fmt.Sprintf("%s%s%s%s", g.colorize(sess, mobColor, displayName), levelStr, hp, desc))
}
return lines
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 4819411..96dc174 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -57,33 +57,40 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
if best.Protected {
mobColor = "protected_mob"
}
- mobLevel := mobCombatLevel(best)
- levelStr := g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel))
+ var levelStr string
+ if best.Protected {
+ levelStr = color.Render(g.colorMode(sess), color.Parse("223"), "(protected)")
+ } else {
+ mobLevel := mobCombatLevel(best)
+ levelStr = g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel))
+ }
sess.WriteLines(
"",
fmt.Sprintf("%s %s", g.colorize(sess, mobColor, best.Name), levelStr),
)
- if best.IdleDescription != "" {
- sess.WriteLine(best.IdleDescription)
+ if best.Description != "" {
+ sess.WriteLine(color.ExpandTags(g.colorMode(sess), best.Description))
}
- sess.WriteLines(
- "",
- fmt.Sprintf("Attack: %d", best.Attack),
- fmt.Sprintf("Strength: %d", best.Strength),
- fmt.Sprintf("Defense: %d", best.Defense),
- fmt.Sprintf("HP: %d/%d", best.HP, best.MaxHP),
- )
- if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 ||
- best.ScienceDefense != 0 || best.RangedDefense != 0 {
+ if !best.Protected {
sess.WriteLines(
"",
- "Defense bonuses:",
- fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense),
- fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense),
+ fmt.Sprintf("Attack: %d", best.Attack),
+ fmt.Sprintf("Strength: %d", best.Strength),
+ fmt.Sprintf("Defense: %d", best.Defense),
+ fmt.Sprintf("HP: %d/%d", best.HP, best.MaxHP),
)
- }
- if best.Weakness != "" {
- sess.WriteLine(fmt.Sprintf("Weakness: %s", best.Weakness))
+ if best.StabDefense != 0 || best.SlashDefense != 0 || best.CrushDefense != 0 ||
+ best.ScienceDefense != 0 || best.RangedDefense != 0 {
+ sess.WriteLines(
+ "",
+ "Defense bonuses:",
+ fmt.Sprintf(" Stab: %+d Slash: %+d Crush: %+d", best.StabDefense, best.SlashDefense, best.CrushDefense),
+ fmt.Sprintf(" Science: %+d Ranged: %+d", best.ScienceDefense, best.RangedDefense),
+ )
+ }
+ if best.Weakness != "" {
+ sess.WriteLine(fmt.Sprintf("Weakness: %s", best.Weakness))
+ }
}
return
}
@@ -142,6 +149,10 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText))
}
+ if def.OnLook != nil {
+ g.applyNodeAction(sess, def.OnLook)
+ }
+
if def.Safespot != nil {
realSt := g.World.GetObjState(p.RoomID, st.DefID, st.Index)
if realSt != nil {
diff --git a/internal/object/object.go b/internal/object/object.go
index 8d1e02e..2dcc4bf 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -29,6 +29,7 @@ type ObjectDef struct {
Talk *behavior.TalkConfig `yaml:"talk,omitempty"`
Use *behavior.UseConfig `yaml:"use,omitempty"`
Safespot *SafespotConfig `yaml:"safespot,omitempty"`
+ OnLook *behavior.NodeAction `yaml:"on_look,omitempty"`
}
type SafespotConfig struct {
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index bb7cbd9..6aa0a81 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -152,6 +152,14 @@ func validateMobs(s Source) []Issue {
})
}
+ if !def.Protected && def.HP <= 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: is not protected but has HP=%d — mobs must have hp > 0 (or set protected: true)", id, def.HP),
+ })
+ }
+
if def.Kind != "" && def.Kind != "combat" && def.Kind != "task" {
issues = append(issues, Issue{
Level: "ERROR",
@@ -322,6 +330,10 @@ func validateObjects(s Source) []Issue {
if obj.Use != nil {
issues = append(issues, validateUseConfig(fmt.Sprintf("Object %q: use", id), obj.Use, itemIDs)...)
}
+
+ if obj.OnLook != nil {
+ issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: on_look", id), obj.OnLook, itemIDs, roomIndex)...)
+ }
}
return issues
@@ -666,6 +678,21 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
if node.Action != nil {
issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...)
}
+ if node.Next != "" {
+ if _, ok := cfg.Nodes[node.Next]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: next points to nonexistent node %q", nodePrefix, node.Next),
+ })
+ }
+ }
+ for i, opt := range node.Options {
+ optPrefix := fmt.Sprintf("%s: option %d", nodePrefix, i+1)
+ if opt.Action != nil {
+ issues = append(issues, validateNodeAction(optPrefix, opt.Action, itemIDs, roomIndex)...)
+ }
+ }
}
return issues
}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index 4c6becb..f11d6fe 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -78,6 +78,7 @@ type MobInstance struct {
InstanceID string
DefID string
Name string
+ Description string
HP int
MaxHP int
Attack int
@@ -133,12 +134,17 @@ func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil }
func (m *MobInstance) IsTask() bool { return m.Kind == "task" }
func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance {
+ hp := def.HP
+ if def.Protected && hp <= 0 {
+ hp = 1
+ }
return &MobInstance{
InstanceID: instanceID,
DefID: def.ID,
Name: def.Name,
- HP: def.HP,
- MaxHP: def.HP,
+ Description: def.Description,
+ HP: hp,
+ MaxHP: hp,
Attack: def.Attack,
Strength: def.Strength,
Defense: def.Defense,