aboutsummaryrefslogtreecommitdiff
path: root/internal/world
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-24 20:58:25 -0400
committerhistoria <[not public]>2026-06-24 20:58:25 -0400
commit9d4b799db4868bcab291b83599ff088763cd4ed6 (patch)
tree252f0fcc779acf35243983d643d6399ee2e0cd0b /internal/world
parentd25638d98fe63efdeab570ef77e6a6a97f2d7a60 (diff)
downloadthehouseoficarus-9d4b799db4868bcab291b83599ff088763cd4ed6.tar.gz
feat: new type of non-violent 'combat': work
Diffstat (limited to 'internal/world')
-rw-r--r--internal/world/hazard.go82
-rw-r--r--internal/world/mob.go23
-rw-r--r--internal/world/objects.go21
-rw-r--r--internal/world/room.go24
-rw-r--r--internal/world/world.go5
5 files changed, 151 insertions, 4 deletions
diff --git a/internal/world/hazard.go b/internal/world/hazard.go
new file mode 100644
index 0000000..396343b
--- /dev/null
+++ b/internal/world/hazard.go
@@ -0,0 +1,82 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "thehouseoficarus/internal/action"
+ "gopkg.in/yaml.v3"
+)
+
+// HazardDef describes an environmental threat attached to a room (a sandstorm,
+// solar radiation, falling rocks, etc.). It is a shared, ID-referenced
+// definition loaded from data/hazards/<id>.yaml, so one hazard can be reused
+// across a whole area and resolved by name for a future `examine` command.
+//
+// A hazard rolls an attack against everyone in the room every Speed ticks using
+// the SAME combat formulas as a mob: accuracy comes from Attack/AttackBonus, max
+// hit from MaxHit, and the player defends with their Defense level + equipment
+// bonus selected by AttackType (so existing armour and the Defense skill apply).
+// Tech protection (Kinetic Barrier / Projectile Screen / Neural Firewall) keys
+// off AttackType automatically.
+type HazardDef struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ AttackType string `yaml:"attack_type"` // stab/slash/crush/ranged/science
+ Attack int `yaml:"attack"`
+ AttackBonus int `yaml:"attack_bonus"`
+ MaxHit int `yaml:"max_hit"`
+ Speed float64 `yaml:"speed"` // ticks between hazard rolls
+ WarnMessage string `yaml:"warn_message"`
+ HitMessage string `yaml:"hit_message"` // accepts a single %d for damage
+ MissMessage string `yaml:"miss_message"`
+ RequiredItem string `yaml:"required_item"` // equipped item that negates the hazard
+}
+
+// LoadHazard returns the shared hazard definition for id, caching results.
+func (w *World) LoadHazard(id string) (*HazardDef, error) {
+ w.hazardMu.Lock()
+ if w.hazardPathIndex == nil {
+ w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards"))
+ }
+ if def, ok := w.hazardDefs[id]; ok {
+ w.hazardMu.Unlock()
+ return def, nil
+ }
+ path, ok := w.hazardPathIndex[id]
+ w.hazardMu.Unlock()
+ if !ok {
+ return nil, fmt.Errorf("read hazard %s: no such hazard", id)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read hazard %s: %w", id, err)
+ }
+ var def HazardDef
+ if err := yaml.Unmarshal(data, &def); err != nil {
+ return nil, fmt.Errorf("parse hazard %s: %w", id, err)
+ }
+ def.ID = id
+
+ w.hazardMu.Lock()
+ w.hazardDefs[id] = &def
+ w.hazardMu.Unlock()
+ return &def, nil
+}
+
+// HazardIndex returns the set of known hazard IDs (used by startup validation).
+func (w *World) HazardIndex() map[string]bool {
+ w.hazardMu.Lock()
+ defer w.hazardMu.Unlock()
+ if w.hazardPathIndex == nil {
+ w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards"))
+ }
+ ids := make(map[string]bool, len(w.hazardPathIndex))
+ for id := range w.hazardPathIndex {
+ ids[id] = true
+ }
+ return ids
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index ec7db36..4864e5c 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -58,11 +58,23 @@ type MobDef struct {
DamageWithout string `yaml:"damage_without"`
Size string `yaml:"size"`
+ // Kind discriminates a normal combat mob ("" or "combat") from a "task"
+ // worksite (build a solar panel, survey a rock, etc.). A task mob reuses the
+ // entire combat engine but its HP drains to 0 to COMPLETE the work; it never
+ // attacks back (the room hazard, if any, supplies the danger). The progress
+ // bar is displayed inverted (filling toward 100%).
+ Kind string `yaml:"kind"`
+ Verb string `yaml:"verb"` // flavor verb for messages (default "work")
+ ProgressNoun string `yaml:"progress_noun"` // flavor noun, e.g. "construction"
+ CompleteMessage string `yaml:"complete_message"` // shown on completion
+
Talk *action.TalkConfig `yaml:"talk,omitempty"`
}
func (d *MobDef) IsTalkable() bool { return d.Talk != nil }
+func (d *MobDef) IsTask() bool { return d.Kind == "task" }
+
type MobInstance struct {
InstanceID string
DefID string
@@ -109,11 +121,18 @@ type MobInstance struct {
DamageWithout string
Size string
+ Kind string
+ Verb string
+ ProgressNoun string
+ CompleteMessage string
+
TalkConfig *action.TalkConfig
}
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 {
return &MobInstance{
InstanceID: instanceID,
@@ -153,6 +172,10 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
FinishingBlow: def.FinishingBlow,
DamageWithout: def.DamageWithout,
Size: def.Size,
+ Kind: def.Kind,
+ Verb: def.Verb,
+ ProgressNoun: def.ProgressNoun,
+ CompleteMessage: def.CompleteMessage,
TalkConfig: def.Talk,
}
}
diff --git a/internal/world/objects.go b/internal/world/objects.go
index bf4b592..a7e0605 100644
--- a/internal/world/objects.go
+++ b/internal/world/objects.go
@@ -14,6 +14,7 @@ type ObjState struct {
DepleteTimer float64
DefID string
Name string
+ Aliases []string
Index int
RoomID int
JustRespawned bool
@@ -160,12 +161,13 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
if st.RoomID != roomID {
continue
}
- if !wordMatchesObj(lower, st.DefID, st.Name) {
+ if !wordMatchesObj(lower, st.DefID, st.Name, st.Aliases) {
continue
}
out = append(out, ObjState{
DefID: st.DefID,
Name: st.Name,
+ Aliases: st.Aliases,
Index: st.Index,
RoomID: st.RoomID,
Depleted: st.Depleted,
@@ -184,10 +186,15 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
return out
}
-func wordMatchesObj(lower, defID, objName string) bool {
+func wordMatchesObj(lower, defID, objName string, aliases []string) bool {
if object.WordPrefixMatch(lower, objName) {
return true
}
+ for _, alias := range aliases {
+ if object.WordPrefixMatch(lower, alias) {
+ return true
+ }
+ }
inputWords := strings.Fields(strings.ToLower(lower))
if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) {
@@ -214,6 +221,16 @@ func (w *World) SetObjName(roomID int, defID string, name string) {
}
}
+func (w *World) SetObjAliases(roomID int, defID string, aliases []string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID {
+ st.Aliases = aliases
+ }
+ }
+}
+
func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) {
w.mu.Lock()
defer w.mu.Unlock()
diff --git a/internal/world/room.go b/internal/world/room.go
index 752ec41..2441c30 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -48,6 +48,8 @@ type ExitDef struct {
Room int `yaml:"room"`
Condition *action.Condition `yaml:"condition"`
BlockedMessage string `yaml:"blocked_message"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
}
func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
@@ -67,11 +69,18 @@ type Room struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
+ Descriptions []RoomDesc `yaml:"descriptions,omitempty"`
Exits map[ExitDir]ExitDef `yaml:"exits"`
Objects []RoomObject `yaml:"objects"`
ItemSpawns []SpawnDef `yaml:"item_spawns"`
Mobs []RoomMob `yaml:"mobs"`
OnEnter []EnterStep `yaml:"on_enter"`
+ Hazard string `yaml:"hazard"`
+}
+
+type RoomDesc struct {
+ Text string `yaml:"text"`
+ Condition *action.Condition `yaml:"condition"`
}
type RoomMob struct {
@@ -94,8 +103,19 @@ func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
}
type EnterStep struct {
- Message string `yaml:"message"`
- Condition *action.Condition `yaml:"condition"`
+ Message string `yaml:"message"`
+ Condition *action.Condition `yaml:"condition"`
+ Delay int `yaml:"delay"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
+}
+
+// IsTimed reports whether the step carries cutscene semantics: a tick delay or
+// a flag mutation. A room whose qualifying on_enter steps are all non-timed is
+// printed synchronously; any timed step turns the sequence into a scheduled
+// cutscene.
+func (e EnterStep) IsTimed() bool {
+ return e.Delay > 0 || len(e.SetFlags) > 0 || len(e.SetPlayerFlags) > 0
}
type RoomObject struct {
diff --git a/internal/world/world.go b/internal/world/world.go
index b305c4b..8ed9a7c 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -19,6 +19,10 @@ type World struct {
seeded map[int]bool
objStates map[string]*ObjState
objMoves []ObjMove
+
+ hazardMu sync.Mutex
+ hazardPathIndex map[string]string
+ hazardDefs map[string]*HazardDef
}
func New(dataDir string) *World {
@@ -28,6 +32,7 @@ func New(dataDir string) *World {
groundItems: make(map[int][]*groundEntry),
seeded: make(map[int]bool),
objStates: make(map[string]*ObjState),
+ hazardDefs: make(map[string]*HazardDef),
}
}