package game import ( "path/filepath" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) // ObstaclePhase is a single phase of an obstacle's advancement sequence. // Each phase prints a message after waiting Delay ticks, then optionally // performs the failure check. type ObstaclePhase struct { Message string `yaml:"message"` Delay float64 `yaml:"delay"` FailCheck bool `yaml:"fail_check,omitempty"` } // ObstacleDef is the YAML representation of a single agility obstacle. // Two equivalent forms are supported: // - Legacy: Messages (exactly 3) + TicksPerPhase + fail at phase index 1. // - Preferred: Phases, an explicit ordered list with per-phase delays and // an optional fail_check flag (at most one phase should carry it). type ObstacleDef struct { RoomID int `yaml:"room_id"` Verb string `yaml:"verb"` XP int `yaml:"xp"` FailDamage [2]int `yaml:"fail_damage"` FailChance *float64 `yaml:"fail_chance,omitempty"` TicksPerPhase float64 `yaml:"ticks_per_phase"` Messages []string `yaml:"messages"` Phases []ObstaclePhase `yaml:"phases"` } type CourseConfig struct { ID string `yaml:"id"` Name string `yaml:"name"` RequiredLevel int `yaml:"required_level"` StartRoom int `yaml:"start_room"` CompletionXP int `yaml:"completion_xp"` Obstacles []ObstacleDef `yaml:"obstacles"` } // PhaseInfo is the runtime-resolved form of an ObstaclePhase. type PhaseInfo struct { Message string Delay float64 FailCheck bool } type ObstacleInfo struct { CourseID string CourseName string ObstacleIndex int TotalObstacles int Verb string Phases []PhaseInfo ObstacleXP int CompletionXP int FailChance *float64 // nil => derived from agility level FailDamage [2]int NextRoom int StartRoom int RequiredLevel int } var obstacleVerbs = map[string]bool{} var verbGerund = map[string]string{ "scramble": "scrambling", "jump": "jumping", "swing": "swinging", "balance": "balancing", "climb": "climbing", "crawl": "crawling", "vault": "vaulting", "leap": "leaping", "slide": "sliding", } type CourseStore struct { dataDir string mu sync.Mutex courses map[string]*CourseConfig roomToObstacle map[int]*ObstacleInfo loaded bool } func NewCourseStore(dataDir string) *CourseStore { return &CourseStore{ dataDir: dataDir, courses: make(map[string]*CourseConfig), } } func (cs *CourseStore) LoadAll() { cs.mu.Lock() defer cs.mu.Unlock() cs.loadAllLocked() } func (cs *CourseStore) ReloadAll() { cs.mu.Lock() cs.loaded = false cs.courses = make(map[string]*CourseConfig) cs.mu.Unlock() cs.LoadAll() } // AllCourses returns the full course config map, loading on first access. func (cs *CourseStore) AllCourses() map[string]*CourseConfig { cs.mu.Lock() defer cs.mu.Unlock() if !cs.loaded { cs.loadAllLocked() } return cs.courses } func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { cs.mu.Lock() defer cs.mu.Unlock() if !cs.loaded { cs.loadAllLocked() } return cs.roomToObstacle[roomID] } // resolvePhases converts an ObstacleDef into a normalized PhaseInfo list. // Preferred form: explicit Phases. Legacy form: Messages with a shared // TicksPerPhase and the failure check at the middle (index 1) phase. func resolvePhases(obs ObstacleDef) []PhaseInfo { if len(obs.Phases) > 0 { phases := make([]PhaseInfo, 0, len(obs.Phases)) for _, p := range obs.Phases { phases = append(phases, PhaseInfo{ Message: p.Message, Delay: p.Delay, FailCheck: p.FailCheck, }) } return phases } msgs := obs.Messages phases := make([]PhaseInfo, 0, len(msgs)) for i, m := range msgs { var delay float64 if i == 0 { delay = 0 } else { delay = obs.TicksPerPhase } phases = append(phases, PhaseInfo{ Message: m, Delay: delay, FailCheck: i == 1, }) } return phases } func (cs *CourseStore) loadAllLocked() { cs.loaded = true cs.roomToObstacle = make(map[int]*ObstacleInfo) localVerbs := make(map[string]bool) behavior.WalkYAMLDir(filepath.Join(cs.dataDir, "courses"), func(path, id string, data []byte) error { var cfg CourseConfig if err := yaml.Unmarshal(data, &cfg); err != nil { return nil } cfg.ID = id cs.courses[cfg.ID] = &cfg totalObstacles := len(cfg.Obstacles) for i, obs := range cfg.Obstacles { nextRoom := 0 if i < totalObstacles-1 { nextRoom = cfg.Obstacles[i+1].RoomID } completionXP := 0 if i == totalObstacles-1 { completionXP = cfg.CompletionXP } info := &ObstacleInfo{ CourseID: cfg.ID, CourseName: cfg.Name, ObstacleIndex: i, TotalObstacles: totalObstacles, Verb: obs.Verb, Phases: resolvePhases(obs), ObstacleXP: obs.XP, CompletionXP: completionXP, FailChance: obs.FailChance, FailDamage: obs.FailDamage, NextRoom: nextRoom, StartRoom: cfg.StartRoom, RequiredLevel: cfg.RequiredLevel, } cs.roomToObstacle[obs.RoomID] = info localVerbs[obs.Verb] = true } return nil }) obstacleVerbs = localVerbs }