aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-08-03 17:15:50 -0400
committerhistoria <[not public]>2026-08-03 17:15:50 -0400
commita2cbc04f86508708daa09affa5e9d73cc689b2ba (patch)
tree0d0c6c7fbf4a775faaf5e9dd700157b883882638 /internal
parentadda27dba5bf7bf1dee62159f0cbc0bd90734751 (diff)
downloadthehouseoficarus-a2cbc04f86508708daa09affa5e9d73cc689b2ba.tar.gz
feat: new color 'command', color tag system implemented
Diffstat (limited to 'internal')
-rw-r--r--internal/admin/api_colors.go4
-rw-r--r--internal/casino/baccarat.go8
-rw-r--r--internal/casino/baccarat_test.go3
-rw-r--r--internal/casino/blackjack.go25
-rw-r--r--internal/casino/blackjack_test.go15
-rw-r--r--internal/casino/machine.go10
-rw-r--r--internal/casino/machine_test.go2
-rw-r--r--internal/casino/types.go16
-rw-r--r--internal/color/color.go37
-rw-r--r--internal/color/color_test.go58
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/game/act_effects.go10
-rw-r--r--internal/game/act_gather.go3
-rw-r--r--internal/game/act_talk.go9
-rw-r--r--internal/game/casino.go54
-rw-r--r--internal/game/casino_test.go23
-rw-r--r--internal/game/cmd_color.go3
-rw-r--r--internal/game/look_entities.go2
-rw-r--r--internal/game/look_room.go3
-rw-r--r--internal/game/look_target.go11
-rw-r--r--internal/game/production_engine.go3
-rw-r--r--internal/game/render_color.go55
-rw-r--r--internal/game/render_help.go5
-rw-r--r--internal/game/render_prompt.go3
-rw-r--r--internal/game/sys_safespot.go9
25 files changed, 304 insertions, 68 deletions
diff --git a/internal/admin/api_colors.go b/internal/admin/api_colors.go
index 16fb00a..876656d 100644
--- a/internal/admin/api_colors.go
+++ b/internal/admin/api_colors.go
@@ -71,6 +71,7 @@ var playerColorLabels = map[string]string{
"broadcast": "Broadcast",
"dialog": "Dialog",
"dialog_options": "Dialog Options",
+ "command": "Command Text",
"drop_message": "Drop Message",
"eat_food": "Eat Food",
"fire": "Fire",
@@ -296,6 +297,9 @@ func buildPreview() []colorSection {
ln(seg(" 1. ", "dialog_options"), seg("I'm looking for work.", "dialog")),
ln(seg(" 2. ", "dialog_options"), seg("Just passing through.", "dialog")),
ln(seg("[enter to continue]", "dialog_options")),
+ txt(""),
+ ln(seg(" ", ""), seg("bet <amount>", "command"), seg(" Set your wager", "")),
+ ln(seg(" ", ""), seg("spin", "command"), seg(" Spin the reels", "")),
},
})
diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go
index 729851d..4261270 100644
--- a/internal/casino/baccarat.go
+++ b/internal/casino/baccarat.go
@@ -65,12 +65,16 @@ func (t *baccaratTable) seats() []*Participant {
func (t *baccaratTable) menuEvent(name string) Event {
e := t.private(name, t.menu())
e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: MenuBaccaratBet}
+ e.Menu = &MenuView{Kind: MenuBaccaratBet, Commands: []MenuCommand{
+ {Command: "bet <amount>", Desc: "Place a wager (min/max accepted)"},
+ {Command: "bet <amount> on player|banker|tie", Desc: "Wager on a specific outcome"},
+ {Command: "stop", Desc: "Leave the table"},
+ }}
return e
}
func (t *baccaratTable) menu() string {
- return "New hand! You can bet, bet <amount> on player|banker|tie (inc. min/max), or stop."
+ return "New baccarat hand! Place your bet."
}
func (t *baccaratTable) join(name string) []Event {
diff --git a/internal/casino/baccarat_test.go b/internal/casino/baccarat_test.go
index 6d3acb1..2cb932e 100644
--- a/internal/casino/baccarat_test.go
+++ b/internal/casino/baccarat_test.go
@@ -319,6 +319,9 @@ func TestBaccaratMenuAndActions(t *testing.T) {
if menu == nil || menu.Kind != MenuBaccaratBet {
t.Fatalf("join menu=%+v, want MenuBaccaratBet", menu)
}
+ if len(menu.Commands) != 3 || menu.Commands[0].Command != "bet <amount>" || menu.Commands[2].Command != "stop" {
+ t.Fatalf("baccarat menu commands=%+v", menu.Commands)
+ }
if events := table.action("alice", "hit"); !hasMessage(events, "no player actions") {
t.Fatalf("baccarat action not rejected: %+v", events)
}
diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go
index ff334c9..3c4dc09 100644
--- a/internal/casino/blackjack.go
+++ b/internal/casino/blackjack.go
@@ -93,7 +93,11 @@ func (t *blackjackTable) seats() []*Participant {
func (t *blackjackTable) menuEvent(name string) Event {
e := t.private(name, t.menu())
e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: MenuBlackjackBet}
+ e.Menu = &MenuView{Kind: MenuBlackjackBet, Commands: []MenuCommand{
+ {Command: "bet", Desc: "Place a wager (min/max accepted)"},
+ {Command: "bet <amount>", Desc: "Place a specific wager"},
+ {Command: "stop", Desc: "Leave the table"},
+ }}
return e
}
@@ -446,10 +450,25 @@ func (t *blackjackTable) prompt() []Event {
actions := t.availableActions(p, h)
e := t.private(name, fmt.Sprintf("Your turn (%s).\nActions: %s", strings.Join(ranks, ","), strings.Join(actions, ", ")))
e.Color = "casino_menu"
- e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions}
+ e.Menu = &MenuView{Kind: MenuBlackjackTurn, Title: fmt.Sprintf("Your turn (%s)", strings.Join(ranks, ",")), Actions: actions, Commands: blackjackActionCommands(actions)}
return []Event{e}
}
+func blackjackActionCommands(actions []string) []MenuCommand {
+ descriptions := map[string]string{
+ "hit": "Take another card",
+ "stand": "Hold your total",
+ "double": "Double your bet and take one card",
+ "split": "Split your pair into two hands",
+ "surrender": "Forfeit half your bet",
+ }
+ commands := make([]MenuCommand, 0, len(actions))
+ for _, action := range actions {
+ commands = append(commands, MenuCommand{Command: action, Desc: descriptions[action]})
+ }
+ return commands
+}
+
func (t *blackjackTable) availableActions(p *blackjackPlayer, hand *blackjackHand) []string {
if fromSplitAces(hand) {
// Split aces receive one card each and may only be re-split.
@@ -657,7 +676,7 @@ func (t *blackjackTable) snapshot(revealDealer bool) *BlackjackSnapshot {
}
func (t *blackjackTable) menu() string {
- return "New hand! You can bet, bet <amount> (inc. min/max), or stop."
+ return "New blackjack hand! Place your bet."
}
func handScore(hand *blackjackHand) string {
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
index 89b5d06..a6735c0 100644
--- a/internal/casino/blackjack_test.go
+++ b/internal/casino/blackjack_test.go
@@ -110,6 +110,21 @@ func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) {
if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet {
t.Fatalf("menu event=%+v", event.Menu)
}
+ if len(event.Menu.Commands) != 3 || event.Menu.Commands[0].Command != "bet" || event.Menu.Commands[2].Command != "stop" {
+ t.Fatalf("bet menu commands=%+v", event.Menu.Commands)
+ }
+}
+
+func TestBlackjackActionCommandsDescribeEachAction(t *testing.T) {
+ commands := blackjackActionCommands([]string{"hit", "stand", "double", "split", "surrender"})
+ if len(commands) != 5 {
+ t.Fatalf("commands=%+v", commands)
+ }
+ for _, c := range commands {
+ if c.Desc == "" {
+ t.Fatalf("action %q missing a description", c.Command)
+ }
+ }
}
func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) {
diff --git a/internal/casino/machine.go b/internal/casino/machine.go
index 0ee4cde..79e4cb6 100644
--- a/internal/casino/machine.go
+++ b/internal/casino/machine.go
@@ -340,13 +340,19 @@ func formatWins(wins []SlotWin, ways int) string {
return message[:len(message)-1]
}
-const slotMenuText = "Slot machine commands:\n bet <amount> Set your wager\n bet max Set the maximum wager\n spin Spin using your current wager\n autospin Start automatic betting\n stop Leave the slot machine"
+var slotCommands = []MenuCommand{
+ {Command: "bet <amount>", Desc: "Set your wager"},
+ {Command: "bet max", Desc: "Set the maximum wager"},
+ {Command: "spin", Desc: "Spin using your current wager"},
+ {Command: "autospin", Desc: "Start automatic betting"},
+ {Command: "stop", Desc: "Leave the slot machine"},
+}
func (m *Machine) privateMenu(prefix string) []Event {
events := m.private(prefix)
events[0].Color = "casino_menu"
if m.phase == machineReady {
- events[0].Menu = &MenuView{Kind: MenuSlotCommands, Body: slotMenuText}
+ events[0].Menu = &MenuView{Kind: MenuSlotCommands, Commands: slotCommands}
}
return events
}
diff --git a/internal/casino/machine_test.go b/internal/casino/machine_test.go
index fdfa42c..386e4f9 100644
--- a/internal/casino/machine_test.go
+++ b/internal/casino/machine_test.go
@@ -27,7 +27,7 @@ func TestMachineMenuEventCarriesStructuredMenu(t *testing.T) {
m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
m.player = &Participant{Name: "alice", Connected: true}
events := m.privateMenu("You sit down at the slot machine.")
- if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || events[0].Menu.Body == "" {
+ if len(events) != 1 || events[0].Menu == nil || events[0].Menu.Kind != MenuSlotCommands || len(events[0].Menu.Commands) == 0 {
t.Fatalf("slot menu event=%+v", events)
}
}
diff --git a/internal/casino/types.go b/internal/casino/types.go
index c28b471..52c4bfa 100644
--- a/internal/casino/types.go
+++ b/internal/casino/types.go
@@ -181,10 +181,18 @@ const (
// presentation layer can enrich it (balances, command colors) without parsing
// message prefixes.
type MenuView struct {
- Kind MenuKind
- Body string // command list (MenuSlotCommands)
- Title string // prompt heading (MenuBlackjackTurn)
- Actions []string // available commands (MenuBlackjackTurn)
+ Kind MenuKind
+ Body string // command list (MenuSlotCommands)
+ Title string // prompt heading (MenuBlackjackTurn)
+ Actions []string // available commands (MenuBlackjackTurn)
+ Commands []MenuCommand // unified command list for all casino menus
+}
+
+// MenuCommand is one selectable command shown in a casino menu: the typed
+// command text plus a short human-readable description.
+type MenuCommand struct {
+ Command string
+ Desc string
}
type Event struct {
diff --git a/internal/color/color.go b/internal/color/color.go
index 8e301e4..e89acc8 100644
--- a/internal/color/color.go
+++ b/internal/color/color.go
@@ -442,6 +442,20 @@ func ExpandTags(mode string, text string) string {
}
func ExpandTagsDefault(mode string, def ColorSpec, text string) string {
+ return ExpandTagsDefaultNamed(mode, def, nil, text)
+}
+
+// ExpandTagsNamed expands {tags} in text, additionally resolving a tag whose
+// spec matches a key in named (a color category name) to that category's
+// ColorSpec. Named hits are treated as real tags even when they resolve to an
+// empty spec, so a category disabled with "off" renders its text plain instead
+// of leaking the literal tag. Any tag not present in named falls back to the
+// ordinary hex/style spec parser.
+func ExpandTagsNamed(mode string, named map[string]ColorSpec, text string) string {
+ return ExpandTagsDefaultNamed(mode, NoColor(), named, text)
+}
+
+func ExpandTagsDefaultNamed(mode string, def ColorSpec, named map[string]ColorSpec, text string) string {
if !strings.Contains(text, "{") {
if !def.Empty() {
return Render(mode, def, text)
@@ -491,8 +505,16 @@ func ExpandTagsDefault(mode string, def ColorSpec, text string) string {
continue
}
- spec := Parse(specStr)
- if spec.Empty() {
+ spec, isNamed := NoColor(), false
+ if named != nil {
+ if s, ok := named[specStr]; ok {
+ spec, isNamed = s, true
+ }
+ }
+ if !isNamed {
+ spec = Parse(specStr)
+ }
+ if spec.Empty() && !isNamed {
before := text[pos : end+1]
if !def.Empty() {
sb.WriteString(Render(mode, def, before))
@@ -534,18 +556,23 @@ func ExpandTagsDefault(mode string, def ColorSpec, text string) string {
}
func ExpandDialogTags(mode string, dialogSpec ColorSpec, text string) string {
+ return ExpandDialogTagsNamed(mode, dialogSpec, nil, text)
+}
+
+// ExpandDialogTagsNamed is ExpandDialogTags with named-color tag resolution.
+func ExpandDialogTagsNamed(mode string, dialogSpec ColorSpec, named map[string]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))
+ sb.WriteString(ExpandTagsDefaultNamed(mode, NoColor(), named, part))
}
} else {
if i < len(parts)-1 {
- sb.WriteString(ExpandTagsDefault(mode, dialogSpec, `"`+part+`"`))
+ sb.WriteString(ExpandTagsDefaultNamed(mode, dialogSpec, named, `"`+part+`"`))
} else {
- sb.WriteString(ExpandTagsDefault(mode, dialogSpec, `"`+part))
+ sb.WriteString(ExpandTagsDefaultNamed(mode, dialogSpec, named, `"`+part))
}
}
}
diff --git a/internal/color/color_test.go b/internal/color/color_test.go
index f6b79f0..9efa19c 100644
--- a/internal/color/color_test.go
+++ b/internal/color/color_test.go
@@ -207,3 +207,61 @@ func TestExpandDialogTags_NoColorMode(t *testing.T) {
t.Errorf("no-color mode should preserve text, got %q", got)
}
}
+
+func TestExpandTagsNamed_ResolvesNamedColor(t *testing.T) {
+ named := map[string]ColorSpec{"command": Parse("0A bold")}
+ got := ExpandTagsNamed("xterm256", named, "Type {command}look sign{/} now")
+ want := StyleCode("bold") + FgCode("xterm256", 10)
+ if !strings.Contains(got, want+"look sign"+Reset) {
+ t.Errorf("named tag did not resolve: %q", got)
+ }
+ if strings.Contains(got, "{command}") {
+ t.Errorf("named tag leaked literally: %q", got)
+ }
+}
+
+func TestExpandTagsNamed_UnknownNameFallsBackToParse(t *testing.T) {
+ // "0B bold" is not a named key, so it must keep parsing as a spec.
+ named := map[string]ColorSpec{"command": Parse("0A bold")}
+ got := ExpandTagsNamed("xterm256", named, "x{0B bold}y{/}z")
+ if !strings.Contains(got, StyleCode("bold")+FgCode("xterm256", 11)+"y"+Reset) {
+ t.Errorf("unknown named tag should fall back to spec parsing, got %q", got)
+ }
+ // A string that is neither named nor a valid spec stays literal.
+ got = ExpandTagsNamed("xterm256", named, "{not_a_color}hi{/}")
+ if !strings.Contains(got, "{not_a_color}hi") {
+ t.Errorf("unknown tag should remain literal, got %q", got)
+ }
+}
+
+func TestExpandTagsNamed_OffResolvesPlain(t *testing.T) {
+ named := map[string]ColorSpec{"command": NoColor()}
+ got := ExpandTagsNamed("xterm256", named, "A {command}bet{/}.")
+ if strings.Contains(got, "\033") {
+ t.Errorf("off category should render plain, got %q", got)
+ }
+ if !strings.Contains(got, "A bet.") {
+ t.Errorf("off category should keep the inner text, got %q", got)
+ }
+}
+
+func TestExpandTagsNamed_NoColorMode(t *testing.T) {
+ named := map[string]ColorSpec{"command": Parse("0A bold")}
+ got := ExpandTagsNamed("none", named, "Type {command}look sign{/} now")
+ if strings.Contains(got, "\033") {
+ t.Errorf("no-color mode should strip ANSI, got %q", got)
+ }
+ if !strings.Contains(got, "Type look sign now") {
+ t.Errorf("no-color mode should keep text, got %q", got)
+ }
+}
+
+func TestExpandDialogTagsNamed_ResolvesInsideQuotes(t *testing.T) {
+ named := map[string]ColorSpec{"command": Parse("0A bold")}
+ dialogSpec := Parse("D0")
+ got := ExpandDialogTagsNamed("xterm256", dialogSpec, named, `"Please be sure {command}look{/} at the sign."`)
+ want := StyleCode("bold") + FgCode("xterm256", 10)
+ if !strings.Contains(got, want+"look"+Reset) {
+ t.Errorf("named tag inside quoted dialog did not resolve: %q", got)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 9510cb2..72c1c87 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -57,6 +57,7 @@ func DefaultColors() ColorsConfig {
"global": "49",
"dialog": "6E",
"dialog_options": "bold",
+ "command": "0A bold",
"broadcast": "89",
"sequence": "off",
"fire": "B4",
diff --git a/internal/game/act_effects.go b/internal/game/act_effects.go
index d1effac..a49bd84 100644
--- a/internal/game/act_effects.go
+++ b/internal/game/act_effects.go
@@ -5,7 +5,6 @@ import (
"strconv"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
)
@@ -75,8 +74,7 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
if step.Broadcast != "" {
raw := expandTemplate(step.Broadcast, playerName, flagValue)
for _, other := range g.Hub.PlayersInRoom(roomID) {
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ rendered := g.expandTagsDefault(other, broadcastSpec, raw)
other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
@@ -86,8 +84,7 @@ func (g *Game) applyStep(sess *net.Session, p *player.Player, step *behavior.Ste
if other.Player == nil {
continue
}
- otherMode := g.colorMode(other)
- rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, raw)
+ rendered := g.expandTagsDefault(other, broadcastSpec, raw)
other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
@@ -212,8 +209,7 @@ func (g *Game) writePlayerMessage(sess *net.Session, msg string, playerName stri
}
rendered := expandTemplate(msg, playerName, flagValue)
seqSpec := g.resolveColor(sess, "sequence")
- mode := g.colorMode(sess)
- rendered = color.ExpandTagsDefault(mode, seqSpec, rendered)
+ rendered = g.expandTagsDefault(sess, seqSpec, rendered)
sess.WriteLine(rendered)
}
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index 8743a46..0701fc4 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -6,7 +6,6 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
@@ -248,7 +247,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
msg = fmt.Sprintf("You manage to get some %s.", coloredName)
} else {
msg = strings.ReplaceAll(msg, "%n", coloredName)
- msg = color.ExpandTags(g.colorMode(sess), msg)
+ msg = g.expandTags(sess, msg)
}
if xp > 0 && p.OptionBool("xp_drops") {
msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp)
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index 8de58df..dfa7b94 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -5,7 +5,6 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
@@ -100,7 +99,7 @@ func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
td.SeqIndex = 0
msg := node.Messages[0]
if msg != "" {
- sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
+ sess.WriteLine(g.expandDialogTags(sess, dialogSpec, msg))
}
td.SeqIndex = 1
if td.SeqIndex < len(node.Messages) {
@@ -118,7 +117,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, color.ExpandDialogTags(g.colorMode(sess), g.resolveColor(sess, "dialog_options"), opt.Text)))
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, g.expandDialogTags(sess, g.resolveColor(sess, "dialog_options"), opt.Text)))
}
sess.State = net.StateTalk
g.reprompt(sess)
@@ -158,7 +157,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, color.ExpandDialogTags(g.colorMode(sess), g.resolveColor(sess, "dialog_options"), opt.Text)))
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, g.expandDialogTags(sess, g.resolveColor(sess, "dialog_options"), opt.Text)))
}
sess.State = net.StateTalk
g.reprompt(sess)
@@ -294,7 +293,7 @@ func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
if td.SeqIndex < len(node.Messages) {
msg := node.Messages[td.SeqIndex]
if msg != "" {
- sess.WriteLine(color.ExpandDialogTags(g.colorMode(sess), dialogSpec, msg))
+ sess.WriteLine(g.expandDialogTags(sess, dialogSpec, msg))
}
td.SeqIndex++
}
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 97372f4..785666e 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -285,7 +285,7 @@ func (g *Game) emitCasinoEvents(events []casino.Event) {
// renderCasinoMessage colorizes a casino message unless it is an
// already-composed structured menu.
func (g *Game) renderCasinoMessage(sess *net.Session, category string, menu *casino.MenuView, message string) string {
- if category == "casino_menu" && menu != nil && menu.Kind != casino.MenuSlotCommands {
+ if category == "casino_menu" && menu != nil {
return message
}
return g.colorize(sess, category, message)
@@ -306,29 +306,47 @@ func (g *Game) casinoPrivateMessage(sess *net.Session, category string, menu *ca
credits := g.colorize(sess, "casino_credits", fmt.Sprintf("%d", sess.Player.Credits))
balance := fmt.Sprintf("You have %s chips and %s credits.", chips, credits)
switch menu.Kind {
- case casino.MenuBlackjackBet:
- command := func(value string) string { return g.colorize(sess, "casino_command", value) }
- return fmt.Sprintf("%s\n\nNew hand! You can %s, %s (inc. min/max), or %s.",
- balance, command("bet"), command("bet <amount>"), command("stop"))
+ case casino.MenuBlackjackBet, casino.MenuBaccaratBet:
+ return g.renderCasinoCommands(sess, balance, message, menu.Commands)
case casino.MenuBlackjackTurn:
- commands := make([]string, len(menu.Actions))
- for i, action := range menu.Actions {
- commands[i] = g.colorize(sess, "casino_command", action)
- }
- return menu.Title + "\nActions: " + strings.Join(commands, ", ")
- case casino.MenuBaccaratBet:
- command := func(value string) string { return g.colorize(sess, "casino_command", value) }
- return fmt.Sprintf("%s\n\nNew hand! You can %s, %s on player|banker|tie (inc. min/max), or %s.",
- balance, command("bet"), command("bet <amount>"), command("stop"))
+ return g.renderCasinoCommands(sess, "", menu.Title, menu.Commands)
case casino.MenuSlotCommands:
- if message == "" {
- return balance + "\n\n" + menu.Body
- }
- return message + "\n\n" + balance + "\n\n" + menu.Body
+ return g.renderCasinoCommands(sess, balance, message, menu.Commands)
}
return message
}
+// renderCasinoCommands lays every casino menu out in the same style: an
+// optional balance line, a heading, and an aligned command list with each
+// command in the "command" color and its description in plain text.
+func (g *Game) renderCasinoCommands(sess *net.Session, balance, heading string, commands []casino.MenuCommand) string {
+ var sb strings.Builder
+ if balance != "" {
+ sb.WriteString(balance)
+ sb.WriteString("\n\n")
+ }
+ if heading != "" {
+ sb.WriteString(heading)
+ sb.WriteString("\n\n")
+ }
+ width := 0
+ for _, c := range commands {
+ if n := utf8.RuneCountInString(c.Command); n > width {
+ width = n
+ }
+ }
+ for _, c := range commands {
+ padded := c.Command + strings.Repeat(" ", width-utf8.RuneCountInString(c.Command))
+ cmd := g.colorize(sess, "command", padded)
+ if c.Desc != "" {
+ fmt.Fprintf(&sb, " %s %s\n", cmd, c.Desc)
+ } else {
+ fmt.Fprintf(&sb, " %s\n", cmd)
+ }
+ }
+ return strings.TrimRight(sb.String(), "\n")
+}
+
func renderBlackjackSnapshot(snapshot *casino.BlackjackSnapshot, mode string, unicode bool, size int, style string, art *cardart.Store) string {
if snapshot == nil {
return ""
diff --git a/internal/game/casino_test.go b/internal/game/casino_test.go
index 0394437..8affe29 100644
--- a/internal/game/casino_test.go
+++ b/internal/game/casino_test.go
@@ -81,6 +81,29 @@ func TestExecuteBetParsesBaccaratSpots(t *testing.T) {
}
}
+func TestCasinoMenuRendersAlignedColoredCommands(t *testing.T) {
+ g := &Game{Casino: casino.NewManager(1)}
+ conn := &casinoTestConn{}
+ sess := &net.Session{Player: player.New("alice"), Conn: conn}
+ g.loggedInChars = map[string]*net.Session{"alice": sess}
+ menu := &casino.MenuView{Kind: casino.MenuSlotCommands, Commands: []casino.MenuCommand{
+ {Command: "bet <amount>", Desc: "Set your wager"},
+ {Command: "spin", Desc: "Spin using your current wager"},
+ }}
+ msg := g.casinoPrivateMessage(sess, "casino_menu", menu, "Your slot machine bet is set to 10.")
+ if !strings.Contains(msg, "You have 0 chips and 0 credits.") {
+ t.Fatalf("missing balance line: %q", msg)
+ }
+ // Commands are painted with the "command" color (0A bold).
+ if !strings.Contains(msg, "\033[1m\033[38;5;10mbet <amount>\033[0m") {
+ t.Fatalf("command not colored: %q", msg)
+ }
+ // Shorter commands are right-padded to align the descriptions.
+ if !strings.Contains(msg, "\033[1m\033[38;5;10mspin \033[0m Spin using your current wager") {
+ t.Fatalf("commands not aligned: %q", msg)
+ }
+}
+
func TestRenderBaccaratSnapshot(t *testing.T) {
snapshot := &casino.BaccaratSnapshot{
Player: []casino.CardView{{Rank: "5", Suit: "clubs"}, {Rank: "4", Suit: "hearts"}},
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 01912a7..b659795 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -173,6 +173,9 @@ var colorCategoryOrder = []string{
"dialog",
"dialog_options",
+ // Commands
+ "command",
+
// Drops / consume / fire
"drop_message",
"eat_food",
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index 4ed47c9..0d08551 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -158,7 +158,7 @@ func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.
roomDesc := def.InRoomDescription
if roomDesc != "" {
- roomDesc = color.ExpandTags(g.colorMode(sess), roomDesc)
+ roomDesc = g.expandTags(sess, roomDesc)
}
coloredName := g.objColorize(sess, def, def.Name)
coloredPlural := g.objColorize(sess, def, def.Name+"s")
diff --git a/internal/game/look_room.go b/internal/game/look_room.go
index dc488f0..9d77ebe 100644
--- a/internal/game/look_room.go
+++ b/internal/game/look_room.go
@@ -58,11 +58,10 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo
descWidth = 70
}
rawLines := wrapText(g.roomDescription(sess, room), descWidth)
- mode := g.colorMode(sess)
roomDescSpec := g.resolveColor(sess, "room_desc")
lines := make([]string, len(rawLines))
for i, l := range rawLines {
- lines[i] = color.ExpandTagsDefault(mode, roomDescSpec, l)
+ lines[i] = g.expandTagsDefault(sess, roomDescSpec, l)
}
return lines
}
diff --git a/internal/game/look_target.go b/internal/game/look_target.go
index 9257940..8368946 100644
--- a/internal/game/look_target.go
+++ b/internal/game/look_target.go
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
@@ -74,7 +73,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
fmt.Sprintf("%s %s", g.colorize(sess, mobColor, best.Name), levelStr),
)
if best.Description != "" {
- sess.WriteLine(color.ExpandTags(g.colorMode(sess), best.Description))
+ sess.WriteLine(g.expandTags(sess, best.Description))
}
if !best.Protected {
sess.WriteLines(
@@ -151,7 +150,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
if objDescText != "" && !strings.Contains(objDescText, "{quality}") {
- sess.WriteLine(color.ExpandTags(g.colorMode(sess), objDescText))
+ sess.WriteLine(g.expandTags(sess, objDescText))
}
if len(def.OnLook) > 0 {
@@ -200,7 +199,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
for _, ist := range instances {
if ist.Quality > 0 && strings.Contains(objDescText, "{quality}") {
desc := strings.ReplaceAll(objDescText, "{quality}", fmt.Sprintf("%.0f", ist.Quality))
- sess.WriteLine(color.ExpandTags(g.colorMode(sess), desc))
+ sess.WriteLine(g.expandTags(sess, desc))
}
}
@@ -228,7 +227,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLines(
"",
g.itemColorize(sess, def, def.Name),
- color.ExpandTags(g.colorMode(sess), def.Description),
+ g.expandTags(sess, def.Description),
fmt.Sprintf("Value: %d credits", def.Value),
)
g.showItemStats(sess, def)
@@ -247,7 +246,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
lines := []string{
"",
g.itemColorize(sess, def, def.Name),
- color.ExpandTags(g.colorMode(sess), def.Description),
+ g.expandTags(sess, def.Description),
fmt.Sprintf("Value: %d credits", def.Value),
}
if slot.MaxQuality > 0 {
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 2fb7968..260cef0 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -6,7 +6,6 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/item"
"thehouseoficarus/internal/net"
@@ -59,7 +58,7 @@ func (g *Game) resolveCraftVar(sess *net.Session, varSpec string, craft *item.Cr
}
}
- return color.ExpandTags(g.colorMode(sess), varSpec)
+ return g.expandTags(sess, varSpec)
}
func (g *Game) resolveCraftMessage(sess *net.Session, itemMsg, defaultMsg string, craft *item.CraftDef, outputID string, byproducts []string, hasItem func(string) bool) string {
diff --git a/internal/game/render_color.go b/internal/game/render_color.go
index 958b671..ab3692e 100644
--- a/internal/game/render_color.go
+++ b/internal/game/render_color.go
@@ -107,3 +107,58 @@ func (g *Game) itemColorize(sess *net.Session, itemDef *item.ItemDef, text strin
}
return g.colorize(sess, "item", text)
}
+
+// namedColorSpecs maps every color category name to its resolved ColorSpec
+// for the session, letting inline tags like {command} bind to a color
+// category. All player-overridable categories, server defaults, and constant
+// colors are included so any color can be tagged in data content.
+func (g *Game) namedColorSpecs(sess *net.Session) map[string]color.ColorSpec {
+ names := make(map[string]color.ColorSpec)
+ for name := range config.DefaultColors() {
+ if spec := g.resolveColor(sess, name); !spec.Empty() {
+ names[name] = spec
+ }
+ }
+ if g.ColorConfig != nil {
+ for name := range *g.ColorConfig {
+ if _, ok := names[name]; ok {
+ continue
+ }
+ if spec := g.resolveColor(sess, name); !spec.Empty() {
+ names[name] = spec
+ }
+ }
+ }
+ for name := range config.DefaultConstantColors() {
+ if spec := g.resolveConstant(sess, name); !spec.Empty() {
+ names[name] = spec
+ }
+ }
+ if g.ConstantColorConfig != nil {
+ for name := range *g.ConstantColorConfig {
+ if _, ok := names[name]; ok {
+ continue
+ }
+ if spec := g.resolveConstant(sess, name); !spec.Empty() {
+ names[name] = spec
+ }
+ }
+ }
+ return names
+}
+
+// expandTags expands inline {spec}text{/} tags, including named color
+// categories such as {command}, using the session's resolved colors.
+func (g *Game) expandTags(sess *net.Session, text string) string {
+ return color.ExpandTagsNamed(g.colorMode(sess), g.namedColorSpecs(sess), text)
+}
+
+// expandTagsDefault is expandTags with a default spec applied to untagged text.
+func (g *Game) expandTagsDefault(sess *net.Session, def color.ColorSpec, text string) string {
+ return color.ExpandTagsDefaultNamed(g.colorMode(sess), def, g.namedColorSpecs(sess), text)
+}
+
+// expandDialogTags expands dialog text (quoted speech) with named color tags.
+func (g *Game) expandDialogTags(sess *net.Session, dialogSpec color.ColorSpec, text string) string {
+ return color.ExpandDialogTagsNamed(g.colorMode(sess), dialogSpec, g.namedColorSpecs(sess), text)
+}
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 4a283d1..e2893b5 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -7,6 +7,7 @@ import (
"gopkg.in/yaml.v3"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/ui"
)
@@ -117,8 +118,10 @@ func (g *Game) doHelp(sess *net.Session, topic string) {
Title: "Commands",
Columns: []string{"Command", "Type", "Description"},
}
+ commandSpec := g.resolveColor(sess, "command")
for _, c := range commandList {
- t.Rows = append(t.Rows, []string{c.Name, c.Type, c.Desc})
+ name := color.Render(g.colorMode(sess), commandSpec, c.Name)
+ t.Rows = append(t.Rows, []string{name, c.Type, c.Desc})
}
for _, line := range t.Render(unicode, wrapWidth) {
sess.WriteLine(line)
diff --git a/internal/game/render_prompt.go b/internal/game/render_prompt.go
index 9d7b20a..2b4f50c 100644
--- a/internal/game/render_prompt.go
+++ b/internal/game/render_prompt.go
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/player"
"thehouseoficarus/internal/world"
@@ -22,7 +21,7 @@ func (g *Game) promptStr(sess *net.Session) string {
}
func (g *Game) expandPromptColors(sess *net.Session, text string) string {
- return color.ExpandTags(g.colorMode(sess), text)
+ return g.expandTags(sess, text)
}
func (g *Game) expandPromptVars(sess *net.Session, text string) string {
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index 41fe19f..d833b62 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -4,7 +4,6 @@ import (
"fmt"
"math/rand"
- "thehouseoficarus/internal/color"
"thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
"thehouseoficarus/internal/object"
@@ -183,7 +182,7 @@ func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg
if msg != "" {
for _, rs := range roomSessions {
rs.WriteLine("")
- rs.WriteLine(color.ExpandTags(g.colorMode(rs), msg))
+ rs.WriteLine(g.expandTags(rs, msg))
}
}
}
@@ -195,7 +194,7 @@ func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg
for _, name := range st.SafespotOccupants {
g.safespot.Delete(name)
if occSess := g.sessionInRoom(name, roomSessions); occSess != nil {
- occSess.WriteLine(color.ExpandTags(g.colorMode(occSess),
+ occSess.WriteLine(g.expandTags(occSess,
fmt.Sprintf("The %s crumbles away!", objDef.Name)))
occSess.WriteLine(g.colorize(occSess, "safespot_alert", "Your safespot has been compromised!"))
g.writePrompt(occSess)
@@ -216,7 +215,7 @@ func (g *Game) degradeSafespot(st *world.ObjState, objDef *object.ObjectDef, cfg
for _, name := range st.SafespotOccupants {
if occSess := g.sessionInRoom(name, roomSessions); occSess != nil {
occSess.WriteLine("")
- occSess.WriteLine(color.ExpandTags(g.colorMode(occSess), msg))
+ occSess.WriteLine(g.expandTags(occSess, msg))
}
}
}
@@ -237,7 +236,7 @@ func (g *Game) scheduleSafespotRespawn(st *world.ObjState, objDef *object.Object
respawnSt.SafespotTicksAtLevel = 0
for _, sess := range g.Hub.PlayersInRoom(roomID) {
sess.WriteLine("")
- sess.WriteLine(color.ExpandTags(g.colorMode(sess),
+ sess.WriteLine(g.expandTags(sess,
fmt.Sprintf("The %s reforms and looks stable again.", objDef.Name)))
}
return false