From 74803657d587b1f93b739c1ee25bb8d62df95413 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Tue, 7 Jul 2026 21:22:39 -0400 Subject: fix: agility course creation fixed and improved in admin web gui --- internal/game/act_agility.go | 104 ++++++++++++++++++++++---------------- internal/game/core_course.go | 75 +++++++++++++++++++++++++++ internal/game/core_course_test.go | 4 +- internal/game/game.go | 1 + 4 files changed, 138 insertions(+), 46 deletions(-) (limited to 'internal/game') diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index 18dbf4a..a243b44 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -44,6 +44,7 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle TotalObstacles: info.TotalObstacles, NextRoom: info.NextRoom, StartRoom: info.StartRoom, + OnFailRoom: info.OnFailRoom, ObstacleXP: info.ObstacleXP, CompletionXP: info.CompletionXP, FailChance: failPtr, @@ -62,6 +63,11 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { phase := d.Phase phases := d.Phases + if len(phases) == 0 { + g.completeObstacle(sess, p, d) + return + } + if phase >= len(phases) { g.cancelAction(p) return @@ -84,58 +90,61 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { nextPhase := phase + 1 if nextPhase >= len(phases) { - obstacleXP := d.ObstacleXP - completionXP := d.CompletionXP - nextRoom := d.NextRoom - startRoom := d.StartRoom - obstacleIndex := d.ObstacleIndex - totalObstacles := d.TotalObstacles - courseID := d.CourseID - - if obstacleXP > 0 { - g.awardSkillXP(sess, p, player.Agility, obstacleXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) - } + g.completeObstacle(sess, p, d) + return + } + + d.Phase = nextPhase + p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay) +} + +func (g *Game) completeObstacle(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { + obstacleXP := d.ObstacleXP + completionXP := d.CompletionXP + nextRoom := d.NextRoom + obstacleIndex := d.ObstacleIndex + totalObstacles := d.TotalObstacles + courseID := d.CourseID + + isLastObstacle := obstacleIndex == totalObstacles-1 + + if obstacleXP > 0 { + g.awardSkillXP(sess, p, player.Agility, obstacleXP) + if !isLastObstacle && p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) } + } - isLastObstacle := obstacleIndex == totalObstacles-1 - - if isLastObstacle { - if completionXP > 0 { - g.awardSkillXP(sess, p, player.Agility, completionXP) - if p.OptionBool("xp_drops") { - sess.WriteLine(g.colorize(sess, "xp", - fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) - } - } - - lapKey := "agility_laps_" + courseID - laps := getPlayerFlagInt(p, lapKey) + 1 - g.setPlayerFlag(p, lapKey, laps) - - courseName := p.Action.TargetName - sess.WriteLine(g.colorize(sess, "broadcast", - fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) - - g.cancelAction(p) - g.teleportPlayer(sess, p, startRoom) - } else { - g.cancelAction(p) - g.teleportPlayer(sess, p, nextRoom) + if isLastObstacle { + if completionXP > 0 { + g.awardSkillXP(sess, p, player.Agility, completionXP) } - g.AccountStore.SaveCharacter(p) - return + lapKey := "agility_laps_" + courseID + laps := getPlayerFlagInt(p, lapKey) + 1 + g.setPlayerFlag(p, lapKey, laps) + + courseName := p.Action.TargetName + msg := fmt.Sprintf("You've completed %d lap%s of %s", laps, pluralLap(laps), courseName) + combinedXP := obstacleXP + completionXP + if p.OptionBool("xp_drops") && combinedXP > 0 { + msg += fmt.Sprintf(" (+%d%s)", combinedXP, player.SkillAbbr[player.Agility]) + } + sess.WriteLine(g.colorize(sess, "broadcast", msg)) + + g.cancelAction(p) + g.teleportPlayer(sess, p, nextRoom) + } else { + g.cancelAction(p) + g.teleportPlayer(sess, p, nextRoom) } - d.Phase = nextPhase - p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay) + g.AccountStore.SaveCharacter(p) } func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { - startRoom := d.StartRoom + onFailRoom := d.OnFailRoom failMin := d.FailDamageMin failMax := d.FailDamageMax @@ -155,7 +164,14 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs g.AccountStore.SaveCharacter(p) g.cancelAction(p) - g.teleportPlayer(sess, p, startRoom) + g.teleportPlayer(sess, p, onFailRoom) +} + +func pluralLap(n int) string { + if n == 1 { + return "" + } + return "s" } // calcFailChance returns the failure probability for an obstacle attempt. diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 54894fd..031eff5 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -1,11 +1,14 @@ 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. @@ -31,6 +34,8 @@ type ObstacleDef struct { TicksPerPhase float64 `yaml:"ticks_per_phase"` Messages []string `yaml:"messages"` Phases []ObstaclePhase `yaml:"phases"` + ExitDir string `yaml:"exit_dir"` + OnFailRoom int `yaml:"on_fail,omitempty"` } type CourseConfig struct { @@ -62,7 +67,9 @@ type ObstacleInfo struct { FailDamage [2]int NextRoom int StartRoom int + OnFailRoom int RequiredLevel int + ExitDir string } var obstacleVerbs = map[string]bool{} @@ -85,6 +92,7 @@ type CourseStore struct { courses map[string]*CourseConfig roomToObstacle map[int]*ObstacleInfo loaded bool + world *world.World } func NewCourseStore(dataDir string) *CourseStore { @@ -94,6 +102,10 @@ func NewCourseStore(dataDir string) *CourseStore { } } +func (cs *CourseStore) SetWorld(w *world.World) { + cs.world = w +} + func (cs *CourseStore) LoadAll() { cs.mu.Lock() defer cs.mu.Unlock() @@ -160,6 +172,52 @@ func resolvePhases(obs ObstacleDef) []PhaseInfo { 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 int: + return x + case int64: + return int(x) + case float64: + return int(x) + 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) @@ -178,6 +236,16 @@ func (cs *CourseStore) loadAllLocked() { 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 @@ -185,6 +253,11 @@ func (cs *CourseStore) loadAllLocked() { completionXP = cfg.CompletionXP } + onFailRoom := obs.OnFailRoom + if onFailRoom == 0 { + onFailRoom = cfg.StartRoom + } + info := &ObstacleInfo{ CourseID: cfg.ID, CourseName: cfg.Name, @@ -198,7 +271,9 @@ func (cs *CourseStore) loadAllLocked() { 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 diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go index 3d9fdc2..64ec8ad 100644 --- a/internal/game/core_course_test.go +++ b/internal/game/core_course_test.go @@ -130,8 +130,8 @@ obstacles: if o2.CompletionXP != 50 { t.Errorf("last obstacle should carry completion_xp, got %d", o2.CompletionXP) } - if o2.NextRoom != 0 { - t.Errorf("last obstacle should have next_room 0, got %d", o2.NextRoom) + if o2.NextRoom != 100 { + t.Errorf("last obstacle with no exit_dir should fall back to start_room 100, got %d", o2.NextRoom) } if cs.GetObstacle(999) != nil { diff --git a/internal/game/game.go b/internal/game/game.go index b9fb0af..f34e649 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -101,6 +101,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali hackingStates: make(map[string]*hacking.Session), enterSeqs: make(map[string]*enterSeq), } + g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() g.LoadTechs() -- cgit v1.2.3