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 --- 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 +++---- 14 files changed, 142 insertions(+), 51 deletions(-) (limited to 'internal/game') 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