diff options
| author | historia <[not public]> | 2026-06-15 02:57:35 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-15 02:58:50 -0400 |
| commit | 4cc1ddcd185509080b4b3e15d1646567edd51c9d (patch) | |
| tree | 2a564846ad4fd159a2d7e55451bfa9569fe8c0b8 | |
| parent | 445c4612cc5cf2c226f85894b6ad1e2419a374e2 (diff) | |
| download | thehouseoficarus-4cc1ddcd185509080b4b3e15d1646567edd51c9d.tar.gz | |
removed game speed for being broken and an antifeature anyway. added color support.
29 files changed, 539 insertions, 197 deletions
@@ -19,7 +19,6 @@ Config options: ```yaml game: tick_length: 600 # ms per game tick (default 600, min 50) - game_speed: 1.0 # multiplier for all tick timers (default 1.0) ``` Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`. @@ -70,7 +69,7 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern + **Tick-driven.** The world runs on a 600ms tick. Combat, gathering, regen, wandering, respawns, shared depletion, woodcutting timers all advance per tick. See `cmd/mud/main.go` for the subscription loop. -**Fractional ticks.** All YAML timer fields (tool_speed, speed, base_wait, deplete_timer, etc.) accept `float64` values. `engine.FractionalTicks(base, speed)` probabilistically rounds to an integer — a tool_speed of 4.5 means each action cycle has a 50% chance of 4 ticks and 50% of 5 ticks. The `game_speed` config option scales all timers uniformly via `g.computeTicks()`. +**Fractional ticks.** All YAML timer fields (tool_speed, speed, base_wait, deplete_timer, etc.) accept `float64` values. `engine.ToTicks(base)` probabilistically rounds to an integer — a tool_speed of 4.5 means each action cycle has a 50% chance of 4 ticks and 50% of 5 ticks. ## Two State Systems @@ -57,6 +57,33 @@ make Then just telnet to the port. If no port is specified, the server will run on port 4000. +# Server Options + +The tc binary checks for a file named config.yaml in the local directory, or else it creates one. + +The game is designed to run with 600ms game ticks. You can change this without issue, but it will change how fast *everything* in the game happens. If you just want the game to "respond faster", that's not possible. **The ticks are a game mechanic.** Techniques like as science flicking, weapon switches, and certain power-skilling methods would not work with instant response or short ticks. Being able to instantly walk anywhere would negate the Agility skill, walk command, APS system, and fast-travel technology. + +``` +game: + tick_length: 600 +``` + +The telnet/http/https options are pretty self-explanatory. TC does not handle HTTPS certs for you. Use something like [certbot](https://certbot.eff.org/) on your server. The HTTP(S) server is just for a simple web client. + +``` +telnet: + enabled: true + port: 4000 +http: + enabled: false + port: 8080 +https: + enabled: false + port: 8443 + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" +``` + # Setting TC is a low-tech sci-fi MUD. Computer systems are severely regulated following an attempted hostile takeover of Earth by a benevolent dictator AI. Corrupt politicians were jailed. Harmful industries shuttered. Wealth and power were redistributed as the AI began to build Utopia. @@ -215,7 +215,7 @@ See WORLDBUILDING.md for full examples of every data type with explanations. - [x] Replaced `toggle` command with `option`/`options` — supports bool (on/off), string (choice lists), and int option types - [x] All old toggles migrated to the option system (tiny_map, xp_drops, exits, mob_enter, mob_leave, mob_spawn, reserve, depletion, description) -- [x] Added `color` option (none, ansi, xterm256) +- [x] Added `color` option — support for ANSI 16-color, xterm 256-color, and `none` modes with tag-based color syntax (`<red>text</red>`, `<bold>text</bold>`) and a `color` Go package for programmatic use - [x] Added `mapwidth` and `mapheight` options for the `map` command viewport size - [x] `map` command — renders a full ASCII map centered on the player using BFS graph traversal with geometric coordinate assignment - [x] Shared BFS graph builder (`buildGraph`) reused by both the tiny map (in `look`) and the `map` command @@ -251,7 +251,7 @@ See WORLDBUILDING.md for full examples of every data type with explanations. - [ ] Hacking, Thieving, Agility, Alchemy, Construction, Crafting, Scavenging, Farming, Assassin - [ ] Quest system (already supported via talk nodes + world/player flags) - [ ] PvP combat, multi-combat zones -- [ ] ANSI color output +- [x] ANSI / xterm256 color output — gather, combat, room names, talk, item names, prompt - [ ] Admin commands - [ ] Persist ground items and mob state across restarts - [ ] More rooms — flesh out the asteroid world diff --git a/cmd/mud/main.go b/cmd/mud/main.go index 6940cd6..c96a0e9 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -8,7 +8,6 @@ import ( "os/signal" "syscall" - "thirdcollapse/internal/combat" "thirdcollapse/internal/config" "thirdcollapse/internal/game" "thirdcollapse/internal/net" @@ -37,10 +36,6 @@ func main() { } g := game.New(dataDir) - g.GameSpeed = cfg.Game.GameSpeed - if g.GameSpeed <= 0 { - g.GameSpeed = 1.0 - } g.Ticks.Start(cfg.Game.TickLength) defer g.Ticks.Stop() @@ -48,7 +43,7 @@ func main() { g.ProcessQueuedCommands() g.World.Tick() g.MobStore.Tick() - combat.TickCombat() + g.TickCombat() g.RegenTick() g.DisconnectTick() g.WanderTick() diff --git a/config.yaml b/config.yaml index cf54642..b4bf45d 100644 --- a/config.yaml +++ b/config.yaml @@ -1,6 +1,5 @@ game: - tick_length: 600 # milliseconds per game tick (min 50) - game_speed: 1.0 # multiplier for all tick timers (default 1.0) + tick_length: 60 # milliseconds per game tick (min 50) telnet: enabled: true diff --git a/data/help/color.yaml b/data/help/color.yaml index 8171ab9..e56512a 100644 --- a/data/help/color.yaml +++ b/data/help/color.yaml @@ -8,6 +8,24 @@ description: | Values: none, ansi, xterm256 Controls the color output format used by the game. - none - No color output - ansi - Standard 16-color ANSI codes - xterm256 - 256-color xterm codes + none - No color output, tags are stripped. + ansi - Standard 16-color ANSI (works in most telnet clients + web client). + xterm256 - 256-color xterm codes (works in PuTTY, iTerm, Kitty, etc., + but not in the web client). + + You can embed color tags in your prompt string, character description, + and other free-text fields: <red>text</red> <bold>text</bold> + Tags are stripped if color mode is "none". + + Available foreground colors: + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, + bright_blue, bright_magenta, bright_cyan, bright_white + + Available styles: + bold, dim, italic, underline + + Examples: + option color ansi + option prompt <green>Ready> </green> + description <red>A tall warrior</red> with <bright_yellow>golden hair</bright_yellow>. diff --git a/data/help/ticks.yaml b/data/help/ticks.yaml index fd128c2..5cd64b6 100644 --- a/data/help/ticks.yaml +++ b/data/help/ticks.yaml @@ -16,9 +16,5 @@ description: | - Search timers - Depletion and respawn timers - The game_speed config option scales ALL tick timers proportionally. - A game_speed of 2.0 makes everything resolve twice as fast; 0.5 makes - everything twice as slow. - Use the "option" command to configure tick-related display settings. Use "help option" for a list of all player options. diff --git a/internal/color/color.go b/internal/color/color.go new file mode 100644 index 0000000..c09a1db --- /dev/null +++ b/internal/color/color.go @@ -0,0 +1,200 @@ +package color + +import ( + "fmt" + "strings" +) + +const Reset = "\033[0m" + +var ansiFg = map[string]int{ + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, +} + +var ansiBg = map[string]int{ + "black": 40, + "red": 41, + "green": 42, + "yellow": 43, + "blue": 44, + "magenta": 45, + "cyan": 46, + "white": 47, +} + +var ansiBrightFg = map[string]int{ + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} + +var ansiBrightBg = map[string]int{ + "bright_black": 100, + "bright_red": 101, + "bright_green": 102, + "bright_yellow": 103, + "bright_blue": 104, + "bright_magenta": 105, + "bright_cyan": 106, + "bright_white": 107, +} + +var xtermFg = map[string]int{ + "black": 0, + "red": 1, + "green": 2, + "yellow": 3, + "blue": 4, + "magenta": 5, + "cyan": 6, + "white": 7, + "bright_black": 8, + "bright_red": 9, + "bright_green": 10, + "bright_yellow": 11, + "bright_blue": 12, + "bright_magenta": 13, + "bright_cyan": 14, + "bright_white": 15, +} + +var ansiStyles = map[string]string{ + "bold": "\033[1m", + "dim": "\033[2m", + "italic": "\033[3m", + "underline": "\033[4m", +} + +func Fg(mode, name string) string { + if mode == "none" || mode == "" { + return "" + } + if mode == "xterm256" { + if c, ok := xtermFg[name]; ok { + return fmt.Sprintf("\033[38;5;%dm", c) + } + return "" + } + if c, ok := ansiFg[name]; ok { + return fmt.Sprintf("\033[%dm", c) + } + if c, ok := ansiBrightFg[name]; ok { + return fmt.Sprintf("\033[%dm", c) + } + return "" +} + +func Bg(mode, name string) string { + if mode == "none" || mode == "" { + return "" + } + if mode == "xterm256" { + if c, ok := xtermFg[name]; ok { + return fmt.Sprintf("\033[48;5;%dm", c) + } + return "" + } + if c, ok := ansiBg[name]; ok { + return fmt.Sprintf("\033[%dm", c) + } + if c, ok := ansiBrightBg[name]; ok { + return fmt.Sprintf("\033[%dm", c) + } + return "" +} + +func Style(name string) string { + if s, ok := ansiStyles[name]; ok { + return s + } + return "" +} + +func Wrap(mode, name, text string) string { + if mode == "none" || mode == "" { + return text + } + return Fg(mode, name) + text + Reset +} + +var knownColors []string + +func init() { + seen := make(map[string]bool) + for name := range ansiFg { + if !seen[name] { + knownColors = append(knownColors, name) + seen[name] = true + } + } + for name := range ansiBrightFg { + if !seen[name] { + knownColors = append(knownColors, name) + seen[name] = true + } + } + for name := range ansiStyles { + if !seen[name] { + knownColors = append(knownColors, name) + seen[name] = true + } + } +} + +func KnownColors() []string { return knownColors } + +func Tag(mode, text string) string { + if mode == "none" || mode == "" { + return cleanTags(text) + } + result := text + for _, name := range knownColors { + if _, isStyle := ansiStyles[name]; isStyle { + result = replaceTag(result, name, Style(name), Reset) + } else { + result = replaceTag(result, name, Fg(mode, name), Reset) + } + } + return result +} + +func replaceTag(s, name, open, close string) string { + tag := "<" + name + ">" + end := "</" + name + ">" + for { + start := strings.Index(s, tag) + if start < 0 { + break + } + closeIdx := strings.Index(s[start+len(tag):], end) + if closeIdx < 0 { + break + } + closeIdx += start + len(tag) + inner := s[start+len(tag) : closeIdx] + replacement := open + inner + close + s = s[:start] + replacement + s[closeIdx+len(end):] + } + return s +} + +func cleanTags(text string) string { + result := text + for _, name := range knownColors { + result = strings.ReplaceAll(result, "<"+name+">", "") + result = strings.ReplaceAll(result, "</"+name+">", "") + } + return result +} diff --git a/internal/config/config.go b/internal/config/config.go index a86689e..5c0a4b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,8 +14,7 @@ type Config struct { } type GameConfig struct { - TickLength int `yaml:"tick_length"` - GameSpeed float64 `yaml:"game_speed"` + TickLength int `yaml:"tick_length"` } type TelnetConfig struct { @@ -39,7 +38,6 @@ func Default() *Config { return &Config{ Game: GameConfig{ TickLength: 600, - GameSpeed: 1.0, }, Telnet: TelnetConfig{ Enabled: true, diff --git a/internal/engine/tick.go b/internal/engine/tick.go index 419867d..7a422af 100644 --- a/internal/engine/tick.go +++ b/internal/engine/tick.go @@ -106,16 +106,12 @@ func (e *Engine) processTick() { } } -func FractionalTicks(base, speed float64) int { - if speed <= 0 { - speed = 1 +func ToTicks(base float64) int { + if base < 1 { + base = 1 } - value := base / speed - if value < 1 { - value = 1 - } - floor := int(value) - if rand.Float64() < value-float64(floor) { + floor := int(base) + if rand.Float64() < base-float64(floor) { return floor + 1 } return floor diff --git a/internal/game/action.go b/internal/game/action.go index bb4bdac..26484e0 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -14,6 +14,32 @@ import ( "thirdcollapse/internal/world" ) +var verbAliases = map[string]string{ + "mine": "gather", + "chop": "gather", + "fish": "gather", + "cut": "gather", + "talk": "talk", + "speak": "talk", + "ask": "talk", + "pull": "toggle", + "push": "toggle", +} + +var verbSkill = map[string]string{ + "mine": "mining", + "chop": "woodcutting", + "cut": "woodcutting", + "fish": "fishing", +} + +func normalizeVerb(v string) string { + if a, ok := verbAliases[v]; ok { + return a + } + return v +} + func (g *Game) StartAction(sess *net.Session, verb, target string) { p := sess.Player.(*player.Player) @@ -199,21 +225,12 @@ func (g *Game) AdvanceActions() { case "search": g.advanceSearch(sess, p) } + if p.Action == nil { + g.writePrompt(sess) + } } } -func normalizeVerb(v string) string { - switch v { - case "mine", "chop", "fish", "cut": - return "gather" - case "talk", "speak", "ask": - return "talk" - case "pull", "push": - return "toggle" - } - return v -} - func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { p, _ := sess.Player.(*player.Player) diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index b16ba42..5c19510 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -5,6 +5,7 @@ import ( "math/rand" "thirdcollapse/internal/action" + "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" "thirdcollapse/internal/player" @@ -146,7 +147,7 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) { Type: "stoke", TargetID: itemID, TargetName: logDef.Name, - WaitLeft: g.computeTicks(10), + WaitLeft: engine.ToTicks(10), Data: map[string]any{ "item_id": itemID, "fire_key": fireKey, @@ -182,7 +183,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { g.broadcastDrop(p, logDef.Name) data["phase"] = 1 - p.Action.WaitLeft = g.computeTicks(toolSpeed) + p.Action.WaitLeft = engine.ToTicks(toolSpeed) return } @@ -196,7 +197,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { if phase == 1 { sess.WriteLine(fmt.Sprintf("You try burning the %s on the ground...", logDef.Name)) data["phase"] = 2 - p.Action.WaitLeft = g.computeTicks(toolSpeed) + p.Action.WaitLeft = engine.ToTicks(toolSpeed) return } @@ -240,7 +241,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { sess.WriteLine("You can't seem to get a fire going.") data["phase"] = 1 - p.Action.WaitLeft = g.computeTicks(toolSpeed) + p.Action.WaitLeft = engine.ToTicks(toolSpeed) } } @@ -293,7 +294,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { return } - p.Action.WaitLeft = g.computeTicks(10.0) + p.Action.WaitLeft = engine.ToTicks(10.0) } func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed float64, ok bool) { @@ -423,6 +424,7 @@ func (g *Game) cancelStokers(roomID int) { if ok && op.Action != nil && op.Action.Type == "stoke" { other.WriteLine("The fire went out!") g.CancelAction(op) + g.writePrompt(other) } } } diff --git a/internal/game/action_default_target.go b/internal/game/action_default_target.go index 45060cc..b3825ca 100644 --- a/internal/game/action_default_target.go +++ b/internal/game/action_default_target.go @@ -5,15 +5,7 @@ import ( ) func verbToSkill(verb string) string { - switch verb { - case "mine": - return "mining" - case "chop", "cut": - return "woodcutting" - case "fish": - return "fishing" - } - return "" + return verbSkill[verb] } func (g *Game) resolveDefaultTarget(roomID int, verb string) string { diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 4e1e609..08d2589 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -6,6 +6,8 @@ import ( "strings" "thirdcollapse/internal/action" + "thirdcollapse/internal/color" + "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" "thirdcollapse/internal/player" @@ -176,6 +178,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { wait := p.Action.Data["effective_wait"].(float64) instanceKey := p.Action.Data["instance_key"].(string) _, shared := p.Action.Data["deplete_timer"] + mode := g.colorMode(sess) if step == 0 { if cfg.Bait != "" { @@ -184,28 +187,28 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if def, err := g.ItemStore.Load(cfg.Bait); err == nil { baitName = def.Name } - sess.WriteLine(fmt.Sprintf("You've run out of %s.", baitName)) + sess.WriteLine(color.Wrap(mode, "red", fmt.Sprintf("You've run out of %s.", baitName))) g.CancelAction(p) return } } - sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) + sess.WriteLine(fmt.Sprintf("\n%s", color.Tag(mode, cfg.GatherMsg))) p.Action.Data["step"] = 1 - p.Action.WaitLeft = g.computeTicks(wait) + p.Action.WaitLeft = engine.ToTicks(wait) return } if step == 2 { st := g.World.GetObjStateByKey(instanceKey) if st != nil && st.Depleted { - p.Action.WaitLeft = g.computeTicks(3) + p.Action.WaitLeft = engine.ToTicks(3) return } if cfg.RespawnMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) + sess.WriteLine(fmt.Sprintf("\n%s", color.Tag(mode, cfg.RespawnMsg))) } p.Action.Data["step"] = 0 - p.Action.WaitLeft = g.computeTicks(wait) + p.Action.WaitLeft = engine.ToTicks(wait) return } @@ -215,7 +218,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if def, err := g.ItemStore.Load(cfg.Bait); err == nil { baitName = def.Name } - sess.WriteLine(fmt.Sprintf("You've run out of %s.", baitName)) + sess.WriteLine(color.Wrap(mode, "red", fmt.Sprintf("You've run out of %s.", baitName))) g.CancelAction(p) return } @@ -231,7 +234,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if drop != nil { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") + sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!")) g.CancelAction(p) return } @@ -253,17 +256,19 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } if xp > 0 { if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), xp); newLevel > 0 { - sess.WriteLine(fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill)) + sess.WriteLine(color.Wrap(mode, "bright_yellow", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))) } } g.AccountStore.SaveCharacter(p) msg := drop.Message if msg == "" { - msg = fmt.Sprintf("You manage to get some %s.", itemName) + msg = fmt.Sprintf("You manage to get some %s.", color.Wrap(mode, "green", itemName)) + } else { + msg = color.Tag(mode, msg) } if xp > 0 && p.OptionBool("xp_drops") { - msg += fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)]) + msg += color.Wrap(mode, "yellow", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)])) } sess.WriteLine(msg) @@ -279,13 +284,13 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { playerNames: []string{p.Name}, }) p.Action.Data["step"] = 2 - p.Action.WaitLeft = g.computeTicks(1) + p.Action.WaitLeft = engine.ToTicks(1) return } } if !shared && drop.Depletes { - delay := g.computeTicks(cfg.RespawnTimer) + delay := engine.ToTicks(cfg.RespawnTimer) if delay <= 0 { delay = 10 } @@ -295,44 +300,45 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { st.DepleteTimer = float64(delay) } - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other == sess { - continue - } - op, ok := other.Player.(*player.Player) - if !ok || op.Action == nil || op.Action.Type != "gather" { - continue - } - if op.Action.Data["instance_key"] == instanceKey { - other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", p.Action.TargetName, p.Name)) - g.CancelAction(op) - } + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other == sess { + continue } + op, ok := other.Player.(*player.Player) + if !ok || op.Action == nil || op.Action.Type != "gather" { + continue + } + if op.Action.Data["instance_key"] == instanceKey { + other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", color.Wrap(mode, "green", p.Action.TargetName), color.Wrap(mode, "bright_white", p.Name))) + g.CancelAction(op) + g.writePrompt(other) + } + } p.Action.Data["step"] = 2 - p.Action.WaitLeft = g.computeTicks(1) + p.Action.WaitLeft = engine.ToTicks(1) return } if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") + sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!")) g.CancelAction(p) return } p.Action.Data["step"] = 0 - p.Action.WaitLeft = g.computeTicks(wait) + p.Action.WaitLeft = engine.ToTicks(wait) return } } - sess.WriteLine(cfg.FailMsg) + sess.WriteLine(color.Tag(mode, cfg.FailMsg)) if p.FirstFreeSlot() == -1 { - sess.WriteLine("Your inventory is too full!") + sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!")) g.CancelAction(p) return } p.Action.Data["step"] = 0 - p.Action.WaitLeft = g.computeTicks(wait) + p.Action.WaitLeft = engine.ToTicks(wait) } func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry { @@ -350,7 +356,7 @@ func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, targetName string, playerNames []string) { st.Depleted = true - st.DepleteTimer = float64(g.computeTicks(cfg.RespawnTimer)) + st.DepleteTimer = float64(engine.ToTicks(cfg.RespawnTimer)) st.SharedTimer = 0 msg := cfg.ExhaustedMessage @@ -379,6 +385,7 @@ func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, t } sess.WriteLine(fmt.Sprintf("\n%s", msg)) g.CancelAction(op) + g.writePrompt(sess) } } diff --git a/internal/game/action_search.go b/internal/game/action_search.go index 07bae44..e9d3d46 100644 --- a/internal/game/action_search.go +++ b/internal/game/action_search.go @@ -4,6 +4,7 @@ import ( "fmt" "thirdcollapse/internal/action" + "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/player" ) @@ -24,7 +25,7 @@ func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, s Type: "search", TargetID: itemID, TargetName: def.Name, - WaitLeft: g.computeTicks(ticks), + WaitLeft: engine.ToTicks(ticks), Data: map[string]any{ "item_id": itemID, "slot_idx": slotIdx, diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index 8aa95be..4102841 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -54,7 +54,7 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object } func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { - sess.WriteLine(fmt.Sprintf("\n%s", node.Message)) + sess.WriteLine(fmt.Sprintf("\n%s", g.colorTag(sess, node.Message))) if node.Action != nil { g.applyNodeAction(sess, node.Action) @@ -62,7 +62,7 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { if len(node.Options) == 0 { sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -82,7 +82,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { p, ok := sess.Player.(*player.Player) if !ok || p.Action == nil || p.Action.Type != "talk" { sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -91,7 +91,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -100,7 +100,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { if err != nil { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -109,7 +109,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { if !ok { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -117,7 +117,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { if err != nil || idx <= 0 { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -138,14 +138,14 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { if chosen == nil { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } if chosen.End { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -159,7 +159,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { g.CancelAction(p) sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) } func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { diff --git a/internal/game/action_use.go b/internal/game/action_use.go index ec63723..d953521 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -5,6 +5,7 @@ import ( "math/rand" "thirdcollapse/internal/action" + "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" "thirdcollapse/internal/player" @@ -39,7 +40,7 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD Type: "use", TargetID: obj.ID, TargetName: obj.Name, - WaitLeft: g.computeTicks(1), + WaitLeft: engine.ToTicks(1), Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, } @@ -78,7 +79,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { if cfg.FailMsg != "" { sess.WriteLine(cfg.FailMsg) } - p.Action.WaitLeft = g.computeTicks(cfg.Wait) + p.Action.WaitLeft = engine.ToTicks(cfg.Wait) return } } @@ -119,5 +120,5 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { return } - p.Action.WaitLeft = g.computeTicks(cfg.Wait) + p.Action.WaitLeft = engine.ToTicks(cfg.Wait) } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index ca5bc4c..08780c3 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -6,7 +6,9 @@ import ( "strconv" "strings" + "thirdcollapse/internal/color" "thirdcollapse/internal/combat" + "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" "thirdcollapse/internal/player" @@ -130,10 +132,11 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn if len(styleParts) > 0 { styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" } - sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr)) + mode := g.colorMode(sess) + sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", color.Wrap(mode, "bright_red", mobDisplayName(mob, true)), styleStr)) p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} - g.Ticks.Subscribe(g.computeTicks(playerSpeed), func() bool { + g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool { cs := combat.GetCombat(p.Name) if cs == nil || !cs.Active { return false @@ -151,7 +154,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn return true }) - g.Ticks.Subscribe(g.computeTicks(mob.Speed), func() bool { + g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool { cs := combat.GetCombat(p.Name) if cs == nil || !cs.Active { return false @@ -182,6 +185,8 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI } } + mode := g.colorMode(sess) + attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) defRoll := combat.DefenseRoll(mob.Defense, 0, 0) @@ -199,7 +204,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI gains, leveled := g.awardCombatXP(p, dmg) for _, skill := range leveled { - sess.WriteLine(fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill)) + sess.WriteLine(color.Wrap(mode, "bright_yellow", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) } mobName := mobDisplayName(mob, true) @@ -212,7 +217,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI w = w2 } g.combatPadWidth = w - prefix := fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg) + prefix := fmt.Sprintf(" You hit %s for %s damage.", color.Wrap(mode, "bright_red", mobName), color.Wrap(mode, "red", fmt.Sprint(dmg))) hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP) line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) if p.OptionBool("xp_drops") && len(gains) > 0 { @@ -220,11 +225,11 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI for _, gain := range gains { parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) } - line += " (" + strings.Join(parts, ", ") + ")" + line += color.Wrap(mode, "yellow", " ("+strings.Join(parts, ", ")+")") } sess.WriteLine(line) } else { - sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))) + sess.WriteLine(color.Wrap(mode, "dim", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) } } @@ -242,6 +247,8 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst attRoll := combat.AttackRoll(mob.Attack, 0, 0) defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef) + mode := g.colorMode(sess) + if combat.HitCheck(attRoll, defRoll) { maxHit := combat.MaxHit(mob.Strength, 0, 0) dmg := combat.RollDamage(maxHit) @@ -263,7 +270,7 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst w = w2 } g.combatPadWidth = w - prefix := fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg) + prefix := fmt.Sprintf(" %s hits you for %s damage.", color.Wrap(mode, "bright_red", attacker), color.Wrap(mode, "red", fmt.Sprint(dmg))) hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP()) sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)) } else { @@ -271,7 +278,7 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst if !mob.Unique { attacker = "The " + mob.Name } - sess.WriteLine(fmt.Sprintf(" %s misses you.", attacker)) + sess.WriteLine(color.Wrap(mode, "dim", fmt.Sprintf(" %s misses you.", attacker))) } } @@ -279,9 +286,10 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst g.combatPadWidth = 0 combat.LeaveCombat(p.Name) p.ActionState = nil + mode := g.colorMode(sess) if p.HP <= 0 { - sess.WriteLine("\nOh dear, you are dead!") + sess.WriteLine(color.Wrap(mode, "red", "\nOh dear, you are dead!")) g.dropItemsOnDeath(p) p.HP = p.MaxHP() p.RoomID = 1 @@ -290,11 +298,12 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst g.Hub.EnterRoom(sess, p.RoomID) } g.doLook(sess) + g.writePrompt(sess) return } if mob != nil && mob.HP <= 0 { - sess.WriteLine(fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))) + sess.WriteLine(color.Wrap(mode, "green", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { @@ -342,15 +351,16 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } } - respawnTicks := g.computeTicks(mob.RespawnTicks) + respawnTicks := engine.ToTicks(mob.RespawnTicks) if respawnTicks <= 0 { - respawnTicks = g.computeTicks(30) + respawnTicks = engine.ToTicks(30) } instanceID := mob.InstanceID g.Ticks.Subscribe(respawnTicks, func() bool { g.respawnMob(instanceID) return false }) + g.writePrompt(sess) } } diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go index 6875840..56ce441 100644 --- a/internal/game/cmd_description.go +++ b/internal/game/cmd_description.go @@ -30,5 +30,5 @@ func (g *Game) handleDescriptionChange(sess *net.Session, input string) { sess.WriteLine("Description left unchanged.") } sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) } diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index dc506f0..2bf00ce 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "thirdcollapse/internal/color" "thirdcollapse/internal/net" "thirdcollapse/internal/player" ) @@ -90,6 +91,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) { func (g *Game) doDrop(sess *net.Session, input string) { p := sess.Player.(*player.Player) g.CancelAction(p) + mode := g.colorMode(sess) qty, itemName := parseQty(input) @@ -149,9 +151,9 @@ func (g *Game) doDrop(sess *net.Session, input string) { g.World.AddGroundItem(p.RoomID, itemID, qty) g.AccountStore.SaveCharacter(p) if qty == 1 { - sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) + sess.WriteLine(fmt.Sprintf("You drop a %s.", color.Wrap(mode, "green", name))) } else { - sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name)) + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, color.Wrap(mode, "green", name))) } return } @@ -190,15 +192,15 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { p, ok := sess.Player.(*player.Player) if !ok { sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } - g.CancelAction(p) + g.CancelAction(p) input = strings.TrimSpace(strings.ToLower(input)) if input != "y" && input != "yes" { sess.State = net.StateGame - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -227,5 +229,5 @@ func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { sess.Write(fmt.Sprintf("\nYou drop your ")) formatPickupList(sess, dropped) } - sess.Write("\r\n> ") + g.writePrompt(sess) } diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 7ec183f..9591657 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -3,6 +3,7 @@ package game import ( "fmt" + "thirdcollapse/internal/color" "thirdcollapse/internal/net" "thirdcollapse/internal/player" ) @@ -10,6 +11,7 @@ import ( func (g *Game) doGet(sess *net.Session, input string) { p := sess.Player.(*player.Player) g.CancelAction(p) + mode := g.colorMode(sess) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") @@ -85,7 +87,7 @@ func (g *Game) doGet(sess *net.Session, input string) { } slot.Quantity += qty g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, def.Name, slot.Quantity)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, color.Wrap(mode, "green", def.Name), slot.Quantity)) return } } @@ -101,9 +103,9 @@ func (g *Game) doGet(sess *net.Session, input string) { p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty)) g.AccountStore.SaveCharacter(p) if qty == 1 { - sess.WriteLine(fmt.Sprintf("You pick up a %s.", def.Name)) + sess.WriteLine(fmt.Sprintf("You pick up a %s.", color.Wrap(mode, "green", def.Name))) } else { - sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, def.Name)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, color.Wrap(mode, "green", def.Name))) } return } @@ -393,35 +395,42 @@ func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) } } -func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { - ground := g.World.GroundItems(roomID) +func (g *Game) collectMatches(input string, each func(func(itemID string, slot int) bool)) []itemMatch { var matches []itemMatch - for itemID := range ground { + each(func(itemID string, slot int) bool { def, err := g.ItemStore.Load(itemID) if err != nil { - continue + return true } if def.MatchesName(input) { - matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1}) + matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: slot}) } - } + return true + }) return matches } -func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { - var matches []itemMatch - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil { - continue +func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { + ground := g.World.GroundItems(roomID) + return g.collectMatches(input, func(yield func(string, int) bool) { + for itemID := range ground { + if !yield(itemID, -1) { + return + } } - if def.MatchesName(input) { - matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i}) + }) +} + +func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { + return g.collectMatches(input, func(yield func(string, int) bool) { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + if !yield(slot.ItemID, i) { + return + } } - } - return matches + }) } diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index ac0c42a..992f3d0 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "thirdcollapse/internal/color" "thirdcollapse/internal/combat" "thirdcollapse/internal/net" "thirdcollapse/internal/player" @@ -20,16 +21,17 @@ func (g *Game) doLook(sess *net.Session) { return } + mode := g.colorMode(sess) sess.WriteLines( "", - fmt.Sprintf("%s (#%d)", room.Name, room.ID), + fmt.Sprintf("%s (#%d)", color.Wrap(mode, "cyan", room.Name), room.ID), ) descWidth := p.OptionInt("room_desc_width") if descWidth <= 0 { descWidth = 70 } - descLines := wrapText(room.Description, descWidth) + descLines := wrapText(g.colorTag(sess, room.Description), descWidth) wroteDesc := false if p.OptionString("tiny_map") != "off" { mapLines := buildTinyMap(g, p.RoomID, mapGlyphsForPlayer(p.OptionBool("unicode"))) diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 0b6571e..65a2875 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -3,6 +3,7 @@ package game import ( "fmt" + "thirdcollapse/internal/color" "thirdcollapse/internal/combat" "thirdcollapse/internal/net" "thirdcollapse/internal/player" @@ -64,6 +65,8 @@ func (g *Game) doMove(sess *net.Session, dir string) { g.seedRoomMobs(p.RoomID) g.seedRoomObjects(p.RoomID) + mode := g.colorMode(sess) + if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(oldRoom) { if other != sess { @@ -78,14 +81,14 @@ func (g *Game) doMove(sess *net.Session, dir string) { } } - sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) + sess.WriteLine(fmt.Sprintf("\nYou walk %s.", color.Wrap(mode, "cyan", string(exitDir)))) p.ActionState = &ActionState{Type: ActionMoving, Direction: string(exitDir)} if p.OptionBool("description") { g.doLook(sess) } else { targetRoom, _ := g.World.LoadRoom(targetID) if targetRoom != nil { - sess.WriteLine(targetRoom.Name) + sess.WriteLine(color.Wrap(mode, "cyan", targetRoom.Name)) } } diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 90e7019..446305b 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -2,6 +2,7 @@ package game import ( "thirdcollapse/internal/combat" + "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/player" ) @@ -19,7 +20,7 @@ func (g *Game) doQuit(sess *net.Session) { g.cancelRest(p.Name) ticksLeft := 10 - id := g.Ticks.Subscribe(g.computeTicks(1), func() bool { + id := g.Ticks.Subscribe(engine.ToTicks(1), func() bool { switch ticksLeft { case 10: sess.WriteLine("You sit down to rest...") diff --git a/internal/game/color.go b/internal/game/color.go new file mode 100644 index 0000000..45df212 --- /dev/null +++ b/internal/game/color.go @@ -0,0 +1,22 @@ +package game + +import ( + "thirdcollapse/internal/color" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) colorMode(sess *net.Session) string { + if p, ok := sess.Player.(*player.Player); ok && p != nil { + return p.OptionString("color") + } + return "none" +} + +func (g *Game) colorize(sess *net.Session, name, text string) string { + return color.Wrap(g.colorMode(sess), name, text) +} + +func (g *Game) colorTag(sess *net.Session, text string) string { + return color.Tag(g.colorMode(sess), text) +} diff --git a/internal/game/game.go b/internal/game/game.go index b08c97e..3c00d7e 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -8,6 +8,7 @@ import ( "time" "thirdcollapse/internal/action" + "thirdcollapse/internal/combat" "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" @@ -21,6 +22,7 @@ const ( ClassInstant CommandClass = iota ClassFree ClassActive + ClassUnknown ) type QueuedCommand struct { @@ -45,7 +47,6 @@ type Game struct { charsMu sync.Mutex loggedInChars map[string]*net.Session combatPadWidth int - GameSpeed float64 freeQueue map[string][]QueuedCommand activeQueue map[string]*QueuedCommand pendingDepletions []pendingDepletion @@ -126,14 +127,40 @@ func classifyCommand(cmd string) CommandClass { return ClassInstant case "wear", "wield", "remove", "unwear", "unwield", "style": return ClassFree - default: + case "get", "take", "grab", "pick", "drop", + "attack", "kill", + "north", "n", "south", "s", "east", "e", + "west", "w", "up", "u", "down", "d", + "quit", "use", "burn", "stoke", "search", "walk": + return ClassActive + } + if _, ok := verbAliases[cmd]; ok { return ClassActive } + return ClassUnknown +} + +func (g *Game) promptStr(sess *net.Session) string { + prompt := "> " + if p, ok := sess.Player.(*player.Player); ok && p != nil { + if custom := p.OptionString("prompt"); custom != "" { + prompt = custom + } + } + return g.colorTag(sess, prompt) +} + +func (g *Game) writePrompt(sess *net.Session) { + sess.Write("\r\n" + g.promptStr(sess)) +} + +func (g *Game) reprompt(sess *net.Session) { + sess.Write(g.promptStr(sess)) } func (g *Game) handleGameCommand(sess *net.Session, input string) { if input == "" { - sess.Write("> ") + g.reprompt(sess) return } @@ -161,7 +188,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { if class == ClassInstant { g.executeCommand(sess, cmd, parts[1:], input) - sess.Write("\r\n> ") + g.writePrompt(sess) return } @@ -183,6 +210,12 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return } + if class == ClassUnknown { + sess.WriteLine("Unknown command.") + g.writePrompt(sess) + return + } + g.activeQueue[p.Name] = &QueuedCommand{ Session: sess, Command: cmd, @@ -224,9 +257,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doDrop(sess, strings.Join(args, " ")) } case "attack", "kill": - if p == nil { - return - } if len(args) == 0 { target := g.resolveDefaultMob(p.RoomID) if target == "" { @@ -290,9 +320,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doHelp(sess, strings.Join(args, " ")) } case "mine", "chop", "fish", "cut", "use", "pull", "push": - if p == nil { - return - } g.CancelAction(p) if len(args) == 0 { target := g.resolveDefaultTarget(p.RoomID, cmd) @@ -307,9 +334,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI return } case "talk", "speak", "ask": - if p == nil { - return - } g.CancelAction(p) if len(args) == 0 { sess.WriteLine("Talk to whom?") @@ -318,16 +342,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI return } case "burn": - if p == nil { - return - } g.CancelAction(p) g.doBurn(sess, p, strings.Join(args, " ")) return case "stoke": - if p == nil { - return - } g.CancelAction(p) g.doStoke(sess, p, strings.Join(args, " ")) return @@ -338,9 +356,6 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doUnalias(sess, args) return case "search": - if p == nil { - return - } g.CancelAction(p) if len(args) == 0 { sess.WriteLine("Search what?") @@ -349,25 +364,41 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI } return case "walk": - if p == nil { - return - } g.doWalk(sess, args) return case "wear", "wield": - if p == nil { - return - } g.doWear(sess, strings.Join(args, " ")) return case "remove", "unwear", "unwield": - if p == nil { - return - } g.doRemove(sess, strings.Join(args, " ")) return default: - sess.WriteLine("Unknown command.") + if a, ok := verbAliases[cmd]; ok { + g.CancelAction(p) + switch a { + case "gather", "toggle": + if len(args) == 0 { + target := g.resolveDefaultTarget(p.RoomID, cmd) + if target == "" { + sess.WriteLine(fmt.Sprintf("%s what?", cmd)) + break + } + g.StartAction(sess, cmd, target) + return + } + g.StartAction(sess, cmd, strings.Join(args, " ")) + return + case "talk": + if len(args) == 0 { + sess.WriteLine("Talk to whom?") + } else { + g.StartAction(sess, "talk", strings.Join(args, " ")) + return + } + } + } else { + sess.WriteLine("Unknown command.") + } } } @@ -405,7 +436,7 @@ func (g *Game) ProcessQueuedCommands() { if len(parts) > 0 { g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) } - qc.Session.Write("\r\n> ") + g.writePrompt(qc.Session) } delete(g.freeQueue, p.Name) } @@ -420,10 +451,18 @@ func (g *Game) ProcessQueuedCommands() { for _, qc := range actives { parts := strings.Fields(qc.Command + " " + qc.Args) + p, ok := qc.Session.Player.(*player.Player) + if !ok || p == nil { + continue + } + wasBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil if len(parts) > 0 { g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) } - qc.Session.Write("\r\n> ") + isBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil + if wasBusy || !isBusy { + g.writePrompt(qc.Session) + } } g.activeQueue = make(map[string]*QueuedCommand) @@ -433,6 +472,9 @@ func (g *Game) ProcessQueuedCommands() { continue } g.advanceWalk(sess, p) + if len(p.WalkSequence) == 0 { + g.writePrompt(sess) + } } g.flushPendingDepletions() @@ -445,6 +487,6 @@ type pendingDepletion struct { playerNames []string } -func (g *Game) computeTicks(base float64) int { - return engine.FractionalTicks(base, g.GameSpeed) +func (g *Game) TickCombat() { + combat.TickCombat() } diff --git a/internal/game/tick.go b/internal/game/tick.go index 767109e..f326ad8 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -109,6 +109,7 @@ func (g *Game) WanderTick() { if p, ok := sess.Player.(*player.Player); ok && p.Action != nil { if p.Action.TargetID == om.DefID { g.CancelAction(p) + g.writePrompt(sess) } } } diff --git a/internal/player/player.go b/internal/player/player.go index 6fa22ce..47c2c37 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -123,6 +123,7 @@ var OptionDefs = []OptionDef{ {"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"}, {"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"}, {"unicode", OptBool, true, nil, "Unicode box-drawing characters"}, + {"prompt", OptString, "> ", nil, "Custom command prompt"}, } var optionByName map[string]*OptionDef diff --git a/internal/world/world.go b/internal/world/world.go index a554432..7768739 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -577,17 +577,18 @@ func (w *World) TickObjWander() { continue } st.WanderCounter++ - if float64(st.WanderCounter) >= st.WanderInterval { - st.WanderCounter = 0 - toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))] - if toRoom == st.RoomID { - continue - } - moves = append(moves, struct { - st *ObjState - toRoom int - }{st, toRoom}) + if float64(st.WanderCounter) < st.WanderInterval { + continue + } + st.WanderCounter = 0 + toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))] + if toRoom == st.RoomID { + continue } + moves = append(moves, struct { + st *ObjState + toRoom int + }{st, toRoom}) } for _, m := range moves { oldKey := w.objStateKey(m.st.RoomID, m.st.DefID, m.st.Index) |
