diff options
| author | historia <[not public]> | 2026-06-25 19:37:20 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-25 19:37:20 -0400 |
| commit | c55d0e4150b23f2c5c9f96bbc3d4dc2fc6dbaa36 (patch) | |
| tree | d1c02acea2b4c8d0f672c1539cfdb373b6272ed0 /internal | |
| parent | 068ce514ea3eebdc42b595c631b4b00ce4594577 (diff) | |
| download | thehouseoficarus-c55d0e4150b23f2c5c9f96bbc3d4dc2fc6dbaa36.tar.gz | |
feat: yaml conversation trees more robust
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/behavior/behavior.go | 5 | ||||
| -rw-r--r-- | internal/behavior/types.go | 28 | ||||
| -rw-r--r-- | internal/game/act_steal.go | 14 | ||||
| -rw-r--r-- | internal/game/act_talk.go | 119 | ||||
| -rw-r--r-- | internal/game/cmd_shop.go | 42 | ||||
| -rw-r--r-- | internal/game/look_entities.go | 11 | ||||
| -rw-r--r-- | internal/game/look_target.go | 49 | ||||
| -rw-r--r-- | internal/object/object.go | 1 | ||||
| -rw-r--r-- | internal/validate/checks.go | 27 | ||||
| -rw-r--r-- | internal/world/mob.go | 10 |
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, |
