aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/color/color.go19
-rw-r--r--internal/color/color_test.go90
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/game/act_talk.go12
-rw-r--r--internal/game/cmd_color.go1
-rw-r--r--internal/game/map_test.go27
-rw-r--r--internal/game/render_map.go44
7 files changed, 171 insertions, 23 deletions
diff --git a/internal/color/color.go b/internal/color/color.go
index 5a1aa54..92535d1 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -521,3 +521,22 @@ func ExpandTagsDefault(mode string, def ColorSpec, text string) string {
return sb.String()
}
+
+func ExpandDialogTags(mode string, dialogSpec ColorSpec, text string) string {
+ parts := strings.Split(text, `"`)
+ var sb strings.Builder
+ for i, part := range parts {
+ if i%2 == 0 {
+ if part != "" {
+ sb.WriteString(ExpandTagsDefault(mode, NoColor(), part))
+ }
+ } else {
+ if i < len(parts)-1 {
+ sb.WriteString(ExpandTagsDefault(mode, dialogSpec, `"`+part+`"`))
+ } else {
+ sb.WriteString(ExpandTagsDefault(mode, dialogSpec, `"`+part))
+ }
+ }
+ }
+ return sb.String()
+}
diff --git a/internal/color/color_test.go b/internal/color/color_test.go
index bffc0d7..f6b79f0 100644
--- a/internal/color/color_test.go
+++ b/internal/color/color_test.go
@@ -117,3 +117,93 @@ func TestAverage(t *testing.T) {
t.Errorf("Average(none, none).Fg = %d, want -1", got.Fg)
}
}
+
+func TestExpandDialogTags_NoQuotes(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, "The Netrunner looks up.")
+ if strings.Contains(got, "\033") {
+ t.Errorf("text without quotes should not contain ANSI codes, got %q", got)
+ }
+}
+
+func TestExpandDialogTags_AllQuoted(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, `"Hello there."`)
+ expectFg := FgCode("xterm256", 208)
+ if !strings.Contains(got, expectFg+`"Hello there."`+Reset) && !strings.Contains(got, `"`+expectFg+`Hello there."`+Reset) {
+ t.Errorf("fully quoted text should be dialog-colored, got %q", got)
+ }
+ if !strings.Contains(got, `"`) {
+ t.Error("quote characters should be present")
+ }
+}
+
+func TestExpandDialogTags_Mixed(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, `He says, "Hello, world!"`)
+ expectFg := FgCode("xterm256", 208)
+ if !strings.Contains(got, `He says, `) {
+ t.Errorf("narrative text missing, got %q", got)
+ }
+ if !strings.Contains(got, expectFg+`"Hello, world!"`+Reset) {
+ t.Errorf("quoted text should be dialog-colored, got %q", got)
+ }
+}
+
+func TestExpandDialogTags_InlineTagsInQuotes(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, `"Please {0B bold}look{/} carefully."`)
+ inlineFg := FgCode("xterm256", 11)
+ if !strings.Contains(got, inlineFg+`look`+Reset) {
+ t.Errorf("inline tag inside quotes should override dialog color, got %q", got)
+ }
+}
+
+func TestExpandDialogTags_InlineTagsOutsideQuotes(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, `{0B bold}Notice:{/} "Something moved."`)
+ inlineFg := FgCode("xterm256", 11)
+ if !strings.Contains(got, inlineFg+`Notice:`+Reset) {
+ t.Errorf("inline tag outside quotes should work, got %q", got)
+ }
+}
+
+func TestExpandDialogTags_MultipleQuotes(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, `"Hello," she said. "How are you?"`)
+ count := strings.Count(got, FgCode("xterm256", 208))
+ if count < 2 {
+ t.Errorf("expected at least 2 dialog-colored segments, got %d in %q", count, got)
+ }
+}
+
+func TestExpandDialogTags_UnmatchedQuote(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, `"Something started but never finished`)
+ expectFg := FgCode("xterm256", 208)
+ if !strings.Contains(got, expectFg) {
+ t.Errorf("unmatched quote should still be dialog-colored, got %q", got)
+ }
+ if !strings.Contains(got, `"Something started but never finished`) {
+ t.Errorf("unmatched quote text should be present, got %q", got)
+ }
+}
+
+func TestExpandDialogTags_Empty(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("xterm256", dialogSpec, "")
+ if got != "" {
+ t.Errorf("empty input should yield empty output, got %q", got)
+ }
+}
+
+func TestExpandDialogTags_NoColorMode(t *testing.T) {
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTags("none", dialogSpec, `"Hello."`)
+ if strings.Contains(got, "\033") {
+ t.Errorf("no-color mode should suppress ANSI codes, got %q", got)
+ }
+ if got != `"Hello."` {
+ t.Errorf("no-color mode should preserve text, got %q", got)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 6393816..c8d8830 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -44,6 +44,7 @@ func DefaultColors() ColorsConfig {
"say": "E6",
"global": "2D",
"dialog": "75",
+ "dialog_options": "bold",
"broadcast": "D7",
"sequence": "off",
"fire": "D0",
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index c87e3c5..bd5a091 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -106,18 +106,18 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
td.SeqIndex = 0
msg := node.Sequence[0]
if msg != "" {
- sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
td.SeqIndex = 1
if td.SeqIndex < len(node.Sequence) {
sess.State = net.StateTalkSequence
- sess.Write("[enter to continue]\n")
+ sess.Write(g.colorize(sess, "dialog_options", "[enter to continue]\n"))
return
}
} else {
msg := node.Message.Pick()
if msg != "" {
- sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
if node.Action != nil {
if g.handleNodeAction(sess, node.Action) {
@@ -128,7 +128,7 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
visible := g.visibleOptions(sess, node.Options)
if len(visible) > 0 {
for i, opt := range visible {
- sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, color.ExpandDialogTags(g.colorMode(sess), g.resolveColor(sess, "dialog_options"), opt.Text)))
}
sess.State = net.StateTalk
g.reprompt(sess)
@@ -168,7 +168,7 @@ func (g *Game) finishSequence(sess *net.Session, node behavior.TalkNode) {
visible := g.visibleOptions(sess, node.Options)
if len(visible) > 0 {
for i, opt := range visible {
- sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, color.ExpandDialogTags(g.colorMode(sess), g.resolveColor(sess, "dialog_options"), opt.Text)))
}
sess.State = net.StateTalk
g.reprompt(sess)
@@ -304,7 +304,7 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
if td.SeqIndex < len(node.Sequence) {
msg := node.Sequence[td.SeqIndex]
if msg != "" {
- sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
}
td.SeqIndex++
}
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 00b4ce8..fd9fa55 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -157,6 +157,7 @@ var colorCategoryOrder = []string{
"say",
"global",
"dialog",
+ "dialog_options",
"broadcast",
"fire",
"eat_food",
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index 4869d38..0d529cf 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -26,15 +26,17 @@ func writeTempRoom(t *testing.T, dir string, id int, body string) {
}
// TestMapConnectorGlyphs verifies the directional link rendering: bidirectional
-// links draw a bar, one-way (or one-side-blocked) links draw a directional
-// arrow, and links with no traversable direction draw a blocked 'X'.
+// links draw a bar, one-way links draw a directional arrow, outward-blocked
+// links draw a blocked 'X', and links with no traversable direction draw 'X'.
func TestMapConnectorGlyphs(t *testing.T) {
const condEast = "name: One\nexits:\n east:\n room: 2\n condition:\n flag: gate_open\n"
+ const condSouth = "name: Three\nexits:\n south:\n room: 2\n condition:\n flag: gate_open\n"
cases := []struct {
name string
room1 string
room2 string
+ room3 string
flagOpen bool
wantPresent string
wantAbsent []string
@@ -54,12 +56,12 @@ func TestMapConnectorGlyphs(t *testing.T) {
wantAbsent: []string{"X", "-", "<"},
},
{
- name: "forward blocked, reverse open -> arrow not X",
+ name: "forward blocked, reverse open -> X (shortest path is blocked)",
room1: condEast,
room2: "name: Two\nexits:\n west: 1\n",
flagOpen: false,
- wantPresent: "<",
- wantAbsent: []string{"X", "-", ">"},
+ wantPresent: "X",
+ wantAbsent: []string{"-", "<", ">"},
},
{
name: "forward blocked, reverse absent -> X",
@@ -77,6 +79,18 @@ func TestMapConnectorGlyphs(t *testing.T) {
wantPresent: "-",
wantAbsent: []string{"X", "<", ">"},
},
+ {
+ name: "outward open, inward blocked -> outward arrow (dist rules)",
+ room1: "name: One\nexits:\n east: 2\n",
+ room2: "name: Two\nexits:\n west: 1\n north: 3\n",
+ room3: condSouth,
+ flagOpen: false,
+ // dist[room1]=0, dist[room2]=1, dist[room3]=2.
+ // Link 2↔3: near=2 (dist1), far=3 (dist2).
+ // out = 2→north→3 = open → '^'
+ wantPresent: "^",
+ wantAbsent: []string{"X", "v", "<", ">"},
+ },
}
for _, tc := range cases {
@@ -84,6 +98,9 @@ func TestMapConnectorGlyphs(t *testing.T) {
dir := t.TempDir()
writeTempRoom(t, dir, 1, tc.room1)
writeTempRoom(t, dir, 2, tc.room2)
+ if tc.room3 != "" {
+ writeTempRoom(t, dir, 3, tc.room3)
+ }
g := &Game{Deps: Deps{World: world.New(dir)}, Flags: NewFlagStore()}
if tc.flagOpen {
diff --git a/internal/game/render_map.go b/internal/game/render_map.go
index 58e0613..8790a1e 100644
--- a/internal/game/render_map.go
+++ b/internal/game/render_map.go
@@ -42,6 +42,7 @@ type mapCell struct {
type mapGraph struct {
posToRoom map[[2]int]int
roomToPos map[int][2]int
+ dist map[int]int
}
var bfsDirs = []struct {
@@ -58,6 +59,7 @@ func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph {
mg := &mapGraph{
posToRoom: make(map[[2]int]int),
roomToPos: make(map[int][2]int),
+ dist: make(map[int]int),
}
type node struct {
@@ -67,6 +69,7 @@ func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph {
queue := []node{{startRoomID, 0, 0}}
mg.posToRoom[[2]int{0, 0}] = startRoomID
mg.roomToPos[startRoomID] = [2]int{0, 0}
+ mg.dist[startRoomID] = 0
for len(queue) > 0 {
n := queue[0]
@@ -92,6 +95,7 @@ func buildGraph(g *Game, startRoomID int, visited map[int]bool) *mapGraph {
nx, ny := n.x+d.dx, n.y+d.dy
mg.posToRoom[[2]int{nx, ny}] = targetID
mg.roomToPos[targetID] = [2]int{nx, ny}
+ mg.dist[targetID] = mg.dist[n.roomID] + 1
queue = append(queue, node{targetID, nx, ny})
}
}
@@ -350,9 +354,16 @@ func (c *mapRenderCtx) nodeSpec(roomID int) color.ColorSpec {
// the per-direction traversability of the two exits joining them. ok is false
// when there is no link at all, so the caller draws nothing:
// - both directions open -> bidirectional bar (- / |)
-// - exactly one open -> arrow pointing along the open direction
-// - >=1 exists but none open -> blocked 'X'
-// - neither exit exists -> ok == false (no cell)
+// - outward direction open -> arrow pointing outward
+// - outward direction blocked -> blocked 'X'
+// - only inward direction open -> arrow pointing inward
+// - none traversable -> blocked 'X'
+// - neither exit exists -> ok == false (no cell)
+//
+// "Outward" is the exit from the room nearer the player (smaller BFS distance)
+// toward the farther one — i.e. the link as reached along the shortest path.
+// Since each BFS hop is a unit grid step, grid-adjacent rooms always differ in
+// distance parity, so there is never a tie to break.
//
// Bars and arrows use the normal link coloring (dim if an endpoint is unvisited,
// otherwise the gradient average); only 'X' uses the blocked color.
@@ -360,23 +371,32 @@ func (c *mapRenderCtx) connectorCell(roomA, roomB int, dirAB, dirBA world.ExitDi
fwd := exitStateTo(c.g, c.sess, roomA, dirAB, roomB) // A -> B
bwd := exitStateTo(c.g, c.sess, roomB, dirBA, roomA) // B -> A
- if fwd == exitAbsent && bwd == exitAbsent {
+ switch {
+ case fwd == exitAbsent && bwd == exitAbsent:
return mapCell{}, false
+ case fwd == exitOpen && bwd == exitOpen:
+ return c.coloredCell(roomA, roomB, barGlyph(c.mg, dirAB))
+ }
+
+ // Orient the link outward, from near room to far room.
+ outDir, inDir, out, in := dirAB, dirBA, fwd, bwd
+ if c.bg.dist[roomB] < c.bg.dist[roomA] {
+ outDir, inDir, out, in = dirBA, dirAB, bwd, fwd
}
- var glyph rune
switch {
- case fwd == exitOpen && bwd == exitOpen:
- glyph = barGlyph(c.mg, dirAB)
- case fwd == exitOpen:
- glyph = arrowGlyph(c.mg, dirAB)
- case bwd == exitOpen:
- glyph = arrowGlyph(c.mg, dirBA)
+ case out == exitOpen:
+ return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, outDir))
+ case out == exitAbsent && in == exitOpen:
+ return c.coloredCell(roomA, roomB, arrowGlyph(c.mg, inDir))
default:
- // At least one exit exists but none are currently traversable.
return mapCell{char: 'X', spec: c.blockedSpec}, 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) {
if c.visited != nil && (!c.visited[roomA] || !c.visited[roomB]) {
return mapCell{char: glyph, spec: c.dimSpec}, true
}