aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-07-05 22:35:21 -0400
committerhistoria <[not public]>2026-07-05 22:35:21 -0400
commit94823b52168b44894fb5e9c960358ed02ebb122b (patch)
tree3060799b89bb7edfa708c72bdd981107d40f4d09 /internal/game
parent843fa6db681e494bf46e191655323a40577542b5 (diff)
downloadthehouseoficarus-94823b52168b44894fb5e9c960358ed02ebb122b.tar.gz
feat: courses gui overhaul, add course tab to map, add hidden exits to give courses structure
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_agility.go66
-rw-r--r--internal/game/cmd_aps.go6
-rw-r--r--internal/game/cmd_color.go1
-rw-r--r--internal/game/cmd_move.go5
-rw-r--r--internal/game/cmd_walk.go7
-rw-r--r--internal/game/core_course.go78
-rw-r--r--internal/game/core_course_test.go140
-rw-r--r--internal/game/look_room.go56
-rw-r--r--internal/game/render_map.go47
9 files changed, 343 insertions, 63 deletions
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
}