From a2cbc04f86508708daa09affa5e9d73cc689b2ba Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Mon, 3 Aug 2026 17:15:50 -0400 Subject: feat: new color 'command', color tag system implemented --- building_guide/rooms.md | 6 +++- building_guide/triggers.md | 4 +-- config.yaml | 2 +- data/help/color.yaml | 8 ++++++ data/mobs/flight_attendant.yaml | 2 +- data/rooms/intro/1001.yaml | 6 ++-- internal/admin/api_colors.go | 4 +++ internal/casino/baccarat.go | 8 ++++-- internal/casino/baccarat_test.go | 3 ++ internal/casino/blackjack.go | 25 ++++++++++++++-- internal/casino/blackjack_test.go | 15 ++++++++++ internal/casino/machine.go | 10 +++++-- internal/casino/machine_test.go | 2 +- internal/casino/types.go | 16 ++++++++--- internal/color/color.go | 37 ++++++++++++++++++++---- internal/color/color_test.go | 58 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 1 + internal/game/act_effects.go | 10 ++----- internal/game/act_gather.go | 3 +- internal/game/act_talk.go | 9 +++--- internal/game/casino.go | 54 +++++++++++++++++++++++------------ internal/game/casino_test.go | 23 +++++++++++++++ internal/game/cmd_color.go | 3 ++ internal/game/look_entities.go | 2 +- internal/game/look_room.go | 3 +- internal/game/look_target.go | 11 ++++---- internal/game/production_engine.go | 3 +- internal/game/render_color.go | 55 ++++++++++++++++++++++++++++++++++++ internal/game/render_help.go | 5 +++- internal/game/render_prompt.go | 3 +- internal/game/sys_safespot.go | 9 +++--- 31 files changed, 324 insertions(+), 76 deletions(-) diff --git a/building_guide/rooms.md b/building_guide/rooms.md index bbd7ec1..cf7aa37 100644 --- a/building_guide/rooms.md +++ b/building_guide/rooms.md @@ -37,6 +37,10 @@ Format: `{<00–FF> [bold] [dim] [underline]}text{/}` Gradients: `{g:C4,52}gradient text{/}`. Multi-stop: `{g:2D,27,3B}three stops{/}`. +Named colors can be used as tags too, and they track the live value of a color +category (including account overrides): `{command}look sign{/}` paints the text +with the current "command" color. + Untagged text uses the `room_desc` theme color. --- @@ -382,7 +386,7 @@ on_enter: not: true player_flag: 1001_welcome messages: - - "{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}" + - "{0B}Type{/} {command}look sign{/} {0B}or{/} {command}talk attendant{/} {0B}to get started{/}" set_player_flags: 1001_welcome: true wait: 7 diff --git a/building_guide/triggers.md b/building_guide/triggers.md index f7720bb..05fb639 100644 --- a/building_guide/triggers.md +++ b/building_guide/triggers.md @@ -235,7 +235,7 @@ on_enter: not: true player_flag: 1001_welcome messages: - - "{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}" + - "{0B}Type{/} {command}look sign{/} {0B}or{/} {command}talk attendant{/} {0B}to get started{/}" set_player_flags: 1001_welcome: true wait: 7 @@ -243,7 +243,7 @@ on_enter: not: true player_flag: 1001_welcome messages: - - "{0B}Type{/} {0A}help newplayer{/} {0B}for the new player's guide...{/}" + - "{0B}Type{/} {command}help newplayer{/} {0B}for the new player's guide...{/}" wait: 7 ``` diff --git a/config.yaml b/config.yaml index c86ef5b..0ebf142 100644 --- a/config.yaml +++ b/config.yaml @@ -70,9 +70,9 @@ default_colors: casino_jackpot_win: "g:220,226,46,51,129,201 bold" casino_chips: "B3" casino_credits: "6E" - casino_command: "0A bold" dialog: "6E" dialog_options: "bold" + command: "0A bold" drop_message: "89" eat_food: "6C" fire: "B4" diff --git a/data/help/color.yaml b/data/help/color.yaml index b07ab17..82e8fed 100644 --- a/data/help/color.yaml +++ b/data/help/color.yaml @@ -48,6 +48,12 @@ description: | {B6 bold}mysterious vase{/} {g:C4,52}gradient text{/} + Color names can be used as inline tags too, so tagged text tracks the + current value of a color target (including any account override): + {command}look sign{/} + {mob}a feral wolf{/} + {item}credits{/} + Color targets: room_name Room name headers room_number Room number display (#id) @@ -69,6 +75,8 @@ description: | error Error messages say Player chat text dialog NPC conversation text + dialog_options Numbered dialog choices + command Command words shown in menus and help (bet, spin, look, ...) broadcast Room-wide event messages fire Firemaking messages eat_food Eating/healing messages diff --git a/data/mobs/flight_attendant.yaml b/data/mobs/flight_attendant.yaml index a0be1e8..85a7484 100644 --- a/data/mobs/flight_attendant.yaml +++ b/data/mobs/flight_attendant.yaml @@ -10,7 +10,7 @@ talk: sign_reminder: messages: - '"Yeah, just us two! Not a lot of people heading into the belt these days."' - - '"But we''ll be landing shortly! Please be sure {0B bold}look{/} at the information {0B bold}sign{/} so you''ll be all set for your final destination."' + - '"But we''ll be landing shortly! Please be sure {command}look{/} at the information {0B bold}sign{/} so you''ll be all set for your final destination."' start: condition: not: true diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml index 5a61bba..3e04381 100644 --- a/data/rooms/intro/1001.yaml +++ b/data/rooms/intro/1001.yaml @@ -28,7 +28,7 @@ mobs: name: Inside Transport Shuttle objects: - description: - - text: "We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\nWhen you arrive at your final destination, please be aware of the following basic commands:\n\n{0B bold}Movement:{/} {0A}north{/}, {0A}south{/}, {0A}east{/}, {0A}west{/}, {0A}up{/}, {0A}down{/}\n{0B bold}Look:{/} {0A}look{/}, {0A}look {/}, and {0A}map{/}\n{0B bold}Items:{/} {0A}inv{/} to see your 28-slot inventory\n {0A}get {/}, {0A}drop {/}, {0A}wear {/}, {0A}remove {/}\n {0A}use on {/}\n {0A}eq{/} for equiped gear\n{0B bold}Comms:{/} {0A}talk {/} for NPC chat\n {0A}say {/} and {0A}global {/} for human chat\n{0B bold}Character:{/} {0A}score{/} and {0A}skills{/} for stats. \n{0B bold}Combat{/} {0A}attack {/}\n{0B bold}Skills{/} Many skills have shortcuts like {0A}mine{/}, {0A}fish{/}, or {0A}chop{/}\n{0B bold}Options{/} {0A}options{/} for a list of account options\n {0A}options {/} to change an option\n\nType {0C bold}what{/} for a list of possible common actions\n\nMake sure to check the {0A}help{/} pages, especially {0A}help newplayer{/}!" + - text: "We hope you enjoy your trip on our shuttle! Your pilot is [TYLER].\n\nWhen you arrive at your final destination, please be aware of the following basic commands:\n\n{0B bold}Movement:{/} {command}north{/}, {command}south{/}, {command}east{/}, {command}west{/}, {command}up{/}, {command}down{/}\n{0B bold}Look:{/} {command}look{/}, {command}look {/}, and {command}map{/}\n{0B bold}Items:{/} {command}inv{/} to see your 28-slot inventory\n {command}get {/}, {command}drop {/}, {command}wear {/}, {command}remove {/}\n {command}use on {/}\n {command}eq{/} for equiped gear\n{0B bold}Comms:{/} {command}talk {/} for NPC chat\n {command}say {/} and {command}global {/} for human chat\n{0B bold}Character:{/} {command}score{/} and {command}skills{/} for stats. \n{0B bold}Combat{/} {command}attack {/}\n{0B bold}Skills{/} Many skills have shortcuts like {command}mine{/}, {command}fish{/}, or {command}chop{/}\n{0B bold}Options{/} {command}options{/} for a list of account options\n {command}options {/} to change an option\n\nType {command}what{/} for a list of possible common actions\n\nMake sure to check the {command}help{/} pages, especially {command}help newplayer{/}!" hidden: true name: framed sign on_look: @@ -126,7 +126,7 @@ on_enter: give_item: "" heal: 0 messages: - - '{0B}Type{/} {0A}look sign{/} {0B}or{/} {0A}talk attendant{/} {0B}to get started{/}' + - '{0B}Type{/} {command}look sign{/} {0B}or{/} {command}talk attendant{/} {0B}to get started{/}' set_global_flags: {} set_player_flags: 1001_welcome: true @@ -149,7 +149,7 @@ on_enter: give_item: "" heal: 0 messages: - - '{0B}Type{/} {0A}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}' + - '{0B}Type{/} {command}help newplayer{/} {0B}for the new player''s guide. That''s it. Explore and have fun.{/}' set_global_flags: {} set_player_flags: {} spawn_mob: null 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 ", "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 ", Desc: "Place a wager (min/max accepted)"}, + {Command: "bet 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 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 " || 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 ", 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 (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 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 ", 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 "), 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 "), 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 ", 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 \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 -- cgit v1.2.3