package world import ( "fmt" "os" "path/filepath" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/combat" ) type HazardCombatBonuses struct { AttackBonus int `yaml:"attack_bonus"` StrengthBonus int `yaml:"strength_bonus"` RangedBonus int `yaml:"ranged_bonus"` RangedStrengthBonus int `yaml:"ranged_strength_bonus"` ScienceBonus int `yaml:"science_bonus"` SciencePercentBonus int `yaml:"science_percent_bonus"` } type HazardCombatStats struct { Attack int `yaml:"attack"` Strength int `yaml:"strength"` Ranged int `yaml:"ranged"` Science int `yaml:"science"` Speed float64 `yaml:"speed"` MaxMeleeHit int `yaml:"max_melee_hit"` MaxRangedHit int `yaml:"max_ranged_hit"` MaxScienceHit int `yaml:"max_science_hit"` Bonuses HazardCombatBonuses `yaml:"bonuses"` } type HazardCombat struct { AttackTypes []string `yaml:"attack_types"` Stats HazardCombatStats `yaml:"stats"` } // HazardDef describes an environmental threat attached to a room. Its combat // block mirrors a mob's combat block (attack_types, stats with per-type max hits // and bonuses) but has NO defense stats — the hazard is never attacked, it only // attacks the player. Speed is read from combat.stats.speed (default 5). type HazardDef struct { Name string `yaml:"name"` Description string `yaml:"description"` Combat *HazardCombat `yaml:"combat,omitempty"` 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 } // AttackType returns the hazard's active attack type: the first melee type from // attack_types, falling back to the first entry, defaulting to crush. func (d *HazardDef) AttackType() string { if d.Combat == nil || len(d.Combat.AttackTypes) == 0 { return combat.DefaultAttackType } for _, t := range d.Combat.AttackTypes { if combat.IsMeleeType(t) { return t } } return d.Combat.AttackTypes[0] } // SpeedTicks returns the hazard's tick interval (default 5). func (d *HazardDef) SpeedTicks() float64 { if d.Combat == nil || d.Combat.Stats.Speed <= 0 { return 5 } return d.Combat.Stats.Speed } // EffectiveMaxScienceHit applies the hazard's science percent bonus to its max // science hit. func (d *HazardDef) EffectiveMaxScienceHit() int { if d.Combat == nil { return 0 } base := d.Combat.Stats.MaxScienceHit if d.Combat.Stats.Bonuses.SciencePercentBonus > 0 { base = int(float64(base) * (1.0 + float64(d.Combat.Stats.Bonuses.SciencePercentBonus)/100.0)) } if base < 1 { base = 1 } return base } // 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 = behavior.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) } 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 = behavior.BuildPathIndex(filepath.Join(w.dataDir, "hazards")) } ids := make(map[string]bool, len(w.hazardPathIndex)) for id := range w.hazardPathIndex { ids[id] = true } return ids }