package game import ( "log" "os" "path/filepath" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/world" ) // 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. // Phases is the ordered list of advancement steps, each with a per-phase // delay 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"` Phases []ObstaclePhase `yaml:"phases"` ExitDir string `yaml:"exit_dir"` OnFailRoom int `yaml:"on_fail,omitempty"` } 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 OnFailRoom int RequiredLevel int ExitDir string } 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 world *world.World } func NewCourseStore(dataDir string) *CourseStore { return &CourseStore{ dataDir: dataDir, courses: make(map[string]*CourseConfig), } } func (cs *CourseStore) SetWorld(w *world.World) { cs.world = w } 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. func resolvePhases(obs ObstacleDef) []PhaseInfo { 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 } func (cs *CourseStore) resolveExitTarget(roomID int, dir string) int { if cs.world == nil || dir == "" { return 0 } path, ok := cs.world.GetRoomPath(roomID) if !ok { return 0 } data, err := os.ReadFile(path) if err != nil { return 0 } var m map[string]any if err := yaml.Unmarshal(data, &m); err != nil { return 0 } exits, _ := m["exits"].(map[string]any) if exits == nil { return 0 } v, ok := exits[dir] if !ok { return 0 } switch x := v.(type) { case map[string]any: if r, ok := x["room"]; ok { switch y := r.(type) { case int: return y case int64: return int(y) case float64: return int(y) } } } return 0 } 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 } else if obs.ExitDir != "" { resolved := cs.resolveExitTarget(obs.RoomID, obs.ExitDir) if resolved != 0 { nextRoom = resolved } else { log.Printf("[WARNING] Course %q: last obstacle room %d has exit_dir=%q but no exit in that direction; falling back to start_room %d", cfg.ID, obs.RoomID, obs.ExitDir, cfg.StartRoom) nextRoom = cfg.StartRoom } } else { nextRoom = cfg.StartRoom } completionXP := 0 if i == totalObstacles-1 { completionXP = cfg.CompletionXP } onFailRoom := obs.OnFailRoom if onFailRoom == 0 { onFailRoom = cfg.StartRoom } 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, OnFailRoom: onFailRoom, RequiredLevel: cfg.RequiredLevel, ExitDir: obs.ExitDir, } cs.roomToObstacle[obs.RoomID] = info localVerbs[obs.Verb] = true } return nil }) obstacleVerbs = localVerbs }