From 94823b52168b44894fb5e9c960358ed02ebb122b Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 5 Jul 2026 22:35:21 -0400 Subject: feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure --- internal/game/act_agility.go | 66 ++++++++++++------ internal/game/cmd_aps.go | 6 +- internal/game/cmd_color.go | 1 + internal/game/cmd_move.go | 5 ++ internal/game/cmd_walk.go | 7 +- internal/game/core_course.go | 78 ++++++++++++++++++--- internal/game/core_course_test.go | 140 ++++++++++++++++++++++++++++++++++++++ internal/game/look_room.go | 56 ++++++++------- internal/game/render_map.go | 47 ++++++++++++- 9 files changed, 343 insertions(+), 63 deletions(-) create mode 100644 internal/game/core_course_test.go (limited to 'internal/game') diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go index d66b9c5..18dbf4a 100644 --- a/internal/game/act_agility.go +++ b/internal/game/act_agility.go @@ -12,15 +12,24 @@ import ( func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { failChance := g.calcFailChance(p, info) - - msgs := make([]any, len(info.Messages)) - for i, m := range info.Messages { - msgs[i] = m + var failPtr *float64 + if failChance != nil { + v := *failChance + failPtr = &v } gerund := info.Verb + "ing" - if g, ok := verbGerund[info.Verb]; ok { - gerund = g + if gr, ok := verbGerund[info.Verb]; ok { + gerund = gr + } + + phases := make([]behavior.PhaseData, len(info.Phases)) + for i, ph := range info.Phases { + phases[i] = behavior.PhaseData{ + Message: ph.Message, + Delay: ph.Delay, + FailCheck: ph.FailCheck, + } } p.Action = &behavior.Action{ @@ -37,11 +46,10 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle StartRoom: info.StartRoom, ObstacleXP: info.ObstacleXP, CompletionXP: info.CompletionXP, - FailChance: failChance, + FailChance: failPtr, FailDamageMin: info.FailDamage[0], FailDamageMax: info.FailDamage[1], - Messages: msgs, - TicksPerPhase: info.TicksPerPhase, + Phases: phases, RequiredLevel: info.RequiredLevel, }, } @@ -52,19 +60,21 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { d := p.Action.Data.(*behavior.ObstacleData) phase := d.Phase - messages := d.Messages - ticksPerPhase := d.TicksPerPhase + phases := d.Phases - if phase >= len(messages) { + if phase >= len(phases) { g.cancelAction(p) return } - msg := messages[phase].(string) - sess.WriteLine(g.colorize(sess, "broadcast", msg)) + ph := phases[phase] + sess.WriteLine(g.colorize(sess, "broadcast", ph.Message)) - if phase == 1 { - failChance := d.FailChance + if ph.FailCheck { + failChance := 0.30 + if d.FailChance != nil { + failChance = *d.FailChance + } if rand.Float64() < failChance { g.obstacleFail(sess, p, d) return @@ -73,7 +83,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { nextPhase := phase + 1 - if nextPhase >= len(messages) { + if nextPhase >= len(phases) { obstacleXP := d.ObstacleXP completionXP := d.CompletionXP nextRoom := d.NextRoom @@ -121,7 +131,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { } d.Phase = nextPhase - p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) + p.Action.WaitLeft = engine.ToTicks(phases[nextPhase].Delay) } func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.ObstacleData) { @@ -148,7 +158,21 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, d *behavior.Obs g.teleportPlayer(sess, p, startRoom) } -func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { +// calcFailChance returns the failure probability for an obstacle attempt. +// If the obstacle defines an explicit fail_chance it is used; otherwise the +// chance is derived from the player's Agility level relative to the course's +// required level (70% success at required, scaling to 95% above, capped). +func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) *float64 { + if info.FailChance != nil { + v := *info.FailChance + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + return &v + } level := p.Level(player.Agility) required := info.RequiredLevel chance := 0.30 - float64(level-required)*0.01 @@ -158,5 +182,5 @@ func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { if chance > 0.60 { chance = 0.60 } - return chance -} + return &chance +} \ No newline at end of file diff --git a/internal/game/cmd_aps.go b/internal/game/cmd_aps.go index a5cff78..f48c2bb 100644 --- a/internal/game/cmd_aps.go +++ b/internal/game/cmd_aps.go @@ -41,7 +41,7 @@ func (g *Game) executeAps(sess *net.Session, args []string, rawInput string) { return } - path := g.findPathToRoom(p.RoomID, targetID) + path := g.findPathToRoom(p.RoomID, targetID, p.GodMode) if path == nil { room, err := g.World.LoadRoom(targetID) roomName := fmt.Sprintf("#%d", targetID) @@ -112,7 +112,7 @@ func displayApsNodes(g *Game, sess *net.Session, p *player.Player, known []int) } dist := 0 if id != p.RoomID { - path := g.findPathToRoom(p.RoomID, id) + path := g.findPathToRoom(p.RoomID, id, p.GodMode) if path != nil { dist = len(path) } @@ -163,7 +163,7 @@ func resolveApsTarget(g *Game, sess *net.Session, p *player.Player, known []int, if id == p.RoomID { return id } - path := g.findPathToRoom(p.RoomID, id) + path := g.findPathToRoom(p.RoomID, id, p.GodMode) if path == nil { continue } diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index a2ec2c4..d48e314 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -167,6 +167,7 @@ var colorCategoryOrder = []string{ "map_at", "map_blocked", + "map_course", "safespot_alert", } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index f12c494..6063160 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -31,6 +31,11 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { return } + if exitDef.Hidden && !p.GodMode { + sess.WriteLine("You can't go that way.") + return + } + if !p.GodMode && exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { msg := exitDef.BlockedMessage if msg == "" { diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index ec62eec..9bc7183 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -23,7 +23,7 @@ func (g *Game) doWalk(sess *net.Session, args []string) { input := strings.Join(args, "") if roomID, err := strconv.Atoi(input); err == nil { - path := g.findPathToRoom(p.RoomID, roomID) + path := g.findPathToRoom(p.RoomID, roomID, p.GodMode) if path == nil { sess.WriteLine(fmt.Sprintf("No path found to room #%d.", roomID)) return @@ -188,7 +188,7 @@ var walkSearchDirs = []world.ExitDir{ world.Up, world.Down, } -func (g *Game) findPathToRoom(fromRoom, toRoom int) []string { +func (g *Game) findPathToRoom(fromRoom, toRoom int, godMode bool) []string { if fromRoom == toRoom { return nil } @@ -216,6 +216,9 @@ func (g *Game) findPathToRoom(fromRoom, toRoom int) []string { if !exists { continue } + if exitDef.Hidden && !godMode { + continue + } if visited[exitDef.Room] { continue } diff --git a/internal/game/core_course.go b/internal/game/core_course.go index 5d5c7c5..54894fd 100644 --- a/internal/game/core_course.go +++ b/internal/game/core_course.go @@ -8,13 +8,29 @@ import ( "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"` - TicksPerPhase float64 `yaml:"ticks_per_phase"` - XP int `yaml:"xp"` - FailDamage [2]int `yaml:"fail_damage"` - Messages []string `yaml:"messages"` + 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 { @@ -26,16 +42,23 @@ type CourseConfig struct { 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 - Messages []string - TicksPerPhase float64 + Phases []PhaseInfo ObstacleXP int CompletionXP int + FailChance *float64 // nil => derived from agility level FailDamage [2]int NextRoom int StartRoom int @@ -104,6 +127,39 @@ func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { 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) @@ -135,10 +191,10 @@ func (cs *CourseStore) loadAllLocked() { ObstacleIndex: i, TotalObstacles: totalObstacles, Verb: obs.Verb, - Messages: obs.Messages, - TicksPerPhase: obs.TicksPerPhase, + Phases: resolvePhases(obs), ObstacleXP: obs.XP, CompletionXP: completionXP, + FailChance: obs.FailChance, FailDamage: obs.FailDamage, NextRoom: nextRoom, StartRoom: cfg.StartRoom, @@ -151,4 +207,4 @@ func (cs *CourseStore) loadAllLocked() { }) obstacleVerbs = localVerbs -} +} \ No newline at end of file diff --git a/internal/game/core_course_test.go b/internal/game/core_course_test.go new file mode 100644 index 0000000..3d9fdc2 --- /dev/null +++ b/internal/game/core_course_test.go @@ -0,0 +1,140 @@ +package game + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolvePhasesPrefersExplicitPhases(t *testing.T) { + obs := ObstacleDef{ + RoomID: 5, + Verb: "climb", + Phases: []ObstaclePhase{ + {Message: "start", Delay: 0}, + {Message: "middle", Delay: 2, FailCheck: true}, + {Message: "end", Delay: 1.5}, + }, + // Legacy fields set as a decoy; should be ignored when Phases present. + TicksPerPhase: 99, + Messages: []string{"ignored1", "ignored2", "ignored3"}, + } + got := resolvePhases(obs) + if len(got) != 3 { + t.Fatalf("expected 3 phases, got %d", len(got)) + } + if got[0].Message != "start" || got[0].Delay != 0 || got[0].FailCheck { + t.Errorf("phase 0 wrong: %+v", got[0]) + } + if got[1].Message != "middle" || got[1].Delay != 2 || !got[1].FailCheck { + t.Errorf("phase 1 (fail check) wrong: %+v", got[1]) + } + if got[2].Message != "end" || got[2].Delay != 1.5 { + t.Errorf("phase 2 wrong: %+v", got[2]) + } +} + +func TestResolvePhasesLegacyMigration(t *testing.T) { + obs := ObstacleDef{ + RoomID: 7, + Verb: "jump", + TicksPerPhase: 2, + Messages: []string{"a", "b", "c"}, + } + got := resolvePhases(obs) + if len(got) != 3 { + t.Fatalf("expected 3 legacy phases, got %d", len(got)) + } + if got[0].Message != "a" || got[0].Delay != 0 || got[0].FailCheck { + t.Errorf("legacy phase 0 wrong: %+v", got[0]) + } + if got[1].Message != "b" || got[1].Delay != 2 || !got[1].FailCheck { + t.Errorf("legacy phase 1 must carry fail_check at index 1: %+v", got[1]) + } + if got[2].Message != "c" || got[2].Delay != 2 || got[2].FailCheck { + t.Errorf("legacy phase 2 wrong: %+v", got[2]) + } +} + +func TestCourseStoreLoadsWithExplicitFailChance(t *testing.T) { + dir := t.TempDir() + coursesDir := filepath.Join(dir, "courses") + if err := os.MkdirAll(coursesDir, 0755); err != nil { + t.Fatal(err) + } + yaml := []byte(` +name: "Test Course" +required_level: 10 +start_room: 100 +completion_xp: 50 +obstacles: + - room_id: 101 + verb: climb + xp: 12 + fail_damage: [2, 5] + fail_chance: 0.20 + phases: + - message: "begin" + delay: 0 + - message: "mid" + delay: 2 + fail_check: true + - message: "done" + delay: 2 + - room_id: 102 + verb: jump + xp: 14 + fail_damage: [1, 3] + messages: ["p0", "p1", "p2"] + ticks_per_phase: 1 +`) + if err := os.WriteFile(filepath.Join(coursesDir, "testcourse.yaml"), yaml, 0644); err != nil { + t.Fatal(err) + } + + cs := NewCourseStore(dir) + cs.LoadAll() + + // Obstacle 1: explicit phases + explicit fail_chance + o1 := cs.GetObstacle(101) + if o1 == nil { + t.Fatal("expected obstacle for room 101") + } + if len(o1.Phases) != 3 || o1.Phases[1].FailCheck != true { + t.Errorf("obstacle 1 phases wrong: %+v", o1.Phases) + } + if o1.FailChance == nil || *o1.FailChance != 0.20 { + t.Errorf("expected explicit fail_chance 0.20, got %v", o1.FailChance) + } + if o1.ObstacleIndex != 0 || o1.TotalObstacles != 2 { + t.Errorf("index/total wrong: %d/%d", o1.ObstacleIndex, o1.TotalObstacles) + } + if o1.NextRoom != 102 { + t.Errorf("expected next room 102, got %d", o1.NextRoom) + } + if o1.CompletionXP != 0 { + t.Errorf("first obstacle should have no completion xp, got %d", o1.CompletionXP) + } + + // Obstacle 2: legacy migration, derived fail chance (nil) + o2 := cs.GetObstacle(102) + if o2 == nil { + t.Fatal("expected obstacle for room 102") + } + if len(o2.Phases) != 3 || !o2.Phases[1].FailCheck { + t.Errorf("legacy obstacle phases not migrated: %+v", o2.Phases) + } + if o2.FailChance != nil { + t.Errorf("legacy obstacle should have nil (derived) fail_chance, got %v", *o2.FailChance) + } + 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 cs.GetObstacle(999) != nil { + t.Error("expected nil for unrelated room") + } +} \ No newline at end of file diff --git a/internal/game/look_room.go b/internal/game/look_room.go index f35d564..887bb1d 100644 --- a/internal/game/look_room.go +++ b/internal/game/look_room.go @@ -83,24 +83,30 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro } var lines []exitLine maxDirLen := 0 - for _, dir := range world.ExitOrder { - exitDef, ok := room.Exits[dir] - if !ok { - continue - } - coloredDir := g.colorize(sess, "exit_direction", string(dir)) - targetRoom, err := g.World.LoadRoom(exitDef.Room) - targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) - if err == nil { - targetName = g.colorize(sess, "exit_name", targetRoom.Name) - } - if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { - targetName += " (blocked)" - } - lines = append(lines, exitLine{coloredDir, targetName}) - if visibleLen(coloredDir) > maxDirLen { - maxDirLen = visibleLen(coloredDir) - } + for _, dir := range world.ExitOrder { + exitDef, ok := room.Exits[dir] + if !ok { + continue + } + if exitDef.Hidden && !p.GodMode { + continue + } + coloredDir := g.colorize(sess, "exit_direction", string(dir)) + targetRoom, err := g.World.LoadRoom(exitDef.Room) + targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) + if err == nil { + targetName = g.colorize(sess, "exit_name", targetRoom.Name) + } + if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { + targetName += " (blocked)" + } + if exitDef.Hidden { + targetName += " (hidden)" + } + lines = append(lines, exitLine{coloredDir, targetName}) + if visibleLen(coloredDir) > maxDirLen { + maxDirLen = visibleLen(coloredDir) + } } for _, l := range lines { pad := maxDirLen + (len(l.dir) - visibleLen(l.dir)) @@ -110,13 +116,15 @@ func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Ro sess.Write("Exits: ") first := true for _, dir := range world.ExitOrder { - if _, ok := room.Exits[dir]; ok { - if !first { - sess.Write(", ") - } - sess.Write(g.colorize(sess, "exit_direction", string(dir))) - first = false + exitDef, ok := room.Exits[dir] + if !ok || (exitDef.Hidden && !p.GodMode) { + continue + } + if !first { + sess.Write(", ") } + sess.Write(g.colorize(sess, "exit_direction", string(dir))) + first = false } sess.WriteLine("") } diff --git a/internal/game/render_map.go b/internal/game/render_map.go index 179277a..1bc9adc 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -95,7 +95,7 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string dimSpec := resolveDim(g, sess) ctx := &mapRenderCtx{ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, - atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), courseSpec: resolveMapCourse(g, sess), mg: mg, diagPairs: make(map[[2]int][2]int), } @@ -268,7 +268,7 @@ func buildFullMap(g *Game, sess *net.Session, roomID, mapWidth, mapHeight int, m dimSpec := resolveDim(g, sess) ctx := &mapRenderCtx{ g: g, sess: sess, bg: bg, visited: visited, currentRoom: roomID, - atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), mg: mg, + atSpec: atSpec, dimSpec: dimSpec, blockedSpec: resolveMapBlocked(g, sess), courseSpec: resolveMapCourse(g, sess), mg: mg, diagPairs: make(map[[2]int][2]int), } @@ -433,6 +433,13 @@ func resolveMapBlocked(g *Game, sess *net.Session) color.ColorSpec { return color.Parse("C4") } +func resolveMapCourse(g *Game, sess *net.Session) color.ColorSpec { + if sess != nil { + return g.resolveColor(sess, "map_course") + } + return color.Parse("1B") +} + // mapRenderCtx bundles the per-render state shared by node and connector drawing // so the tiny and full maps build cells the same way. type mapRenderCtx struct { @@ -444,6 +451,7 @@ type mapRenderCtx struct { atSpec color.ColorSpec dimSpec color.ColorSpec blockedSpec color.ColorSpec + courseSpec color.ColorSpec mg mapGlyphs diagPairs map[[2]int][2]int } @@ -497,14 +505,44 @@ func (c *mapRenderCtx) connectorCell(roomA, roomB int, dirAB, dirBA world.ExitDi switch { case out == exitOpen: + if c.isCourseLink(roomA, roomB) { + return c.courseColoredCell(roomA, roomB, arrowGlyph(c.mg, outDir)) + } return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, outDir)) case out == exitAbsent && in == exitOpen: + if c.isCourseLink(roomA, roomB) { + return c.courseColoredCell(roomA, roomB, arrowGlyph(c.mg, inDir)) + } return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, inDir)) default: return mapCell{char: 'X', spec: c.blockedSpec}, true } } +// isCourseLink reports whether roomA and roomB are consecutive obstacles in +// the same agility course (one's NextRoom equals the other's room id). +func (c *mapRenderCtx) isCourseLink(roomA, roomB int) bool { + if c.g == nil || c.g.CourseStore == nil { + return false + } + if info := c.g.CourseStore.GetObstacle(roomA); info != nil && info.NextRoom == roomB { + return true + } + if info := c.g.CourseStore.GetObstacle(roomB); info != nil && info.NextRoom == roomA { + return true + } + return false +} + +// courseColoredCell draws a one-way course arrow using the map_course color +// (dimmed if either endpoint is unvisited). +func (c *mapRenderCtx) courseColoredCell(roomA, roomB int, glyph rune) (mapCell, bool) { + if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) { + return mapCell{char: glyph, spec: c.dimSpec}, true + } + return mapCell{char: glyph, spec: c.courseSpec}, true +} + // coloredCell applies the shared link coloring logic for bar/arrow glyphs // (dim if either endpoint is unvisited, otherwise the gradient average). func (c *mapRenderCtx) coloredCell(roomA, roomB int, glyph rune) (mapCell, bool) { @@ -564,6 +602,11 @@ func exitStateTo(g *Game, sess *net.Session, from int, dir world.ExitDir, neighb if !ok || exit.Room != neighbor { return exitAbsent } + // Hidden exits are non-traversable to players but are followed by the map + // layout BFS, so they always render as traversable (open) connectors. + if exit.Hidden { + return exitOpen + } if exit.Condition == nil || sess == nil || sess.Player == nil { return exitOpen } -- cgit v1.2.3