diff options
| author | historia <[not public]> | 2026-06-16 01:59:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-16 01:59:27 -0400 |
| commit | 43f21aabae8924f35c12c8485e690aeefe0089fb (patch) | |
| tree | b5fbadb89df3cf4ffec86503bb8e2d7e52b27454 | |
| parent | 4cc1ddcd185509080b4b3e15d1646567edd51c9d (diff) | |
| download | thehouseoficarus-43f21aabae8924f35c12c8485e690aeefe0089fb.tar.gz | |
feat: overhauled ansi color, added prompt options
153 files changed, 1317 insertions, 716 deletions
@@ -8,4 +8,4 @@ tc .local/ .config/ node_modules/ -REFERENCE_ONLY/ +IDEAS.md @@ -2,62 +2,70 @@ Third Collapse — a Runescape-like sci-fi MUD written in Go. Data-driven via YAML files. No database, no ORM. -See `TODO.md` for the full project overview, implemented features, and roadmap. +Set on a terraformed asteroid where technology has regressed. "Science" replaces Prayer, "Technology" replaces Magic, Scavenging replaces Runecrafting. ## Build & Run ```bash make build # go build -o tc ./cmd/mud make run # build + run with DATA_DIR=./data -make test # go test ./... +make test # go test ./... -timeout 60s make vet # go vet ./... ``` Binary accepts `--config <path>` (defaults to `config.yaml`). If no config found, starts telnet on :4000. -Config options: +Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`. + +Config: ```yaml game: tick_length: 600 # ms per game tick (default 600, min 50) ``` -Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`. - ## Package Map ``` cmd/mud/main.go Entry point — config loading, multi-listener, tick engine -internal/config/ YAML config (telnet, http, https listeners) + doc.go -internal/net/ Conn interface (tcp + websocket), session state, hub, IAC echo + doc.go -internal/game/ Login flow, command dispatch, input sanitization, all game logic + doc.go -internal/action/ Behavior structs (GatherConfig, TalkConfig, etc.), Action, drop tables + doc.go -internal/player/ Player struct, skills, inventory, XP, name/password validation + doc.go -internal/world/ Room, MobDef, MobInstance, ObjState, ground items, wandering + doc.go -internal/combat/ Combat state tracking, OSRS-style combat formulas + doc.go -internal/object/ ItemDef, ObjectDef, item/object YAML loaders + doc.go -internal/engine/ Tick scheduler — 600ms interval, subscriber pattern + doc.go +internal/ + config/ YAML config (telnet, http, https listeners) + net/ Conn interface (tcp + websocket), session states, hub, IAC echo + game/ Login flow, command dispatch, input sanitization, all game logic + action/ Behavior structs (GatherConfig, TalkConfig, etc.), Action, drop tables, SuccessChance + player/ Player struct, skills, inventory, XP (RSC table), name/password validation, accounts + world/ Room, MobDef, MobInstance, ObjState, ground items, wandering, drop tables + combat/ Combat state tracking, combat lock, OSRS-style combat formulas + object/ ItemDef, ObjectDef, item/object YAML loaders, EquipSlot, WeaponType, ItemStats + engine/ Tick scheduler — configurable ms interval, subscriber pattern, ToTicks() + color/ ANSI 16-color and xterm256-color escape code tags (<red>text</red>, <bold>, etc.) ``` `internal/game/` is the largest package. Key files: | File | Purpose | |---|---| -| `game.go` | Game struct, HandleSession state machine, command dispatch | +| `game.go` | Game struct, HandleSession state machine, command dispatch, ProcessQueuedCommands | | `login_account.go` | Account auth, creation, password handling, main menu, account rename/purge | | `login_char.go` | Character creation, connection, rename, delete | -| `cmd_*.go` | Command handlers (see Adding a New Command below) | -| `action.go` | StartAction, CancelAction, AdvanceActions, checkCondition | -| `action_*.go` | Action lifecycle per behavior type (gather, talk, use, toggle, burn) | -| `action_state.go` | ActionState type, ActionType consts, Description for look display | +| `cmd_*.go` | Command handlers for each command (see Commands section) | +| `action.go` | StartAction, CancelAction, AdvanceActions, checkCondition, verbAliases, verbSkill | +| `action_*.go` | Action lifecycle per type (gather, talk, use, toggle) | +| `action_burn.go` | Burn/stoke firemaking (standalone, not behavior-YAML-driven) | +| `action_search.go` | Search bird's nests for loot | +| `action_room.go` | RunEnterSteps (on_enter scripts), BroadcastRespawns | +| `action_state.go` | ActionType consts, ActionState struct, Description() for look display | +| `action_default_target.go` | Smart auto-selection for gather/attack | | `tick.go` | DisconnectTick, RegenTick, WanderTick, SharedDepletionTick | | `map.go` | BFS graph builder, tiny map, full map, display utilities (strip, trim) | -| `types.go` | Shared types (EquipSlots, itemMatch, xpGain, deathDrop) | +| `types.go` | EquipSlots, itemMatch, xpGain, deathDrop | | `utils.go` | Helper functions (plural, parseQty, parseChoiceIndex, formatPickupList, etc.) | | `help.go` | Help topic YAML loading and display | +| `color.go` | colorMode(), colorize(), colorTag() helpers | +| `table.go` | Unicode/ASCII table rendering for score/option/help | ## Key Conventions -**YAML-driven.** Rooms, items, objects, mobs, behaviors, drops are all YAML files under `data/`. Read from disk on every access — edit a room description and it takes effect immediately, no restart. +**YAML-driven.** Rooms, items, objects, mobs, behaviors, drops are YAML files under `data/`. Read from disk on every access — edit a room description and it takes effect immediately, no restart. **Live state.** Player data (characters, accounts) is written to YAML on every state change. Ground items, mob instances, and object states live in memory only and are lost on restart. @@ -65,151 +73,421 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern + **Transport-agnostic.** The `Conn` interface (`internal/net/conn.go`) abstracts the transport layer. `tcpConn` wraps a raw `net.Conn` for telnet. `wsConn` wraps `gorilla/websocket.Conn` for the web client. Game code works with `*Session` regardless of transport. -**Input sanitization.** `HandleSession` strips Unicode control characters from every input line via `player.StripControlCharacters` before dispatch. Account/character names validated to `[A-Za-z0-9 ]{1,30}`. Input truncated to 1024 bytes. Password echo suppressed via IAC ECHO (telnet) and OSC sequences (web). WebSocket origin checked against host header. +**Input sanitization.** `HandleSession` strips Unicode control characters from every input line via `player.StripControlCharacters` before dispatch. Account/character names validated to `[A-Za-z0-9 ]{1,30}`. Input truncated to 1024 bytes. Password echo suppressed via IAC ECHO (telnet) and OSC sequences (web). WebSocket origin checked against host header + loopback. -**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. +**Tick-driven.** The world runs on a configurable tick (default 600ms). Combat, gathering, regen, wandering, respawns, shared depletion, fire ticks all advance per tick. **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. +**Concurrency.** Every state-bearing package uses its own `sync.Mutex`: +- `world.World` — groundItems, objStates, seeded, objMoves +- `world.MobStore` — defs, instances +- `action.Store` — cache +- `engine.Engine` — subscribers +- `Game` — charsMu (loggedInChars map) +- `combat` — combatants, mobTargets + +The pattern is: lock, snapshot/unmarshal, unlock, then process. The engine creates a subscriber snapshot each tick. + ## Two State Systems - **World flags** (`set_flags` / checked with `flag`): Shared by all players. A door opened by one player is open for everyone. - **Player flags** (`set_player_flags` / checked with `player_flag`): Per-character, saved to character YAML. Quest progress, dialog history. +## Data Files + +``` +data/rooms/<id>.yaml Room definitions (exits, objects, mobs, spawns, on_enter) +data/items/<id>.yaml Item definitions (stats, equip_slot, tool_type, burn_ticks, search_table, etc.) +data/mobs/<id>.yaml Mob definitions (combat stats, drops, behavior — NO wander config) +data/objects/<id>.yaml Object definitions (name, behavior, hidden, inroom_description, removal_item, props) +data/behaviors/<id>.yaml Behaviors (gather, talk, use, toggle) +data/drops/<id>.yaml Shared drop tables (weighted item lists) +data/help/<id>.yaml Help topics +data/players/accounts/ Account YAML (gitignored) +data/players/characters/ Character YAML (gitignored) +``` + +Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YAML — mob defs are generic. Mobs wander via legal (unconditioned) room exits; objects teleport between rooms in their list. + +See `WORLDBUILDING.md` for full YAML format reference with examples. + +## Commands + +### Instant (execute immediately, no queue) +| Command | Description | +|---------|-------------| +| `say <text>` | Room chat (preserves case from raw input) | +| `score` / `sc` | Character stats and skills | +| `inventory` / `i` / `inv` | Inventory contents (28 slots) | +| `equipment` / `eq` | Equipped items by slot | +| `look` / `l` | Room view (mobs, objects, ground items, exits, players) | +| `look <target>` / `l <target>` | Examine mobs, objects, items, players, directions | +| `exits` | List available exits | +| `help [topic]` | Help topics from data/help/ | +| `map` | BFS ASCII map centered on player | +| `option` / `options [name] [value]` | Character settings (see Option System) | +| `alias <name> <cmd>` | Account-wide command alias with arg passthrough | +| `unalias <name>` | Remove an alias | +| `description` / `desc` | Set custom player description | +| `queued` | Show pending tick actions | + +### Free (stackable, execute before active) +| Command | Description | +|---------|-------------| +| `wear` / `wield <item>` | Equip item to correct slot. `wear all` auto-equips best per slot | +| `wear all` | Auto-equips highest-value item per slot from inventory | +| `remove` / `unwear` / `unwield <item>` | Unequip item back to inventory | +| `style <name>` | Set combat style: accurate, aggressive, defensive, balanced (prefix match) | + +### Active (replaces previous active, queued per tick) +| Command | Description | +|---------|-------------| +| `n` / `s` / `e` / `w` / `u` / `d` | Movement (conditional exits, interrupts combat/actions) | +| `get` / `take` / `grab` / `pick <item>` | Pick up item from ground (reservation-aware) | +| `get all` / `get all <name>` | Pick up all (or matching) items | +| `drop <item>` | Drop item to ground | +| `drop all` / `drop all <name>` | Drop all (or matching) items | +| `attack` / `kill <mob>` | Combat with numbered targeting and smart default | +| `mine` / `chop` / `cut` / `fish <object>` | Gathering with smart default (verbAliases map to "gather") | +| `use <object>` | Crafting station interaction | +| `talk` / `speak` / `ask <target>` | NPC conversations (objects or mobs with behavior) | +| `pull` / `push <object>` | Toggle interactions (levers, gates) | +| `search <item>` | Search bird's nests / searchable items (rolls on search_table) | +| `burn [item]` | Start a fire (smart default: auto-select single log type) | +| `stoke [item]` | Add logs to existing fire for XP | +| `walk <room_number>` | BFS pathfinding to a room by number | +| `walk <directions>` | Direction sequence like `3n2e1u` (capped by agility level) | +| `quit` | 10-tick rest sequence, then return to menu (blocked during combat) | + ## Command Dispatch -`internal/game/game.go` — `handleGameCommand()`: +`handleGameCommand()` in `internal/game/game.go`: 1. Cancel rest timer -2. Resolve account aliases (alias expansion happens BEFORE command lookup) -3. Switch on command word -4. Most commands call into `cmd_*.go` files - -Actions that interact with objects/mobs route through `StartAction()` in `action.go`, which: -1. Normalizes verb (mine/chop/fish/cut → gather, talk/speak/ask → talk, pull/push → toggle) -2. Checks for ambiguous matches (multiple object types) -3. Searches object instances in room +2. Resolve account aliases (alias expansion BEFORE command lookup — can shadow built-ins) +3. Classify command (Instant / Free / Active / Unknown) +4. Instant runs immediately; Free/Active queue for next tick + +## Tick-Based Action Queue + +`ProcessQueuedCommands` runs each tick: +1. Clears stale ActionState from one-tick actions (move, get, drop) +2. Executes all free commands per player in queued order +3. Executes all active commands sorted globally by timestamp (first-to-queue wins conflicts) +4. Advances walk sequences (one step per tick) +5. Flushes pending shared depletions + +**Conflict resolution:** multi-player attack on same mob or get on same item — first to queue wins (timestamp-order). Shared depletion defers to award all successful gatherers on depletion tick. + +**ActionState** tracks player activity for `look` display. Timed actions (gather, combat, burn, stoke, use, rest, walk) persist until interrupted. One-tick actions (move, get, drop) clear after one tick. + +Queue feedback controlled by `queue_silently` option (default on). + +## Action System + +Actions that interact with objects/mobs route through `StartAction()` in `action.go`: +1. Normalizes verb via `verbAliases` map (mine/chop/fish/cut → gather, talk/speak/ask → talk, pull/push → toggle) +2. Multiple object types → "That's ambiguous, which one?" +3. Searches object instances in room (numbered targeting via `N.name`) 4. Falls back to mob instances if no object matches 5. Loads the behavior from YAML 6. Routes to type-specific handler (startGather, startTalk, startUse, startToggle) -## Adding a New Skill +The `verbSkill` map maps verb → skill name for default target resolution: +- mine → mining, chop/cut → woodcutting, fish → fishing -1. Add `SkillName` constant + entry in `AllSkills` + `SkillAbbr` in `internal/player/player.go` -2. Create a gather behavior YAML in `data/behaviors/` -3. Create an object YAML referencing that behavior in `data/objects/` -4. Create an item for the tool in `data/items/` (with `tool_type`) -5. Place the object in a room YAML in `data/rooms/` -6. Create any resource items in `data/items/` +### Action Types -Set `xp` on the behavior to award XP on successful gathers. Set `deplete_timer` for tree-style depletion, or use per-drop `depletes: true` for rock-style depletion. +| ActionType | Trigger | Description | +|---|---|---| +| `gathering` | mine/chop/cut/fish | Resource gathering with skill checks, tool reqs, depletion | +| `combating` | attack/kill | Combat with attack/defense rolls | +| `using` | use | Crafting — consume items, produce output | +| `talking` | talk/speak/ask | NPC conversation trees with choices/conditions/actions | +| `toggling` | pull/push | Levers/switches — set world flags | +| `burning` | burn | Firemaking startup (3 phases) | +| `stoking` | stoke | Adding logs to existing fire | +| `searching` | search | Search bird's nests / items with search_table | +| `resting` | quit | 10-tick rest before disconnect | +| `walking` | walk | Multi-step movement (one dir per tick) | +| `moving` | n/s/e/w/u/d | One-tick movement state | +| `picking_up` | get | One-tick pickup state | +| `dropping` | drop | One-tick drop state | + +### Behavior Types (YAML-driven) + +| Type | Verbs | Use | +|---|---|---| +| `gather` | mine, chop, cut, fish | Resource gathering with skill checks, tool requirements, depletion/respawn | +| `use` | use | Crafting stations — consume items, produce output, optional skill checks | +| `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, actions | +| `toggle` | pull, push | Levers, switches, gates — set world flags, conditional on existing values | -## Adding a New Production Skill +### Behaviors Not in YAML (hardcoded) -Same as gathering but use `type: use` behavior (see `UseConfig` in `internal/action/behavior.go`). Smithing is the reference example planned. +| Action | File | Description | +|---|---|---| +| burn | `action_burn.go` | 3-phase firemaking: drop log → try burn → skill check → create fire object | +| stoke | `action_burn.go` | Add logs to fire, XP per log, increments fire quality | +| search | `action_search.go` | Search items with search_table, consumes one, rolls on drop table | -## Adding a New Command +### GatherConfig Fields -1. Add a `cmd_*.go` file in `internal/game/` with the handler -2. Add a case in `handleGameCommand()` in `internal/game/game.go` -3. Update `data/help/` YAML files +| Field | Description | +|---|---| +| `skill` | Skill name for level check | +| `level` | Required level | +| `xp` | XP per successful gather | +| `base_wait` | Base ticks per action cycle | +| `tools` | Required tool_type list | +| `bait` | Required bait item | +| `success` | SuccessFormula (base + per_level, capped) | +| `drops` | Weighted drop entries (item_id, table ref, depletes, quantity, message, level, xp) | +| `respawn_timer` | Ticks until depleted object respawns | +| `deplete_timer` | Ticks for shared depletion (trees) | +| `nest_chance` | 1/N chance for bird's nest alongside normal drop | +| `gather_message` | Custom "You gather..." message | +| `depleted_message` | Custom "The rock is depleted" message | +| `exhausted_message` | Custom "The tree falls" message | +| `fail_message` | Custom failure message | +| `respawn_broadcast` | Broadcast message when object respawns | + +### SuccessChance Formula + +`SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64`: +``` +chance = cfg.Base + (level - requiredLevel) * cfg.PerLevel +clamped to [0, cfg.Cap] +``` -## Firemaking (`burn` / `stoke`) +### Node Actions (talk dialog) -Firemaking is implemented as standalone actions (not behavior-YAML-driven) in `internal/game/action_burn.go`. The flow: +| Field | Effect | +|---|---| +| `set_flags` | Sets world flags (global) | +| `set_player_flags` | Sets player-local flags (per-character, saved to YAML) | +| `give_item` | Gives an item to inventory | +| `take_item` | Removes an item from inventory | +| `teleport` | Moves player to a room ID | +| `heal` | Restores hitpoints | + +### Condition System -| Command | Action | Description | -|---------|--------|-------------| -| `burn [item]` | `"burn"` | Start a fire from logs. Checks ground items first, then inventory. Requires a `tool_type: fire` item. | -| `stoke [item]` | `"stoke"` | Add logs from inventory to an existing fire object for XP. No tool required. | +Used by exits, talk options, on-enter scripts, and toggle checks: + +| Field | Scope | Example | +|---|---|---| +| `flag` | World (shared by all players) | `flag: gate_open` | +| `player_flag` | Per-character (quest progress) | `player_flag: finished_tutorial` | +| `has_item` | Player inventory check | `has_item: bronze_key` | +| `all_of` | All sub-conditions must pass | Nested list | +| `any_of` | Any sub-condition passes | Nested list | +| `not` | Invert the check | `not: true` | + +### Firemaking (burn/stoke) + +See `internal/game/action_burn.go`. **Burn flow (3 phases):** - Phase 0 (inventory only): Drops 1 log to ground, outputs "You drop the X and begin to make a fire..." - Phase 1: "You try burning the X on the ground..." (waits tool_speed ticks) -- Phase 2: Skill check (firemaking level vs log's `fire_level`). On fail, outputs "You can't seem to get a fire going" and retries at Phase 1. On success, creates a `fire` object (`quality` = log's `burn_ticks`), gives XP, broadcasts to room. +- Phase 2: Skill check (chance = 0.5 + (level - fireLevel) * 0.02, capped [0.05, 0.95]). On success creates `fire` object (quality = log's burn_ticks), gives XP, broadcasts. On fail retries Phase 1. -**Stoke flow:** -- One-time "You begin to tend to the fire." -- Loops: waits 10 ticks → "You throw X onto the fire", gives XP, adds `burn_ticks/2` to fire `quality` -- Ends when out of the item: "You're out of X and the fire is roaring." +**Stoke flow:** 10-tick loop → "You throw X onto the fire", gives XP, adds burn_ticks/2 to fire quality. Ends when out of item. -**Fire objects:** -- Created dynamically via `World.AddObjInstance()`, tracked by `ObjState.Quality`. -- Each tick, `FireTick()` decrements quality; at 0, `RemoveObjInstance()` cleans up. -- `inroom_description` on object YAML replaces the generic "A X is here." -- `props.quality_description` (with `{quality}` placeholder) is used by `look <fire>`. +**Fire objects:** Created via `World.AddObjInstance()`, tracked by `ObjState.Quality`. Each tick `FireTick()` decrements quality; at 0, `RemoveObjInstance()` cleans up and drops `removal_item` (e.g., ashes). -**Item fields for firemaking:** -- `burn_ticks: <int>` — how long the log burns (fire quality). Only items with `burn_ticks > 0` are burnable. -- `fire_level: <int>` — required firemaking level to burn. -- `fire_xp: <int>` — XP awarded. -- `quality: <int>` / `max_quality: <int>` — for tools like lighter (start value / max value). -- `EquipQuality map[string]int` on Player tracks quality of equipped items with quality. +**Fire tools:** `matches` (stackable, consumed), `firesteel` (non-consumable), `lighter` (quality-based, `MaxQuality: 100`, decreases per use). Tool search: main hand → inventory. -**Fire tools:** `matches` (stackable, consumed on use), `firesteel` (non-consumable), `lighter` (quality-based, `MaxQuality: 100`, decreases per use). +**Item fields for firemaking:** `burn_ticks`, `fire_level`, `fire_xp`, `quality`, `max_quality`. -**Smart defaults:** `burn` auto-selects if only one burnable type on ground OR in inventory. `stoke` auto-selects if only one burnable type in inventory. +## Two Depletion Mechanics -**Level-up messages:** All skills that gain XP (gathering, combat, use, firemaking) output `*** You are now level N <skill>! ***` when the player levels up. +**Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop. -**Stoke error messages:** "There's no fire here to stoke" (no fire), "You have no logs to stoke with!" (no burnable items), "Stoke what?" (ambiguous). Fire going out mid-stoke interrupts immediately with "The fire went out!". +**Shared depletion** (higher-tier trees): Set `deplete_timer: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. Timer regenerates when no one is chopping. Managed by `SharedDepletionTick` in `tick.go`. -## Two Depletion Mechanics +## Ground Item System -**Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop. Rocks don't show despawn timers when not depleted. +- `DropDespawnTicks = 1000` — dropped items despawn after 1000 ticks +- `ReserveTicks = 100` — mob loot is reserved for the killer for 100 ticks +- Items with same ID in the same room are NOT merged — each entry has independent despawn timer +- Spawn items respawn after their respawn delay; nearby matching items are merged on respawn +- Despawn timers visible via `despawn` option; reserve info via `reserve` option -**Shared depletion** (higher-tier trees): Set `deplete_timer: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. If multiple players succeed on the depletion tick, all get their drops. The timer regenerates back to max when no one is chopping. `SharedDepletionTick` manages this in `internal/game/tick.go`. +## Combat System -## Data Files +**Combat lock:** `LockedTicks: 15` — players cannot move for 15 ticks after entering combat. Decremented each tick. -``` -data/rooms/<id>.yaml Room definitions (exits, objects, mobs, spawns, on_enter) -data/items/<id>.yaml Item definitions (stats, equip slot, tool type, speed) -data/mobs/<id>.yaml Mob definitions (combat stats, drops, behavior — NO wander config) -data/objects/<id>.yaml Object definitions (name, behavior, hidden) -data/behaviors/<id>.yaml Behaviors (gather, talk, use, toggle configs) -data/drops/<id>.yaml Shared drop tables (weighted item lists) -data/help/<id>.yaml Help topics -data/players/accounts/ Account YAML (gitignored) -data/players/characters/ Character YAML (gitignored) -``` +**Death mechanic:** Player drops credits to ground. If player has >3 items (inventory + equipment), the 3 most valuable are kept and rest are dropped. Player teleports to room 1 at full HP. -Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YAML — mob defs are generic. Mobs wander via legal (unconditioned) room exits; objects teleport between rooms in their list. +**XP formula:** `baseXP = dmg * 4`, distributed by attack style: +- Accurate: 75% Attack, 25% Hitpoints +- Aggressive: 75% Strength, 25% Hitpoints +- Defensive: 75% Defense, 25% Hitpoints +- Balanced: 25% each Attack/Strength/Defense/Hitpoints -See `WORLDBUILDING.md` for full YAML format reference with examples. +**Mob combat level:** `0.25 * (attack + strength + defense + maxHP)` -## Tick-Based Action Queue +**Player combat level:** Ranged-or-melee dominant calculation. Includes Science (0.25) and Technology (0.125). -All gameplay commands are queued for the next 600ms game tick instead of executing immediately. Commands are classified into three types: +**Mob respawn:** Default 30 ticks. Broadcasts spawn messages if `mob_spawn` option is on. -| Class | Behavior | Commands | -|---|---|---| -| Instant | Execute immediately, no queue | `say`, `score`, `inventory`, `equipment`, `look`, `exits`, `help`, `map`, `option`, `alias`, `unalias`, `description`, `queued` | -| Free | Stackable, execute in order before active | `wear`/`wield`, `remove`/`unwear`, `style` | -| Active | Only the last active per player survives | Everything else: move, attack, mine/chop/fish/cut, use, talk, burn, stoke, get, drop, search, quit | +**Mob DropTable:** `Remains` (guaranteed drop item), `Loot` (weighted DropEntry table). -Each 600ms tick, `ProcessQueuedCommands` in `internal/game/game.go`: -1. Clears stale ActionState from one-tick actions (move, get, drop, etc.) -2. Executes all free commands per player in queued order -3. Executes all active commands sorted globally by real-time timestamp -4. Flushes any pending shared resource depletions +## Option System + +Player options set via `option <name> <value>`: + +| Option | Type | Default | Description | +|---|---|---|---| +| `description` | bool | true | Long room descriptions when moving | +| `tiny_map` | string | "right" | off/right/left — mini-map display | +| `xp_drops` | bool | true | XP drop messages | +| `exits` | bool | true | Long exit display in look | +| `mob_enter` | bool | true | Messages when mobs enter the room | +| `mob_leave` | bool | true | Messages when mobs leave the room | +| `mob_spawn` | bool | true | Messages when mobs spawn | +| `reserve` | bool | true | Show full reserved item details | +| `depletion` | bool | false | Show depletion and despawn timers | +| `despawn` | bool | false | Show ground item despawn timers | +| `color` | string | "none" | none/ansi/xterm256 — color output mode | +| `mapwidth` | int | 30 | Map viewport width | +| `mapheight` | int | 20 | Map viewport height | +| `mappadding` | string | "none" | none/x/y/xy — blank-row stripping | +| `automap` | bool | false | Show map automatically after moving | +| `queue_silently` | bool | true | Suppress queue messages | +| `room_desc_width` | int | 70 | Room desc line wrap width | +| `unicode` | bool | true | Unicode box-drawing characters | +| `prompt` | string | "> " | Custom command prompt | + +## ItemDef Fields + +From `internal/object/item.go`: + +| Field | Description | +|---|---| +| `id` | Unique identifier | +| `name` | Display name | +| `aliases` | Alternate name prefixes for matching | +| `description` | Item description | +| `value` | Credit value | +| `stackable` | Can stack in inventory | +| `equip_slot` | head/neck/torso/legs/hands/feet/back/ammo/main_hand/off_hand/ring | +| `weapon_type` | "melee" / "ranged" / "technology" | +| `stats` | attack/strength/defense/science/technology bonuses | +| `speed` | Attack speed | +| `tool_type` | Tool type string for gather requirements | +| `tool_speed` | Speed bonus for gathering | +| `burn_ticks` | Burn duration for firemaking | +| `fire_level` | Required firemaking level | +| `fire_xp` | XP for burning | +| `quality` | Default quality (e.g., lighter butane) | +| `max_quality` | Maximum quality | +| `search_table` | Drop table for searching this item | +| `search_misc_table` | Secondary drop table | +| `search_ticks` | Ticks per search action | +| `search_message` | Custom search message | + +## ObjectDef Fields + +From `internal/object/object.go`: + +| Field | Description | +|---|---| +| `id` | Unique identifier | +| `name` | Display name | +| `behavior` | Behavior ID for interaction | +| `hidden` | Interactable but not shown in room listing | +| `inroom_description` | Custom "A X is here." text | +| `removal_item` | Item dropped when instance is removed (fire → ashes) | +| `props` | Arbitrary key-value map (description, quality_description with `{quality}` placeholder) | -**Conflict resolution:** -- Multi-player attack on same mob: first player to queue wins (timestamp-order). -- Multi-player get on same item: first player to queue wins. -- Multi-player shared depletion: defer depletion, award all successful gatherers. +## World ObjState Fields -**ActionState** (`internal/game/action_state.go`) tracks what a player is doing for `look` display. Timed actions (gather, combat, burn, stoke, use) persist the state until interrupted. One-tick actions (move, get, drop) clear after one tick. +From `internal/world/world.go`: + +| Field | Description | +|---|---| +| `DefID` | Object definition ID | +| `Name` | Display name | +| `Index` | Index for duplicate objects in same room | +| `RoomID` | Current room | +| `Depleted` | Resource is depleted | +| `DepleteTimer` | Ticks until respawn | +| `SharedMax` | Max shared depletion timer | +| `SharedTimer` | Current shared timer value | +| `WanderRooms` | Rooms for object wandering | +| `WanderInterval` | Ticks between wanders | +| `Quality` | Fire quality, resource health, etc. | +| `JustRespawned` | Flag for respawn broadcast | + +## Session States + +From `internal/net/server.go`: + +| State | Purpose | +|---|---| +| `StateAccountName` | Entering account name | +| `StatePassword` | Entering password | +| `StateNewAccountPass` | Setting password for new account | +| `StateNewAccountConfirm` | Confirming password | +| `StateMenu` | Main menu | +| `StateNewCharName` | Naming new character | +| `StateRenameAccount` | Renaming account | +| `StateRenameChar` | Selecting character to rename | +| `StateRenameCharName` | Entering new character name | +| `StateDeleteChar` | Confirming character deletion | +| `StatePurgeAccount` | Confirming account purge | +| `StateGame` | In-game command mode | +| `StateChangeDescription` | Setting player description | +| `StateTalk` | NPC conversation input | +| `StateDropAllConfirm` | Confirming `drop all` | + +## Adding a New Skill + +1. Add `SkillName` constant + entry in `AllSkills` + `SkillAbbr` in `internal/player/player.go` +2. Create a gather behavior YAML in `data/behaviors/` +3. Create an object YAML referencing that behavior in `data/objects/` +4. Create an item for the tool in `data/items/` (with `tool_type`) +5. Place the object in a room YAML in `data/rooms/` +6. Create any resource items in `data/items/` + +Set `xp` on the behavior to award XP on successful gathers. Set `deplete_timer` for tree-style depletion, or use per-drop `depletes: true` for rock-style depletion. + +## Adding a New Production Skill + +Same as gathering but use `type: use` behavior (see `UseConfig` in `internal/action/behavior.go`). Smithing is the reference example planned. + +## Adding a New Command + +1. Add a `cmd_*.go` file in `internal/game/` with the handler +2. Add a case in `handleGameCommand()` and entry in `classifyCommand()` in `internal/game/game.go` +3. Update `data/help/` YAML files + +## Skills (23 total, 1-99, RSC XP table) + +| Category | Skills | +|---|---| +| Combat | Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology, Assassin | +| Gathering | Fishing, Woodcutting, Mining, Hacking, Scavenging, Farming, Thieving | +| Production | Cooking, Smithing, Crafting, Fletching, Alchemy, Construction, Firemaking | +| Utility | Agility | -Queue feedback is controlled by the `queue_silently` player option (default on). +Implemented: Mining, Fishing, Woodcutting, Firemaking. The rest are stubs (skill defined, XP tracked, but no behaviors/objects/items yet). ## Places to Be Careful - `StartAction` searches objects first, then mobs. A mob must have `behavior:` set on its YAML def to be interactable. -- Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`. Same pattern used for `RoomMob` (supports both `"man"` and `{id: man, ...}`). +- Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`. Same pattern for `RoomMob`. - `checkCondition` is the single condition evaluator — used by exits, talk options, on-enter scripts, and toggle checks. - Object `Hidden: true` means the object doesn't appear in room listings but is still interactable. -- Alias expansion happens before command dispatch. An alias can shadow a built-in command. -- `say` preserves case (alias expansion preserves case, and the dispatch extracts the original input text for say). +- Alias expansion happens before command dispatch and can shadow built-in commands. +- `say` preserves case (alias expansion preserves case, dispatch extracts original input text). - `get`, `drop`, and `quit` cancel the player's active action (gathering, use, etc.). - `findGroundMatches` and `findInventoryMatches` support bidirectional prefix matching — `"iron ax"` matches `"iron axe"`. -- Mob wander config is per-instance in room YAML via `RoomMob`, not on `MobDef`. Mobs without `wander_interval` in the room stay still. +- Mob wander config is per-instance in room YAML via `RoomMob`, not on `MobDef`. +- Walk distance capped by agility level (`len(dirs) > agilityLevel`). +- `wear all` auto-equips the highest-value item per slot from inventory. +- Combat lock prevents movement for 15 ticks after entering combat. +- Level-up messages output `*** You are now level N <skill>! ***` across all skills that gain XP. @@ -1,245 +1,16 @@ -# Third Collapse +# TODO -A low-tech sci-fi MUD set on a terraformed asteroid. Runescape-style combat and skill progression, written in Go. +Upcoming work for Third Collapse. See `AGENTS.md` for architecture, commands, and reference. -## Theme +## Phase 1: Core Skill Chains (breadth over depth) -Takes place on a terraformed asteroid. Technology has regressed — people scrap and scavenge remnants of the old world. "Science" replaces Prayer, "Technology" replaces Magic. Scavenging replaces Runecrafting. - -## Skills (23 total, all go from 1-99) - -``` -Combat: Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology, Assassin -Gathering: Fishing, Woodcutting, Mining, Hacking, Scavenging, Farming, Thieving -Production: Cooking, Smithing, Crafting, Fletching, Alchemy, Construction, Firemaking -Utility: Agility -``` - -## Architecture - -``` -cmd/mud/ Entry point (--config flag, multi-listener) -internal/ - config/ YAML config loading (telnet, http, https listeners) - action/ Behavior system: gather/talk/use/toggle types, drop tables - engine/ Tick scheduler (600ms) - world/ Rooms, exits, ground items, mobs, object instance state - player/ Character sheet, skills, XP, inventory, equipment, validation - combat/ Combat formulas, combat state tracking - object/ ItemDef, ObjectDef, item/object stores - net/ Telnet + HTTPS/WebSocket server, sessions, Conn interface, echo control - game/ Session handler, login flow, command dispatch, action tick, input sanitization -data/ - behaviors/ Behavior YAML files (gather, talk, use, toggle) - drops/ Shared drop table YAML files - rooms/ Room YAML files - objects/ Object definition YAML files - items/ Item definition YAML files - mobs/ Mob definition YAML files - help/ Help topic YAML files - players/ - accounts/ Account YAML files (gitignored) - characters/ Character YAML files (gitignored) -``` - -## Behavior System - -Objects and mobs in rooms can have behaviors. Four types exist: - -| Type | Verbs | Use | -| -------- | --------------------- | -------------------------------------------------------------------------- | -| `gather` | mine, chop, cut, fish | Resource gathering with skill checks, tool requirements, depletion/respawn | -| `use` | use | Crafting stations — consume items, produce output, optional skill checks | -| `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, actions | -| `toggle` | pull, push | Levers, switches, gates — set world flags, conditional on existing values | - -Behaviors are defined in `data/behaviors/<id>.yaml`. Objects reference them with `behavior: <id>`. Mobs can also carry `behavior: <id>` — StartAction falls back to mob lookup when no object matches. - -### Two Depletion Mechanics - -**Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop. - -**Shared depletion** (higher-tier trees): Set `deplete_timer: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. The timer regenerates back to max when no one is chopping. - -### Bird's Nests - -Set `nest_chance: 256` on a gather behavior to give a 1/256 independent chance to find a bird's nest alongside the normal drop. Bird's nests are searched with the `search` command, rolling on the `birds_nest_drop` drop table. - -### Condition System - -Used by exits, talk options, on-enter scripts, and toggle checks: - -| Field | Scope | Example | -| ------------- | ------------------------------ | -------------------------------- | -| `flag` | World (shared by all players) | `flag: gate_open` | -| `player_flag` | Per-character (quest progress) | `player_flag: finished_tutorial` | -| `has_item` | Player inventory check | `has_item: bronze_key` | -| `all_of` | All sub-conditions must pass | Nested list of conditions | -| `any_of` | Any sub-condition passes | Nested list of conditions | -| `not` | Invert the check | `not: true` | - -### Node Actions (talk dialog) - -Set on nodes in talk behaviors: - -| Field | Effect | -| ------------------ | ------------------------------------------------------ | -| `set_flags` | Sets world flags (global) | -| `set_player_flags` | Sets player-local flags (per-character, saved to YAML) | -| `give_item` | Gives an item to inventory | -| `take_item` | Removes an item from inventory | -| `teleport` | Moves player to a room ID | -| `heal` | Restores hitpoints | - -All fields in a single action are processed together. - -### Accounts & Aliases - -Accounts stored in `data/players/accounts/<name>.yaml`. Each account has: -- Password hash (sha256 + salt) -- Character list -- Alias map (`alias <name> <cmd>` — account-wide, shared by all characters) - -Aliases resolve before built-in commands and support argument passthrough (`alias k kill` → `k man` expands to `kill man`). - -### World - -- Rooms are flat YAML files, read from disk on each access (live editing) -- Everything is YAML-driven — no database -- Exits support conditions (world flags, player flags, items, compound) -- Rooms support `on_enter` scripts with per-step conditions -- Objects can be `hidden: true` (interactable but not listed in room) -- Mobs wander via legal room exits (unconditioned), not hardcoded room lists -- Mob wander config (`wander_rooms`, `wander_interval`) set per-instance in room YAML -- Object wander config same format — objects teleport between listed rooms - -## Implemented - -### Core Systems -- [x] Telnet server, account creation, hashed passwords, account/character management -- [x] HTTPS/WebSocket server with embedded web terminal client -- [x] Multi-listener config (telnet + http + https, any combination) -- [x] YAML config file (`config.yaml`) with `--config` flag and runtime defaults -- [x] 600ms tick engine driving all world simulation -- [x] Room-based player hub with enter/exit/action notifications -- [x] Single-login enforcement, disconnect timer -- [x] Player YAML persistence on every state change -- [x] Transport-agnostic Conn interface (tcpConn + wsConn) -- [x] Password echo suppression (telnet IAC ECHO, web OSC sequences) -- [x] Input sanitization (control character stripping at HandleSession) -- [x] Name validation (alphanumeric+spaces, 3-30 chars) -- [x] Max input line length (1024 bytes) -- [x] WebSocket origin checking (same-origin + loopback) -- [x] Tick-based action queue system (600ms) — commands classified as instant, free, or active -- [x] ActionState system for player activity display in room look -- [x] Smart conflict resolution (multi-player attack/get timestamp ordering, shared depletion deferral) -- [x] Fractional tick system — all YAML timers accept float64, probabilistically rounded -- [x] Configurable tick_length and game_speed for server operators -- [x] Ground item spawn stacking fix (Tick + SeedGroundItems) - -### Commands -- [x] `look / l` — room rendering (mobs, objects, ground items, exits, players) -- [x] `look <target>` — examine mobs, objects, items, players, directions -- [x] `n/s/e/w/u/d` — movement (conditional exits, interrupts combat/actions) -- [x] `get / drop / drop all` — item pickup/drop with stacking, reservations -- [x] `say` — room chat (preserves case) -- [x] `score / inventory / equipment / exits` — character and world info -- [x] `style` — combat style selection (prefix matching) -- [x] `attack / kill <mob>` — combat with numbered targeting -- [x] `mine / chop / cut / fish <object>` — gathering with numbered targeting -- [x] `use <object>` — crafting station interaction -- [x] `talk / speak / ask <target>` — NPC conversations (objects or mobs) -- [x] `pull / push <object>` — toggle interactions (levers, gates) -- [x] `alias <name> <cmd> / unalias <name>` — account-wide command aliases -- [x] `option / options [name] [value]` — 15 character settings (bool, string, int types) -- [x] `queued` — show pending tick actions -- [x] `description / desc / help [topic] / quit` -- [x] `search <item>` — search bird's nests for loot -- [x] `burn [item]` — start a fire from logs on ground or in inventory (smart default) -- [x] `stoke [item]` — add logs to an existing fire for firemaking XP - -### Combat -- [x] RSC-based formulas (attack/defense rolls, max hit) -- [x] Attack styles with bonuses and XP distribution -- [x] Equipment bonuses, HP tracking, death mechanics -- [x] Combat lock timer, movement interrupts combat -- [x] Mob/player regen (1 HP per 100 ticks) -- [x] Mob exit-based wandering, enter/leave/spawn notifications -- [x] Drop reservation system (100-tick owner lock on loot) - -### Skills & Gathering -- [x] Mining (copper rocks, pickaxe, depletion/respawn, gem sub-table) -- [x] Fishing (wandering spots, non-depleting, fishing rod) -- [x] Woodcutting (9 tree types, axes, shared depletion, bird's nests, exhausted message) -- [x] Firemaking (9 log tiers, burn/stoke, fire objects, fire tools: matches/firesteel/lighter) -- [x] Tool system (tool_type + tool_speed on items, tool requirement on behaviors) -- [x] Shared drop tables (referenced by multiple behaviors) -- [x] XP drops for all skill actions (combat, gathering, use) -- [x] Level-up messages across all skills ("*** You are now level N <skill>! ***") -- [x] 23 skill definitions with RSC XP table (4 implemented: Mining, Fishing, Woodcutting, Firemaking) - -### World & Data -- [x] Room, item, object, mob, behavior, drop table YAML loaders -- [x] Object instance tracking (depletion, wandering, respawn, broadcast) -- [x] 21 structured rooms (west gathering wing, east production/utility/combat wing) -- [x] Conditional exits (world flags, player flags, items, compound conditions) -- [x] Room on_enter scripts with conditions -- [x] Hidden objects (interactable but not listed in room output) -- [x] Player-local flags vs world flags for multiplayer state separation -- [x] Compound conditions: all_of / any_of -- [x] Aligned ground item reservation display -- [x] Mob wander config per-instance in room YAML (not on MobDef) -- [x] Object depletion timer display (depletion toggle) -- [x] Bidirectional prefix matching for get/drop/attack/gather commands -- [x] Ambiguity detection when multiple object types match a command -- [x] Smart default targeting for mine/chop/fish (single type → auto-select) -- [x] Smart default targeting for attack/kill (same mob type → auto-target) -- [x] Smart default targeting for burn/stoke (single log type → auto-select) -- [x] Object inroom_description (replaces generic "A X is here.") -- [x] Object removal_item (drops item when object instance is removed) -- [x] Ground item independent despawn timers (no stack merging) -- [x] Ground item despawn display (option: despawn) -- [x] Reserve display format: "reserved for <player> <ticks>t" - -### Documentation -- [x] WORLDBUILDING.md — comprehensive guide with examples -- [x] AGENTS.md — repo structure for LLM context - -## Data Format Reference - -See WORLDBUILDING.md for full examples of every data type with explanations. - -## Action Plan - -### Phase 1: ASCII map implemented with BFS - -- [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 — 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 -- [x] Added `mappadding` option (none, x, y, xy) — controls blank-row stripping and left-trimming of map output -- [x] Tiny map built via BFS viewport rendering (3x3 room window at 2x zoom) — no hardcoded corner/cardinal rules, no room duplication - - -### Phase 2: Core Skill Chains (breadth over depth) -- [x] Woodcutting — tree objects + axes, shared depletion, bird's nests -- [x] Firemaking — burn (inventory/ground logs → fire object), stoke (add logs to fire), 3 fire tools -- [x] Exhausted message on gather behaviors (custom tree-fall message) -- [x] Depleted message on gather behaviors (custom depleted rock/tree message) -- [x] Level-up messages across all skills -- [x] Smart default targeting for gather/combat/burn/stoke -- [x] Ground item despawn display via new `despawn` option -- [x] Object `inroom_description` replacing generic "A X is here." -- [x] Object `removal_item` for generic cleanup drops (fire → ashes) -- [x] Independent despawn timers per ground item entry - [ ] Cooking — fire/range object (`use` type), raw fish/meat → cooked food - [ ] Smithing — furnace + anvil objects (`use` type), ore → bars → weapons/armor - [ ] Fletching — fletching table object (`use` type), logs → arrow shafts/bows - [ ] Equipment: bronze/iron weapons, axes, cooked trout, arrows, bows -### Phase 3: World Depth +## Phase 2: World Depth + - [ ] Shop system — talk node action for buy/sell interface with credits - [ ] Bank system — deposit/withdraw items at bank booth objects - [ ] Ranged combat — bows/crossbows/ammo, add Ranged to combat formulas @@ -247,11 +18,11 @@ See WORLDBUILDING.md for full examples of every data type with explanations. - [ ] Flee command - [ ] Equipment skill requirements -### Phase 4: Polish +## Phase 3: Polish + - [ ] Hacking, Thieving, Agility, Alchemy, Construction, Crafting, Scavenging, Farming, Assassin - [ ] Quest system (already supported via talk nodes + world/player flags) - [ ] PvP combat, multi-combat zones -- [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 c96a0e9..2d19608 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -35,7 +35,7 @@ func main() { } } - g := game.New(dataDir) + g := game.New(dataDir, &cfg.Colors) g.Ticks.Start(cfg.Game.TickLength) defer g.Ticks.Stop() diff --git a/config.yaml b/config.yaml index b4bf45d..57e1dd3 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,27 @@ game: - tick_length: 60 # milliseconds per game tick (min 50) + tick_length: 600 + +colors: + room_name: "fg=cyan bold" + room_number: "dim" + room_desc: "fg=white" + direction: "fg=cyan" + exit_direction: "fg=cyan" + exit_name: "fg=green" + mob_name: "fg=bright_red" + friendly_npc: "fg=green" + hostile_npc: "fg=bright_red" + damage: "fg=red" + enemy_hp: "fg=red" + character_hp: "fg=green" + xp: "fg=yellow" + level_up: "fg=bright_yellow bold" + item: "fg=green" + player_name: "fg=bright_white" + death: "fg=red bold" + victory: "fg=green" + miss: "dim" + error: "fg=red" telnet: enabled: true diff --git a/data/help/color.yaml b/data/help/color.yaml index e56512a..1d5ba54 100644 --- a/data/help/color.yaml +++ b/data/help/color.yaml @@ -1,31 +1,76 @@ name: "color" category: "Options" description: | - Color output mode. - - Type: string - Default: none - Values: none, ansi, xterm256 - - Controls the color output format used by the game. - 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>. + Color output customization. + + The "color" command lets you customize the color of various game + elements on an account-wide basis. Colors are shared by all + characters on the same account. + + Usage: + color List all targets and their current values + color <target> Show current color for a target + color <target> <string> Set a custom color + color <target> reset Reset to server default + color <target> off Disable color for this target entirely + + Color string format (space-separated tokens): + fg=<color> Foreground color + bg=<color> Background color + bold Bold text + dim Dim text + italic Italic text + underline Underlined text + + Example: fg=green bg=black bold + + Available color names: + black, red, green, yellow, blue, magenta, cyan, white, + bright_black, bright_red, bright_green, bright_yellow, + bright_blue, bright_magenta, bright_cyan, bright_white + + Color targets: + room_name Room name headers + room_number Room number display (#id) + room_desc Room description text + direction Movement direction text ("You walk north") + exit_direction Exit direction labels (north, south, etc.) + exit_name Exit destination room names + mob_name Mob names in combat + friendly_npc Friendly NPC names (cannot be attacked) + hostile_npc Hostile NPC/mob names in room listings + damage Damage numbers + enemy_hp Enemy HP in combat messages + character_hp Your HP in combat messages + xp XP gain messages + level_up Level-up announcements + item Item names on pickup/drop/gather + player_name Player names in broadcasts + death Death message + victory Combat victory message + miss Miss messages in combat + error Error messages + + Resolution order: + 1. Account override (set via "color" command) + 2. Server default (set in config.yaml) + 3. Built-in fallback + + Auto-colored elements (not configurable): + - Object names: colors are set per-object in their YAML definition + (fishing spots are blue, rocks are earth-toned, trees are green, etc.) + - Item names: colors are set per-item in their YAML definition + (ores, logs, fish, tools, etc.) + - Mob/player levels: displayed relative to your combat level + (red if higher, orange if <5 higher, white if equal, + yellow if <5 lower, green if much lower) + + The "color" option (option color none/ansi/xterm256) controls + whether ANSI codes are emitted and which mode is used. + "none" strips all color; the "color" command settings are + still saved but have no visual effect until color mode is + enabled. + + You can use color blocks in your prompt using the same format: + option prompt {fg=green bold}Ready>{/} %h/%Hhp + See 'help prompt' for full prompt customization. diff --git a/data/help/prompt.yaml b/data/help/prompt.yaml new file mode 100644 index 0000000..95d852b --- /dev/null +++ b/data/help/prompt.yaml @@ -0,0 +1,35 @@ +name: "prompt" +category: "Options" +description: | + Customize your command prompt. + + Set via: option prompt <string> + + Your prompt string can include variables and color blocks. + + Variables: + %h Current HP + %H Maximum HP + %c Credits + %m Current mob HP (blank unless in combat) + %M Mob max HP (blank unless in combat) + %s Attack style shorthand (acc, agg, def, bal) + %S Attack style longform (accurate, aggressive, defensive, balanced) + %i Free inventory slots + %x_<skill> XP to next level for a skill (e.g. %x_mining) + %X_<skill> Total XP for a skill (e.g. %X_attack) + + Use %% for a literal % sign. + + Color blocks use the same format as the color command: + {fg=green bold}text{/} + + A color block starts with {color_string} and ends with {/}. + + Examples: + option prompt "> " + option prompt "{fg=cyan}%h/%Hhp $%c %s{/} >" + option prompt "<{fg=green}%h{/}/{fg=red}%H{/}hp> " + + To reset to the default "> ": + option prompt "> " diff --git a/data/items/adamant_axe.yaml b/data/items/adamant_axe.yaml index 389e21e..bd52982 100644 --- a/data/items/adamant_axe.yaml +++ b/data/items/adamant_axe.yaml @@ -1,5 +1,6 @@ id: adamant_axe name: adamant axe +color: "fg=green" aliases: ["axe", "hatchet"] description: "A powerful adamant axe." value: 1250 diff --git a/data/items/adamant_pickaxe.yaml b/data/items/adamant_pickaxe.yaml index 953dec0..4aa0bb0 100644 --- a/data/items/adamant_pickaxe.yaml +++ b/data/items/adamant_pickaxe.yaml @@ -1,5 +1,6 @@ id: adamant_pickaxe name: adamant pickaxe +color: "fg=green" aliases: ["pick", "pickaxe"] description: "A powerful adamant pickaxe." value: 1250 diff --git a/data/items/adamantite_ore.yaml b/data/items/adamantite_ore.yaml index 078deea..578ece5 100644 --- a/data/items/adamantite_ore.yaml +++ b/data/items/adamantite_ore.yaml @@ -1,5 +1,6 @@ id: adamantite_ore name: adamantite ore +color: "fg=green" aliases: ["adamantite", "adamant", "ore"] description: "A chunk of dark green adamantite ore." value: 100 diff --git a/data/items/ashes.yaml b/data/items/ashes.yaml index 9cbe67f..40998d7 100644 --- a/data/items/ashes.yaml +++ b/data/items/ashes.yaml @@ -1,5 +1,6 @@ id: ashes name: ashes +color: "dim" aliases: ["ash"] description: "A pile of cold ashes. What was once ablaze is now mere dust." value: 1 diff --git a/data/items/big_fishing_net.yaml b/data/items/big_fishing_net.yaml index da8a321..79eff43 100644 --- a/data/items/big_fishing_net.yaml +++ b/data/items/big_fishing_net.yaml @@ -1,5 +1,6 @@ id: big_fishing_net name: big fishing net +color: "fg=white" aliases: ["net", "big net"] description: "A larger fishing net for catching bigger fish." value: 20 diff --git a/data/items/birds_nest.yaml b/data/items/birds_nest.yaml index 844426f..8a9e907 100644 --- a/data/items/birds_nest.yaml +++ b/data/items/birds_nest.yaml @@ -1,5 +1,6 @@ id: birds_nest name: bird's nest +color: "fg=bright_yellow" aliases: ["nest", "birds", "bird"] description: "A bird's nest. You can search it for treasure." value: 5 diff --git a/data/items/black_axe.yaml b/data/items/black_axe.yaml index d7ec254..b47fbed 100644 --- a/data/items/black_axe.yaml +++ b/data/items/black_axe.yaml @@ -1,5 +1,6 @@ id: black_axe name: black axe +color: "dim" aliases: ["axe", "hatchet"] description: "A sleek black axe, as fast as steel." value: 500 diff --git a/data/items/black_pickaxe.yaml b/data/items/black_pickaxe.yaml index f29c05c..1de8fb6 100644 --- a/data/items/black_pickaxe.yaml +++ b/data/items/black_pickaxe.yaml @@ -1,5 +1,6 @@ id: black_pickaxe name: black pickaxe +color: "dim" aliases: ["pick", "pickaxe"] description: "A sleek black pickaxe." value: 500 diff --git a/data/items/bones.yaml b/data/items/bones.yaml index de868a0..6e9e801 100644 --- a/data/items/bones.yaml +++ b/data/items/bones.yaml @@ -1,5 +1,6 @@ id: "bones" name: "bones" +color: "fg=white" aliases: ["bone"] description: "A pile of bones." value: 2 diff --git a/data/items/bread.yaml b/data/items/bread.yaml index f1c64f1..87c9067 100644 --- a/data/items/bread.yaml +++ b/data/items/bread.yaml @@ -1,5 +1,6 @@ id: bread name: bread +color: "fg=bright_yellow" aliases: ["loaf", "bread"] description: "A fresh loaf of bread. Smells wonderful." value: 2 diff --git a/data/items/bronze_axe.yaml b/data/items/bronze_axe.yaml index 802a9f7..9a024c8 100644 --- a/data/items/bronze_axe.yaml +++ b/data/items/bronze_axe.yaml @@ -1,5 +1,6 @@ id: bronze_axe name: bronze axe +color: "fg=bright_yellow" aliases: ["axe", "hatchet"] description: "A sturdy bronze axe, good for woodcutting." value: 10 diff --git a/data/items/bronze_pickaxe.yaml b/data/items/bronze_pickaxe.yaml index c451887..3d87f77 100644 --- a/data/items/bronze_pickaxe.yaml +++ b/data/items/bronze_pickaxe.yaml @@ -1,5 +1,6 @@ id: bronze_pickaxe name: bronze pickaxe +color: "fg=bright_yellow" aliases: ["pick", "pickaxe"] description: "A sturdy bronze pickaxe, good for mining." value: 10 diff --git a/data/items/bronze_sword.yaml b/data/items/bronze_sword.yaml index e8ba217..e001d26 100644 --- a/data/items/bronze_sword.yaml +++ b/data/items/bronze_sword.yaml @@ -1,5 +1,6 @@ id: "bronze_sword" name: "bronze sword" +color: "fg=bright_yellow" aliases: ["sword", "bronze"] description: "A basic bronze short sword." value: 12 diff --git a/data/items/christmas_cracker.yaml b/data/items/christmas_cracker.yaml index 42dbf26..352730c 100644 --- a/data/items/christmas_cracker.yaml +++ b/data/items/christmas_cracker.yaml @@ -1,5 +1,6 @@ id: christmas_cracker name: christmas cracker +color: "fg=bright_red" aliases: ["cracker", "christmas"] description: "A festive party cracker. Pull it apart to see what's inside!" value: 10 diff --git a/data/items/clay.yaml b/data/items/clay.yaml index 2a26d51..1b67233 100644 --- a/data/items/clay.yaml +++ b/data/items/clay.yaml @@ -1,5 +1,6 @@ id: clay name: clay +color: "fg=bright_red" aliases: ["clay"] description: "Some soft clay, useful for crafting and pottery." value: 1 diff --git a/data/items/coal.yaml b/data/items/coal.yaml index 7e88fda..97befb5 100644 --- a/data/items/coal.yaml +++ b/data/items/coal.yaml @@ -1,5 +1,6 @@ id: coal name: coal +color: "dim" aliases: ["coal"] description: "A lump of coal, useful for smelting." value: 5 diff --git a/data/items/copper_ore.yaml b/data/items/copper_ore.yaml index 215ece8..30e4053 100644 --- a/data/items/copper_ore.yaml +++ b/data/items/copper_ore.yaml @@ -1,5 +1,6 @@ id: copper_ore name: copper ore +color: "fg=bright_yellow" aliases: ["ore", "copper"] description: "A chunk of copper-bearing rock. Can be smelted into a bronze bar." value: 1 diff --git a/data/items/credits.yaml b/data/items/credits.yaml index 80c109e..b434edf 100644 --- a/data/items/credits.yaml +++ b/data/items/credits.yaml @@ -1,5 +1,6 @@ id: "credits" name: "credits" +color: "fg=bright_yellow" aliases: ["credit", "creds"] description: "A handful of universal credits." value: 1 diff --git a/data/items/dragon_axe.yaml b/data/items/dragon_axe.yaml index 613f7c6..57fd85d 100644 --- a/data/items/dragon_axe.yaml +++ b/data/items/dragon_axe.yaml @@ -1,5 +1,6 @@ id: dragon_axe name: dragon axe +color: "fg=bright_red" aliases: ["axe", "hatchet"] description: "A legendary dragon axe that bites through wood like butter." value: 100000 diff --git a/data/items/dragon_pickaxe.yaml b/data/items/dragon_pickaxe.yaml index bb6211d..581b318 100644 --- a/data/items/dragon_pickaxe.yaml +++ b/data/items/dragon_pickaxe.yaml @@ -1,5 +1,6 @@ id: dragon_pickaxe name: dragon pickaxe +color: "fg=bright_red" aliases: ["pick", "pickaxe"] description: "A legendary dragon pickaxe that pulverizes rock." value: 100000 diff --git a/data/items/feather.yaml b/data/items/feather.yaml index d9216f8..b8a89fe 100644 --- a/data/items/feather.yaml +++ b/data/items/feather.yaml @@ -1,5 +1,6 @@ id: feather name: feather +color: "fg=white" aliases: ["feathers"] description: "A colorful feather. Used as fly fishing bait." value: 1 diff --git a/data/items/firesteel.yaml b/data/items/firesteel.yaml index 0cdb354..4323daf 100644 --- a/data/items/firesteel.yaml +++ b/data/items/firesteel.yaml @@ -1,5 +1,6 @@ id: firesteel name: firesteel +color: "dim" aliases: ["steel", "flint"] description: "A rugged firesteel that never wears out." value: 20 diff --git a/data/items/fishing_bait.yaml b/data/items/fishing_bait.yaml index 004a61d..81f2f2b 100644 --- a/data/items/fishing_bait.yaml +++ b/data/items/fishing_bait.yaml @@ -1,5 +1,6 @@ id: fishing_bait name: fishing bait +color: "fg=yellow" aliases: ["bait"] description: "A handful of wriggling bait. Used with a fishing rod." value: 1 diff --git a/data/items/fishing_rod.yaml b/data/items/fishing_rod.yaml index 24c8ce9..85a3622 100644 --- a/data/items/fishing_rod.yaml +++ b/data/items/fishing_rod.yaml @@ -1,5 +1,6 @@ id: fishing_rod name: fishing rod +color: "fg=green" aliases: ["rod", "fishing"] description: "A simple fishing rod. Use with fishing bait." value: 5 diff --git a/data/items/fly_fishing_rod.yaml b/data/items/fly_fishing_rod.yaml index a2571f8..04745fd 100644 --- a/data/items/fly_fishing_rod.yaml +++ b/data/items/fly_fishing_rod.yaml @@ -1,5 +1,6 @@ id: fly_fishing_rod name: fly fishing rod +color: "fg=green" aliases: ["rod", "fly rod", "fly fishing"] description: "A flexible rod for fly fishing with artificial flies." value: 20 diff --git a/data/items/gold_ore.yaml b/data/items/gold_ore.yaml index 28f3352..91c32dc 100644 --- a/data/items/gold_ore.yaml +++ b/data/items/gold_ore.yaml @@ -1,5 +1,6 @@ id: gold_ore name: gold ore +color: "fg=bright_yellow" aliases: ["gold", "ore"] description: "A glittering chunk of gold ore." value: 25 diff --git a/data/items/harpoon.yaml b/data/items/harpoon.yaml index d69cc41..c7cfbc2 100644 --- a/data/items/harpoon.yaml +++ b/data/items/harpoon.yaml @@ -1,5 +1,6 @@ id: harpoon name: harpoon +color: "dim" aliases: ["harpoon", "spear"] description: "A barbed harpoon for spearing large fish." value: 30 diff --git a/data/items/iron_axe.yaml b/data/items/iron_axe.yaml index d236a5d..6e05bd6 100644 --- a/data/items/iron_axe.yaml +++ b/data/items/iron_axe.yaml @@ -1,5 +1,6 @@ id: iron_axe name: iron axe +color: "dim" aliases: ["axe", "hatchet"] description: "A solid iron axe, better than bronze." value: 50 diff --git a/data/items/iron_ore.yaml b/data/items/iron_ore.yaml index c89d789..f3e4183 100644 --- a/data/items/iron_ore.yaml +++ b/data/items/iron_ore.yaml @@ -1,5 +1,6 @@ id: iron_ore name: iron ore +color: "dim" aliases: ["iron", "ore"] description: "A chunk of iron ore, ready for smelting." value: 5 diff --git a/data/items/iron_pickaxe.yaml b/data/items/iron_pickaxe.yaml index 83a436a..7deeafe 100644 --- a/data/items/iron_pickaxe.yaml +++ b/data/items/iron_pickaxe.yaml @@ -1,5 +1,6 @@ id: iron_pickaxe name: iron pickaxe +color: "dim" aliases: ["pick", "pickaxe"] description: "A solid iron pickaxe, better than bronze." value: 50 diff --git a/data/items/lighter.yaml b/data/items/lighter.yaml index 17158f8..e672993 100644 --- a/data/items/lighter.yaml +++ b/data/items/lighter.yaml @@ -1,5 +1,6 @@ id: lighter name: lighter +color: "fg=cyan" aliases: ["light"] description: "A reliable butane lighter." value: 15 diff --git a/data/items/lobster_pot.yaml b/data/items/lobster_pot.yaml index 52fe908..caa96ea 100644 --- a/data/items/lobster_pot.yaml +++ b/data/items/lobster_pot.yaml @@ -1,5 +1,6 @@ id: lobster_pot name: lobster pot +color: "dim" aliases: ["pot", "lobster", "cage"] description: "A wire cage for trapping lobsters." value: 20 diff --git a/data/items/logs.yaml b/data/items/logs.yaml index d838348..a21dd00 100644 --- a/data/items/logs.yaml +++ b/data/items/logs.yaml @@ -1,5 +1,6 @@ id: logs name: logs +color: "fg=green" aliases: ["log", "wood"] description: "Some regular logs, good for firemaking and fletching." value: 2 diff --git a/data/items/magic_logs.yaml b/data/items/magic_logs.yaml index 1cb76a9..c17e234 100644 --- a/data/items/magic_logs.yaml +++ b/data/items/magic_logs.yaml @@ -1,5 +1,6 @@ id: magic_logs name: magic logs +color: "fg=magenta" aliases: ["logs", "magic", "log"] description: "Some mystical magic logs." value: 80 diff --git a/data/items/mahogany_logs.yaml b/data/items/mahogany_logs.yaml index 7fc3a7c..2c68ebc 100644 --- a/data/items/mahogany_logs.yaml +++ b/data/items/mahogany_logs.yaml @@ -1,5 +1,6 @@ id: mahogany_logs name: mahogany logs +color: "fg=bright_red" aliases: ["logs", "mahogany", "log"] description: "Some rich mahogany logs." value: 20 diff --git a/data/items/maple_logs.yaml b/data/items/maple_logs.yaml index d162471..ac5c2c9 100644 --- a/data/items/maple_logs.yaml +++ b/data/items/maple_logs.yaml @@ -1,5 +1,6 @@ id: maple_logs name: maple logs +color: "fg=bright_red" aliases: ["logs", "maple", "log"] description: "Some smooth maple logs." value: 16 diff --git a/data/items/matches.yaml b/data/items/matches.yaml index 95a8da9..e3b9284 100644 --- a/data/items/matches.yaml +++ b/data/items/matches.yaml @@ -1,5 +1,6 @@ id: matches name: matches +color: "fg=bright_red" aliases: ["match"] description: "A small box of matches for starting fires." value: 5 diff --git a/data/items/mithril_axe.yaml b/data/items/mithril_axe.yaml index 642f6d5..2c3bfd4 100644 --- a/data/items/mithril_axe.yaml +++ b/data/items/mithril_axe.yaml @@ -1,5 +1,6 @@ id: mithril_axe name: mithril axe +color: "fg=blue" aliases: ["axe", "hatchet"] description: "A lightweight mithril axe." value: 500 diff --git a/data/items/mithril_ore.yaml b/data/items/mithril_ore.yaml index c0b9481..23a5d81 100644 --- a/data/items/mithril_ore.yaml +++ b/data/items/mithril_ore.yaml @@ -1,5 +1,6 @@ id: mithril_ore name: mithril ore +color: "fg=blue" aliases: ["mithril", "ore"] description: "A piece of light blue mithril ore." value: 50 diff --git a/data/items/mithril_pickaxe.yaml b/data/items/mithril_pickaxe.yaml index 8eef9e2..585cec6 100644 --- a/data/items/mithril_pickaxe.yaml +++ b/data/items/mithril_pickaxe.yaml @@ -1,5 +1,6 @@ id: mithril_pickaxe name: mithril pickaxe +color: "fg=blue" aliases: ["pick", "pickaxe"] description: "A lightweight mithril pickaxe." value: 500 diff --git a/data/items/oak_logs.yaml b/data/items/oak_logs.yaml index 2ed87a8..12ee703 100644 --- a/data/items/oak_logs.yaml +++ b/data/items/oak_logs.yaml @@ -1,5 +1,6 @@ id: oak_logs name: oak logs +color: "fg=green" aliases: ["logs", "oak", "log"] description: "Some sturdy oak logs." value: 4 diff --git a/data/items/party_hat_blue.yaml b/data/items/party_hat_blue.yaml index 81ad988..5ccfb26 100644 --- a/data/items/party_hat_blue.yaml +++ b/data/items/party_hat_blue.yaml @@ -1,5 +1,6 @@ id: party_hat_blue name: blue party hat +color: "fg=blue" aliases: ["party hat", "blue hat"] description: "A festive blue party hat." value: 2 diff --git a/data/items/party_hat_green.yaml b/data/items/party_hat_green.yaml index 907da90..edf590e 100644 --- a/data/items/party_hat_green.yaml +++ b/data/items/party_hat_green.yaml @@ -1,5 +1,6 @@ id: party_hat_green name: green party hat +color: "fg=green" aliases: ["party hat", "green hat"] description: "A festive green party hat." value: 2 diff --git a/data/items/party_hat_pink.yaml b/data/items/party_hat_pink.yaml index accd9a0..d0c8abe 100644 --- a/data/items/party_hat_pink.yaml +++ b/data/items/party_hat_pink.yaml @@ -1,5 +1,6 @@ id: party_hat_pink name: pink party hat +color: "fg=magenta" aliases: ["party hat", "pink hat"] description: "A festive pink party hat." value: 2 diff --git a/data/items/party_hat_red.yaml b/data/items/party_hat_red.yaml index 911d07b..f85396f 100644 --- a/data/items/party_hat_red.yaml +++ b/data/items/party_hat_red.yaml @@ -1,5 +1,6 @@ id: party_hat_red name: red party hat +color: "fg=red" aliases: ["party hat", "red hat"] description: "A festive red party hat." value: 2 diff --git a/data/items/party_hat_white.yaml b/data/items/party_hat_white.yaml index e4bd555..29b978a 100644 --- a/data/items/party_hat_white.yaml +++ b/data/items/party_hat_white.yaml @@ -1,5 +1,6 @@ id: party_hat_white name: white party hat +color: "fg=white" aliases: ["party hat", "white hat"] description: "A festive white party hat." value: 2 diff --git a/data/items/party_hat_yellow.yaml b/data/items/party_hat_yellow.yaml index eabd3e8..561156a 100644 --- a/data/items/party_hat_yellow.yaml +++ b/data/items/party_hat_yellow.yaml @@ -1,5 +1,6 @@ id: party_hat_yellow name: yellow party hat +color: "fg=yellow" aliases: ["party hat", "yellow hat"] description: "A festive yellow party hat." value: 2 diff --git a/data/items/pass_stub.yaml b/data/items/pass_stub.yaml index a7456ef..8cedff1 100644 --- a/data/items/pass_stub.yaml +++ b/data/items/pass_stub.yaml @@ -1,5 +1,6 @@ id: pass_stub name: pass stub +color: "fg=yellow" description: "A crumpled slip of paper stamped with the guard's seal. Grants one-time access past the gate." value: 0 stackable: false diff --git a/data/items/raw_anchovies.yaml b/data/items/raw_anchovies.yaml index f833026..ae6234b 100644 --- a/data/items/raw_anchovies.yaml +++ b/data/items/raw_anchovies.yaml @@ -1,5 +1,6 @@ id: raw_anchovies name: raw anchovies +color: "fg=blue" aliases: ["anchovies", "anchovy"] description: "Some tiny raw anchovies." value: 2 diff --git a/data/items/raw_anglerfish.yaml b/data/items/raw_anglerfish.yaml index 42cd9b9..b38105e 100644 --- a/data/items/raw_anglerfish.yaml +++ b/data/items/raw_anglerfish.yaml @@ -1,5 +1,6 @@ id: raw_anglerfish name: raw anglerfish +color: "fg=cyan" aliases: ["anglerfish", "angler"] description: "An ugly but delicious raw anglerfish." value: 20 diff --git a/data/items/raw_bass.yaml b/data/items/raw_bass.yaml index 07d373e..6f80a2c 100644 --- a/data/items/raw_bass.yaml +++ b/data/items/raw_bass.yaml @@ -1,5 +1,6 @@ id: raw_bass name: raw bass +color: "fg=blue" aliases: ["bass"] description: "A freshly caught raw bass." value: 10 diff --git a/data/items/raw_cave_eel.yaml b/data/items/raw_cave_eel.yaml index 66048c1..884fe1e 100644 --- a/data/items/raw_cave_eel.yaml +++ b/data/items/raw_cave_eel.yaml @@ -1,5 +1,6 @@ id: raw_cave_eel name: raw cave eel +color: "fg=cyan" aliases: ["cave eel", "eel"] description: "A pale eel from deep underground waters." value: 12 diff --git a/data/items/raw_cod.yaml b/data/items/raw_cod.yaml index cdc4fb3..aa98c30 100644 --- a/data/items/raw_cod.yaml +++ b/data/items/raw_cod.yaml @@ -1,5 +1,6 @@ id: raw_cod name: raw cod +color: "fg=blue" aliases: ["cod"] description: "A freshly caught raw cod." value: 6 diff --git a/data/items/raw_herring.yaml b/data/items/raw_herring.yaml index d6fce43..ad439d9 100644 --- a/data/items/raw_herring.yaml +++ b/data/items/raw_herring.yaml @@ -1,5 +1,6 @@ id: raw_herring name: raw herring +color: "fg=blue" aliases: ["herring"] description: "A freshly caught raw herring." value: 5 diff --git a/data/items/raw_lobster.yaml b/data/items/raw_lobster.yaml index d30907e..ac97b82 100644 --- a/data/items/raw_lobster.yaml +++ b/data/items/raw_lobster.yaml @@ -1,5 +1,6 @@ id: raw_lobster name: raw lobster +color: "fg=blue" aliases: ["lobster"] description: "A freshly caught raw lobster." value: 15 diff --git a/data/items/raw_mackerel.yaml b/data/items/raw_mackerel.yaml index 5cd81e8..0f1eb5d 100644 --- a/data/items/raw_mackerel.yaml +++ b/data/items/raw_mackerel.yaml @@ -1,5 +1,6 @@ id: raw_mackerel name: raw mackerel +color: "fg=blue" aliases: ["mackerel"] description: "A freshly caught raw mackerel." value: 4 diff --git a/data/items/raw_pike.yaml b/data/items/raw_pike.yaml index 6313798..fad2599 100644 --- a/data/items/raw_pike.yaml +++ b/data/items/raw_pike.yaml @@ -1,5 +1,6 @@ id: raw_pike name: raw pike +color: "fg=blue" aliases: ["pike"] description: "A freshly caught raw pike." value: 8 diff --git a/data/items/raw_rainbow_fish.yaml b/data/items/raw_rainbow_fish.yaml index 72f20b2..f27f52b 100644 --- a/data/items/raw_rainbow_fish.yaml +++ b/data/items/raw_rainbow_fish.yaml @@ -1,5 +1,6 @@ id: raw_rainbow_fish name: raw rainbow fish +color: "fg=magenta" aliases: ["rainbow fish", "rainbow"] description: "A shimmering raw rainbow fish." value: 10 diff --git a/data/items/raw_salmon.yaml b/data/items/raw_salmon.yaml index e90b8a5..457e74d 100644 --- a/data/items/raw_salmon.yaml +++ b/data/items/raw_salmon.yaml @@ -1,5 +1,6 @@ id: raw_salmon name: raw salmon +color: "fg=blue" aliases: ["salmon"] description: "A freshly caught raw salmon." value: 8 diff --git a/data/items/raw_sardine.yaml b/data/items/raw_sardine.yaml index 262d30e..f42d19d 100644 --- a/data/items/raw_sardine.yaml +++ b/data/items/raw_sardine.yaml @@ -1,5 +1,6 @@ id: raw_sardine name: raw sardine +color: "fg=blue" aliases: ["sardine"] description: "A freshly caught raw sardine." value: 3 diff --git a/data/items/raw_shark.yaml b/data/items/raw_shark.yaml index 05a4e72..b5a0ebc 100644 --- a/data/items/raw_shark.yaml +++ b/data/items/raw_shark.yaml @@ -1,5 +1,6 @@ id: raw_shark name: raw shark +color: "fg=blue" aliases: ["shark"] description: "A freshly caught raw shark." value: 30 diff --git a/data/items/raw_shrimps.yaml b/data/items/raw_shrimps.yaml index 0696f90..a343011 100644 --- a/data/items/raw_shrimps.yaml +++ b/data/items/raw_shrimps.yaml @@ -1,5 +1,6 @@ id: raw_shrimps name: raw shrimps +color: "fg=blue" aliases: ["shrimps", "shrimp"] description: "Some small raw shrimps." value: 2 diff --git a/data/items/raw_slimy_eel.yaml b/data/items/raw_slimy_eel.yaml index 535bf4d..68c0efe 100644 --- a/data/items/raw_slimy_eel.yaml +++ b/data/items/raw_slimy_eel.yaml @@ -1,5 +1,6 @@ id: raw_slimy_eel name: raw slimy eel +color: "fg=green" aliases: ["slimy eel", "eel"] description: "A slippery raw eel covered in slime." value: 10 diff --git a/data/items/raw_swordfish.yaml b/data/items/raw_swordfish.yaml index 745fdb1..a09e3f4 100644 --- a/data/items/raw_swordfish.yaml +++ b/data/items/raw_swordfish.yaml @@ -1,5 +1,6 @@ id: raw_swordfish name: raw swordfish +color: "fg=blue" aliases: ["swordfish", "sword"] description: "A freshly caught raw swordfish." value: 18 diff --git a/data/items/raw_trout.yaml b/data/items/raw_trout.yaml index 0e96ba3..299f8fd 100644 --- a/data/items/raw_trout.yaml +++ b/data/items/raw_trout.yaml @@ -1,5 +1,6 @@ id: raw_trout name: raw trout +color: "fg=blue" aliases: ["trout", "fish", "raw"] description: "A freshly caught trout. Needs cooking." value: 3 diff --git a/data/items/raw_tuna.yaml b/data/items/raw_tuna.yaml index 747ef14..0b707c3 100644 --- a/data/items/raw_tuna.yaml +++ b/data/items/raw_tuna.yaml @@ -1,5 +1,6 @@ id: raw_tuna name: raw tuna +color: "fg=blue" aliases: ["tuna"] description: "A freshly caught raw tuna." value: 12 diff --git a/data/items/redwood_logs.yaml b/data/items/redwood_logs.yaml index 8e492c7..c3cd8e6 100644 --- a/data/items/redwood_logs.yaml +++ b/data/items/redwood_logs.yaml @@ -1,5 +1,6 @@ id: redwood_logs name: redwood logs +color: "fg=bright_red" aliases: ["logs", "redwood", "log"] description: "Some rare redwood logs." value: 100 diff --git a/data/items/rune_axe.yaml b/data/items/rune_axe.yaml index 8e145f1..13158fc 100644 --- a/data/items/rune_axe.yaml +++ b/data/items/rune_axe.yaml @@ -1,5 +1,6 @@ id: rune_axe name: rune axe +color: "fg=cyan" aliases: ["axe", "hatchet"] description: "An extremely sharp rune axe." value: 40000 diff --git a/data/items/rune_pickaxe.yaml b/data/items/rune_pickaxe.yaml index 9f0ca32..0086d9d 100644 --- a/data/items/rune_pickaxe.yaml +++ b/data/items/rune_pickaxe.yaml @@ -1,5 +1,6 @@ id: rune_pickaxe name: rune pickaxe +color: "fg=cyan" aliases: ["pick", "pickaxe"] description: "An extremely durable rune pickaxe." value: 40000 diff --git a/data/items/runite_ore.yaml b/data/items/runite_ore.yaml index 330ebe2..ca8e417 100644 --- a/data/items/runite_ore.yaml +++ b/data/items/runite_ore.yaml @@ -1,5 +1,6 @@ id: runite_ore name: runite ore +color: "fg=cyan" aliases: ["runite", "rune", "ore"] description: "A rare piece of light blue runite ore." value: 500 diff --git a/data/items/sacred_eel.yaml b/data/items/sacred_eel.yaml index 18b7991..2697f1b 100644 --- a/data/items/sacred_eel.yaml +++ b/data/items/sacred_eel.yaml @@ -1,5 +1,6 @@ id: sacred_eel name: sacred eel +color: "fg=magenta" aliases: ["sacred eel", "eel"] description: "A glowing eel imbued with sacred energy." value: 25 diff --git a/data/items/scrap_metal.yaml b/data/items/scrap_metal.yaml index ce4b137..6d96e89 100644 --- a/data/items/scrap_metal.yaml +++ b/data/items/scrap_metal.yaml @@ -1,5 +1,6 @@ id: scrap_metal name: scrap metal +color: "dim" aliases: ["scrap", "metal"] description: "A chunk of salvaged scrap metal." value: 2 diff --git a/data/items/silver_ore.yaml b/data/items/silver_ore.yaml index 0f2c7f2..b6554d5 100644 --- a/data/items/silver_ore.yaml +++ b/data/items/silver_ore.yaml @@ -1,5 +1,6 @@ id: silver_ore name: silver ore +color: "fg=white" aliases: ["silver", "ore"] description: "A piece of silver ore with a faint metallic sheen." value: 10 diff --git a/data/items/small_fishing_net.yaml b/data/items/small_fishing_net.yaml index 01dafd2..e5fab71 100644 --- a/data/items/small_fishing_net.yaml +++ b/data/items/small_fishing_net.yaml @@ -1,5 +1,6 @@ id: small_fishing_net name: small fishing net +color: "fg=white" aliases: ["net", "small net"] description: "A small hand-held fishing net." value: 5 diff --git a/data/items/steel_axe.yaml b/data/items/steel_axe.yaml index ba14b16..976a5af 100644 --- a/data/items/steel_axe.yaml +++ b/data/items/steel_axe.yaml @@ -1,5 +1,6 @@ id: steel_axe name: steel axe +color: "fg=white" aliases: ["axe", "hatchet"] description: "A sharp steel axe for serious woodcutting." value: 200 diff --git a/data/items/steel_pickaxe.yaml b/data/items/steel_pickaxe.yaml index d2b24d4..282e0e5 100644 --- a/data/items/steel_pickaxe.yaml +++ b/data/items/steel_pickaxe.yaml @@ -1,5 +1,6 @@ id: steel_pickaxe name: steel pickaxe +color: "fg=white" aliases: ["pick", "pickaxe"] description: "A sharp steel pickaxe." value: 200 diff --git a/data/items/teak_logs.yaml b/data/items/teak_logs.yaml index 49be224..b33a86a 100644 --- a/data/items/teak_logs.yaml +++ b/data/items/teak_logs.yaml @@ -1,5 +1,6 @@ id: teak_logs name: teak logs +color: "fg=bright_yellow" aliases: ["logs", "teak", "log"] description: "Some solid teak logs." value: 12 diff --git a/data/items/tin_ore.yaml b/data/items/tin_ore.yaml index 97d0adb..b2227aa 100644 --- a/data/items/tin_ore.yaml +++ b/data/items/tin_ore.yaml @@ -1,5 +1,6 @@ id: tin_ore name: tin ore +color: "fg=white" aliases: ["tin", "ore"] description: "A lump of tin ore, ready for smelting." value: 3 diff --git a/data/items/uncut_diamond.yaml b/data/items/uncut_diamond.yaml index 843b2a4..1c3fffe 100644 --- a/data/items/uncut_diamond.yaml +++ b/data/items/uncut_diamond.yaml @@ -1,5 +1,6 @@ id: uncut_diamond name: uncut diamond +color: "fg=white" aliases: ["diamond", "gem", "uncut"] description: "An uncut diamond. Can be cut with crafting." value: 200 diff --git a/data/items/uncut_emerald.yaml b/data/items/uncut_emerald.yaml index e584286..2ec0d5c 100644 --- a/data/items/uncut_emerald.yaml +++ b/data/items/uncut_emerald.yaml @@ -1,5 +1,6 @@ id: uncut_emerald name: uncut emerald +color: "fg=green" aliases: ["emerald", "gem", "uncut"] description: "An uncut emerald. Can be cut with crafting." value: 50 diff --git a/data/items/uncut_ruby.yaml b/data/items/uncut_ruby.yaml index 560bf08..491bbe0 100644 --- a/data/items/uncut_ruby.yaml +++ b/data/items/uncut_ruby.yaml @@ -1,5 +1,6 @@ id: uncut_ruby name: uncut ruby +color: "fg=bright_red" aliases: ["ruby", "gem", "uncut"] description: "An uncut ruby. Can be cut with crafting." value: 100 diff --git a/data/items/uncut_sapphire.yaml b/data/items/uncut_sapphire.yaml index 2f5c93e..9e61020 100644 --- a/data/items/uncut_sapphire.yaml +++ b/data/items/uncut_sapphire.yaml @@ -1,5 +1,6 @@ id: uncut_sapphire name: uncut sapphire +color: "fg=blue" aliases: ["sapphire", "gem", "uncut"] description: "An uncut sapphire. Can be cut with crafting." value: 25 diff --git a/data/items/willow_logs.yaml b/data/items/willow_logs.yaml index f337168..fa87857 100644 --- a/data/items/willow_logs.yaml +++ b/data/items/willow_logs.yaml @@ -1,5 +1,6 @@ id: willow_logs name: willow logs +color: "fg=green" aliases: ["logs", "willow", "log"] description: "Some flexible willow logs." value: 8 diff --git a/data/items/yew_logs.yaml b/data/items/yew_logs.yaml index 6e120b1..6ca8c88 100644 --- a/data/items/yew_logs.yaml +++ b/data/items/yew_logs.yaml @@ -1,5 +1,6 @@ id: yew_logs name: yew logs +color: "fg=green" aliases: ["logs", "yew", "log"] description: "Some high-quality yew logs." value: 40 diff --git a/data/objects/adamantite_rock.yaml b/data/objects/adamantite_rock.yaml index 603f358..70b7b24 100644 --- a/data/objects/adamantite_rock.yaml +++ b/data/objects/adamantite_rock.yaml @@ -1,3 +1,4 @@ id: adamantite_rock name: adamantite rock +color: "fg=green" behavior: mine_adamantite diff --git a/data/objects/anglerfish_spot.yaml b/data/objects/anglerfish_spot.yaml index 8d8658f..dfe244e 100644 --- a/data/objects/anglerfish_spot.yaml +++ b/data/objects/anglerfish_spot.yaml @@ -1,5 +1,6 @@ id: anglerfish_spot name: anglerfish spot +color: "fg=cyan" behavior: fish_angler props: description: "A faint glow emanates from the depths. Use a fishing rod with bait here." diff --git a/data/objects/bait_fishing_spot.yaml b/data/objects/bait_fishing_spot.yaml index 60971a6..30925f6 100644 --- a/data/objects/bait_fishing_spot.yaml +++ b/data/objects/bait_fishing_spot.yaml @@ -1,5 +1,6 @@ id: bait_fishing_spot name: bait fishing spot +color: "fg=blue" behavior: fish_bait props: description: "Small fish ripple the surface. Use a fishing rod with bait here." diff --git a/data/objects/cage_fishing_spot.yaml b/data/objects/cage_fishing_spot.yaml index 1a7d848..ac40567 100644 --- a/data/objects/cage_fishing_spot.yaml +++ b/data/objects/cage_fishing_spot.yaml @@ -1,5 +1,6 @@ id: cage_fishing_spot name: cage fishing spot +color: "fg=blue" behavior: fish_cage props: description: "A rocky underwater shelf. Use a lobster pot here." diff --git a/data/objects/clay_rock.yaml b/data/objects/clay_rock.yaml index 368373f..1c9f6c5 100644 --- a/data/objects/clay_rock.yaml +++ b/data/objects/clay_rock.yaml @@ -1,3 +1,4 @@ id: clay_rock name: clay deposit +color: "fg=bright_red" behavior: mine_clay diff --git a/data/objects/coal_rock.yaml b/data/objects/coal_rock.yaml index 1f44461..57831f5 100644 --- a/data/objects/coal_rock.yaml +++ b/data/objects/coal_rock.yaml @@ -1,3 +1,4 @@ id: coal_rock name: coal rock +color: "dim" behavior: mine_coal diff --git a/data/objects/copper_rock.yaml b/data/objects/copper_rock.yaml index c16bc53..7b027c8 100644 --- a/data/objects/copper_rock.yaml +++ b/data/objects/copper_rock.yaml @@ -1,3 +1,4 @@ id: copper_rock name: copper rock +color: "fg=bright_yellow" behavior: mine_copper diff --git a/data/objects/fire.yaml b/data/objects/fire.yaml index 58ccc92..50cc98c 100644 --- a/data/objects/fire.yaml +++ b/data/objects/fire.yaml @@ -1,5 +1,6 @@ id: fire name: fire +color: "fg=bright_yellow" behavior: "" hidden: false inroom_description: "A fire is burning warmly." diff --git a/data/objects/fly_fishing_spot.yaml b/data/objects/fly_fishing_spot.yaml index b9545c9..18fe6f8 100644 --- a/data/objects/fly_fishing_spot.yaml +++ b/data/objects/fly_fishing_spot.yaml @@ -1,5 +1,6 @@ id: fly_fishing_spot name: fly fishing spot +color: "fg=blue" behavior: fish_fly props: description: "Insects dance above the water. Use a fly fishing rod with feathers here." diff --git a/data/objects/gold_rock.yaml b/data/objects/gold_rock.yaml index d701817..566e828 100644 --- a/data/objects/gold_rock.yaml +++ b/data/objects/gold_rock.yaml @@ -1,3 +1,4 @@ id: gold_rock name: gold rock +color: "fg=bright_yellow" behavior: mine_gold diff --git a/data/objects/harpoon_fishing_spot.yaml b/data/objects/harpoon_fishing_spot.yaml index c7e7f89..bd29de9 100644 --- a/data/objects/harpoon_fishing_spot.yaml +++ b/data/objects/harpoon_fishing_spot.yaml @@ -1,5 +1,6 @@ id: harpoon_fishing_spot name: harpoon fishing spot +color: "fg=blue" behavior: fish_harpoon props: description: "Large shadows move beneath the waves. Use a harpoon here." diff --git a/data/objects/iron_gate.yaml b/data/objects/iron_gate.yaml index 7ff0bfb..eba87be 100644 --- a/data/objects/iron_gate.yaml +++ b/data/objects/iron_gate.yaml @@ -1,5 +1,6 @@ id: iron_gate name: iron gate +color: "dim" behavior: iron_gate_toggle hidden: true props: diff --git a/data/objects/iron_rock.yaml b/data/objects/iron_rock.yaml index 448ddc3..4875b78 100644 --- a/data/objects/iron_rock.yaml +++ b/data/objects/iron_rock.yaml @@ -1,3 +1,4 @@ id: iron_rock name: iron rock +color: "dim" behavior: mine_iron diff --git a/data/objects/lumby_fountain.yaml b/data/objects/lumby_fountain.yaml index a6a5d1a..e46d0f5 100644 --- a/data/objects/lumby_fountain.yaml +++ b/data/objects/lumby_fountain.yaml @@ -1,5 +1,6 @@ id: lumby_fountain name: town fountain +color: "fg=cyan" behavior: "" props: description: "Clear water sparkles in the sunlight." diff --git a/data/objects/magic_tree.yaml b/data/objects/magic_tree.yaml index 5e82407..4dd8140 100644 --- a/data/objects/magic_tree.yaml +++ b/data/objects/magic_tree.yaml @@ -1,3 +1,4 @@ id: magic_tree name: magic tree +color: "fg=magenta" behavior: chop_magic diff --git a/data/objects/mahogany_tree.yaml b/data/objects/mahogany_tree.yaml index 33bc35e..9e74031 100644 --- a/data/objects/mahogany_tree.yaml +++ b/data/objects/mahogany_tree.yaml @@ -1,3 +1,4 @@ id: mahogany_tree name: mahogany tree +color: "fg=bright_red" behavior: chop_mahogany diff --git a/data/objects/maple_tree.yaml b/data/objects/maple_tree.yaml index 8d145c0..9ad8cf0 100644 --- a/data/objects/maple_tree.yaml +++ b/data/objects/maple_tree.yaml @@ -1,3 +1,4 @@ id: maple_tree name: maple tree +color: "fg=bright_red" behavior: chop_maple diff --git a/data/objects/mithril_rock.yaml b/data/objects/mithril_rock.yaml index afe106b..6ca5975 100644 --- a/data/objects/mithril_rock.yaml +++ b/data/objects/mithril_rock.yaml @@ -1,3 +1,4 @@ id: mithril_rock name: mithril rock +color: "fg=blue" behavior: mine_mithril diff --git a/data/objects/net_fishing_spot.yaml b/data/objects/net_fishing_spot.yaml index 0f1cd96..6dda919 100644 --- a/data/objects/net_fishing_spot.yaml +++ b/data/objects/net_fishing_spot.yaml @@ -1,5 +1,6 @@ id: net_fishing_spot name: net fishing spot +color: "fg=blue" behavior: fish_net props: description: "Bubbles break the surface. Use a small or big fishing net here." diff --git a/data/objects/oak_tree.yaml b/data/objects/oak_tree.yaml index d3a8e74..f733739 100644 --- a/data/objects/oak_tree.yaml +++ b/data/objects/oak_tree.yaml @@ -1,3 +1,4 @@ id: oak_tree name: oak tree +color: "fg=green" behavior: chop_oak diff --git a/data/objects/redwood_tree.yaml b/data/objects/redwood_tree.yaml index 345f037..fcb8296 100644 --- a/data/objects/redwood_tree.yaml +++ b/data/objects/redwood_tree.yaml @@ -1,3 +1,4 @@ id: redwood_tree name: redwood tree +color: "fg=bright_red" behavior: chop_redwood diff --git a/data/objects/runite_rock.yaml b/data/objects/runite_rock.yaml index df40aa3..d8c793a 100644 --- a/data/objects/runite_rock.yaml +++ b/data/objects/runite_rock.yaml @@ -1,3 +1,4 @@ id: runite_rock name: runite rock +color: "fg=cyan" behavior: mine_runite diff --git a/data/objects/sacred_eel_spot.yaml b/data/objects/sacred_eel_spot.yaml index 9925727..772a201 100644 --- a/data/objects/sacred_eel_spot.yaml +++ b/data/objects/sacred_eel_spot.yaml @@ -1,5 +1,6 @@ id: sacred_eel_spot name: sacred eel spot +color: "fg=magenta" behavior: fish_sacred_eel props: description: "Waters shimmer with an otherworldly light. Use a fishing rod with bait here." diff --git a/data/objects/scrap_pile.yaml b/data/objects/scrap_pile.yaml index d67ef11..0aa518e 100644 --- a/data/objects/scrap_pile.yaml +++ b/data/objects/scrap_pile.yaml @@ -1,3 +1,4 @@ id: scrap_pile name: scrap pile +color: "dim" behavior: mine_scrap diff --git a/data/objects/silver_rock.yaml b/data/objects/silver_rock.yaml index c5a9658..0b4d646 100644 --- a/data/objects/silver_rock.yaml +++ b/data/objects/silver_rock.yaml @@ -1,3 +1,4 @@ id: silver_rock name: silver rock +color: "fg=white" behavior: mine_silver diff --git a/data/objects/swamp_fishing_spot.yaml b/data/objects/swamp_fishing_spot.yaml index 8fc83b7..3605be3 100644 --- a/data/objects/swamp_fishing_spot.yaml +++ b/data/objects/swamp_fishing_spot.yaml @@ -1,5 +1,6 @@ id: swamp_fishing_spot name: swamp fishing spot +color: "fg=green" behavior: fish_swamp props: description: "Murky water bubbles ominously. Use a fishing rod with bait here." diff --git a/data/objects/teak_tree.yaml b/data/objects/teak_tree.yaml index 628ec8a..801ebd1 100644 --- a/data/objects/teak_tree.yaml +++ b/data/objects/teak_tree.yaml @@ -1,3 +1,4 @@ id: teak_tree name: teak tree +color: "fg=bright_yellow" behavior: chop_teak diff --git a/data/objects/tin_rock.yaml b/data/objects/tin_rock.yaml index 96c524d..a65d66d 100644 --- a/data/objects/tin_rock.yaml +++ b/data/objects/tin_rock.yaml @@ -1,3 +1,4 @@ id: tin_rock name: tin rock +color: "fg=white" behavior: mine_tin diff --git a/data/objects/tree.yaml b/data/objects/tree.yaml index cfcbe02..d73ff7f 100644 --- a/data/objects/tree.yaml +++ b/data/objects/tree.yaml @@ -1,3 +1,4 @@ id: tree name: tree +color: "fg=green" behavior: chop_tree diff --git a/data/objects/willow_tree.yaml b/data/objects/willow_tree.yaml index b5426ff..33221bd 100644 --- a/data/objects/willow_tree.yaml +++ b/data/objects/willow_tree.yaml @@ -1,3 +1,4 @@ id: willow_tree name: willow tree +color: "fg=green" behavior: chop_willow diff --git a/data/objects/yew_tree.yaml b/data/objects/yew_tree.yaml index 3e784f7..c43f06f 100644 --- a/data/objects/yew_tree.yaml +++ b/data/objects/yew_tree.yaml @@ -1,3 +1,4 @@ id: yew_tree name: yew tree +color: "fg=green" behavior: chop_yew diff --git a/internal/action/doc.go b/internal/action/doc.go deleted file mode 100644 index c1a898d..0000000 --- a/internal/action/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package action defines the behavior type system, action state tracking, -// and drop table resolution for the game world. -// -// Behaviors are YAML-driven and come in four types: gather, talk, use, and toggle. -// This package provides configuration structs for each type, the Action struct -// that tracks per-player action state (type, target, tick countdown), and the -// Store for loading behaviors and resolving weighted drop tables. -// -// The SuccessChance function implements the OSRS-style skill check formula. -// Drop table resolution supports nested sub-tables via the ResolveDrop method. -package action diff --git a/internal/color/color.go b/internal/color/color.go index c09a1db..4ef66b8 100644 --- a/internal/color/color.go +++ b/internal/color/color.go @@ -122,79 +122,65 @@ func Style(name string) string { return "" } -func Wrap(mode, name, text string) string { - if mode == "none" || mode == "" { - return text - } - return Fg(mode, name) + text + Reset +type ColorSpec struct { + Fg string + Bg string + Bold bool + Dim bool + Italic bool + Underline bool } -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 Parse(input string) ColorSpec { + var spec ColorSpec + for _, token := range strings.Fields(input) { + switch { + case token == "bold": + spec.Bold = true + case token == "dim": + spec.Dim = true + case token == "italic": + spec.Italic = true + case token == "underline": + spec.Underline = true + case strings.HasPrefix(token, "fg="): + spec.Fg = strings.TrimPrefix(token, "fg=") + case strings.HasPrefix(token, "bg="): + spec.Bg = strings.TrimPrefix(token, "bg=") } } + return spec } -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 (s ColorSpec) Empty() bool { + return s.Fg == "" && s.Bg == "" && !s.Bold && !s.Dim && !s.Italic && !s.Underline } -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):] +func Render(mode string, spec ColorSpec, text string) string { + if mode == "none" || mode == "" || spec.Empty() { + return text } - return s -} - -func cleanTags(text string) string { - result := text - for _, name := range knownColors { - result = strings.ReplaceAll(result, "<"+name+">", "") - result = strings.ReplaceAll(result, "</"+name+">", "") + var codes []string + if spec.Bold { + codes = append(codes, Style("bold")) + } + if spec.Dim { + codes = append(codes, Style("dim")) + } + if spec.Italic { + codes = append(codes, Style("italic")) + } + if spec.Underline { + codes = append(codes, Style("underline")) + } + if spec.Fg != "" { + codes = append(codes, Fg(mode, spec.Fg)) + } + if spec.Bg != "" { + codes = append(codes, Bg(mode, spec.Bg)) + } + if len(codes) == 0 { + return text } - return result + return strings.Join(codes, "") + text + Reset } diff --git a/internal/combat/doc.go b/internal/combat/doc.go deleted file mode 100644 index 758be47..0000000 --- a/internal/combat/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package combat implements OSRS-style combat formulas and per-player -// combat state tracking. -// -// Combat uses attack/defense roll formulas, style bonuses, and max-hit -// calculations. The State type tracks active combat between a player and -// a mob instance, with a lock timer preventing immediate disengagement. -// -// Combat state is managed through package-level maps protected by a mutex. -// TickCombat decrements lock timers each tick. EnterCombat and LeaveCombat -// manage the relationship between player names and mob instance IDs. -package combat diff --git a/internal/config/config.go b/internal/config/config.go index 5c0a4b0..48f1a69 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,11 +8,39 @@ import ( type Config struct { Game GameConfig `yaml:"game"` + Colors ColorsConfig `yaml:"colors"` Telnet TelnetConfig `yaml:"telnet"` HTTP HTTPConfig `yaml:"http"` HTTPS HTTPSConfig `yaml:"https"` } +type ColorsConfig map[string]string + +func DefaultColors() ColorsConfig { + return ColorsConfig{ + "room_name": "fg=cyan bold", + "room_number": "dim", + "room_desc": "fg=white", + "direction": "fg=cyan", + "exit_direction": "fg=cyan", + "exit_name": "fg=green", + "mob_name": "fg=bright_red", + "friendly_npc": "fg=green", + "hostile_npc": "fg=bright_red", + "damage": "fg=red", + "enemy_hp": "fg=red", + "character_hp": "fg=green", + "xp": "fg=yellow", + "level_up": "fg=bright_yellow bold", + "item": "fg=green", + "player_name": "fg=bright_white", + "death": "fg=red bold", + "victory": "fg=green", + "miss": "dim", + "error": "fg=red", + } +} + type GameConfig struct { TickLength int `yaml:"tick_length"` } @@ -39,6 +67,7 @@ func Default() *Config { Game: GameConfig{ TickLength: 600, }, + Colors: DefaultColors(), Telnet: TelnetConfig{ Enabled: true, Port: 4000, diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index f35aad3..0000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package config loads YAML server configuration and provides sensible -// defaults when no config file is present. -// -// Configuration covers three listener types: telnet (raw TCP), HTTP + WebSocket, -// and HTTPS + WebSocket. Each has an enabled flag and port. The game section -// controls display settings like max terminal width. -// -// Call Load(path) to read a YAML file merged with defaults, or Default() -// for a configuration suitable for running with telnet on :4000. -package config diff --git a/internal/engine/doc.go b/internal/engine/doc.go deleted file mode 100644 index 0ce25bd..0000000 --- a/internal/engine/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package engine provides a tick scheduler that drives all time-based -// world simulation. -// -// The Engine runs on a 600ms interval. Subscribers register callbacks with -// a tick interval (1 = every tick, N = every Nth tick). Callbacks return -// true to remain subscribed or false to auto-unsubscribe. -// -// The main game loop subscribes a single callback that advances combat, -// regeneration, wandering, woodcutting depletion, fire decay, and player -// actions all on the same tick beat. -package engine diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 08d2589..e3ea105 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -6,7 +6,6 @@ import ( "strings" "thirdcollapse/internal/action" - "thirdcollapse/internal/color" "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" @@ -178,7 +177,6 @@ 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 != "" { @@ -187,12 +185,12 @@ 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(color.Wrap(mode, "red", fmt.Sprintf("You've run out of %s.", baitName))) + sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) g.CancelAction(p) return } } - sess.WriteLine(fmt.Sprintf("\n%s", color.Tag(mode, cfg.GatherMsg))) + sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) p.Action.Data["step"] = 1 p.Action.WaitLeft = engine.ToTicks(wait) return @@ -205,7 +203,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { return } if cfg.RespawnMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", color.Tag(mode, cfg.RespawnMsg))) + sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) } p.Action.Data["step"] = 0 p.Action.WaitLeft = engine.ToTicks(wait) @@ -218,7 +216,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(color.Wrap(mode, "red", fmt.Sprintf("You've run out of %s.", baitName))) + sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) g.CancelAction(p) return } @@ -234,7 +232,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if drop != nil { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { - sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!")) + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) g.CancelAction(p) return } @@ -245,8 +243,9 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } itemName := drop.ItemID - if def, err := g.ItemStore.Load(drop.ItemID); err == nil { - itemName = def.Name + itemDef, _ := g.ItemStore.Load(drop.ItemID) + if itemDef != nil { + itemName = itemDef.Name } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) @@ -256,19 +255,17 @@ 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(color.Wrap(mode, "bright_yellow", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))) + sess.WriteLine(g.colorize(sess, "level_up", 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.", color.Wrap(mode, "green", itemName)) - } else { - msg = color.Tag(mode, msg) + msg = fmt.Sprintf("You manage to get some %s.", g.itemColorize(sess, itemDef, itemName)) } if xp > 0 && p.OptionBool("xp_drops") { - msg += color.Wrap(mode, "yellow", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)])) + msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)])) } sess.WriteLine(msg) @@ -309,7 +306,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { 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))) + other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) g.CancelAction(op) g.writePrompt(other) } @@ -321,7 +318,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } if p.FirstFreeSlot() == -1 { - sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!")) + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) g.CancelAction(p) return } @@ -331,9 +328,9 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } } - sess.WriteLine(color.Tag(mode, cfg.FailMsg)) + sess.WriteLine(cfg.FailMsg) if p.FirstFreeSlot() == -1 { - sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!")) + sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) g.CancelAction(p) return } diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index 4102841..9e90f93 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", g.colorTag(sess, node.Message))) + sess.WriteLine(fmt.Sprintf("\n%s", node.Message)) if node.Action != nil { g.applyNodeAction(sess, node.Action) diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 08780c3..ba4a2e1 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "thirdcollapse/internal/color" "thirdcollapse/internal/combat" "thirdcollapse/internal/engine" "thirdcollapse/internal/net" @@ -132,8 +131,7 @@ 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, ", ") + ")" } - mode := g.colorMode(sess) - sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", color.Wrap(mode, "bright_red", mobDisplayName(mob, true)), styleStr)) + sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), styleStr)) p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool { @@ -185,8 +183,6 @@ 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) @@ -204,7 +200,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(color.Wrap(mode, "bright_yellow", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) } mobName := mobDisplayName(mob, true) @@ -217,19 +213,19 @@ 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 %s damage.", color.Wrap(mode, "bright_red", mobName), color.Wrap(mode, "red", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP) + prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) if p.OptionBool("xp_drops") && len(gains) > 0 { var parts []string for _, gain := range gains { parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) } - line += color.Wrap(mode, "yellow", " ("+strings.Join(parts, ", ")+")") + line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") } sess.WriteLine(line) } else { - sess.WriteLine(color.Wrap(mode, "dim", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) } } @@ -247,8 +243,6 @@ 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) @@ -270,15 +264,15 @@ 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 %s damage.", color.Wrap(mode, "bright_red", attacker), color.Wrap(mode, "red", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP()) + prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP()) sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)) } else { attacker := mob.Name if !mob.Unique { attacker = "The " + mob.Name } - sess.WriteLine(color.Wrap(mode, "dim", fmt.Sprintf(" %s misses you.", attacker))) + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker))) } } @@ -286,10 +280,9 @@ 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(color.Wrap(mode, "red", "\nOh dear, you are dead!")) + sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!")) g.dropItemsOnDeath(p) p.HP = p.MaxHP() p.RoomID = 1 @@ -303,11 +296,14 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } if mob != nil && mob.HP <= 0 { - sess.WriteLine(color.Wrap(mode, "green", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true)))) + sess.WriteLine(g.colorize(sess, "victory", 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 { - other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob))) + op := other.Player.(*player.Player) + mobLvl := mobCombatLevel(mob) + levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) + other.WriteLine(fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)) } } } @@ -473,7 +469,9 @@ func (g *Game) respawnMob(instanceID string) { if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(homeRoom) { if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mob_spawn") { - sess.WriteLine(fmt.Sprintf("\n%s (level %d) spawns in the area.", mobDisplayName(inst, false), mobCombatLevel(inst))) + mobLvl := mobCombatLevel(inst) + levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) + sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", mobDisplayName(inst, false), levelStr)) } } } diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go new file mode 100644 index 0000000..7a208a2 --- /dev/null +++ b/internal/game/cmd_color.go @@ -0,0 +1,168 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/color" + "thirdcollapse/internal/config" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doColor(sess *net.Session, input string) { + if sess.Account == nil { + sess.WriteLine("\nYou must be logged into an account to set colors.") + return + } + + if input == "" { + showColorTable(g, sess) + return + } + + parts := strings.Fields(input) + target := strings.ToLower(parts[0]) + + if _, ok := config.DefaultColors()[target]; !ok { + sess.WriteLine(fmt.Sprintf("\nUnknown color target: %s", target)) + sess.WriteLine("Available targets:") + for _, t := range colorCategoryOrder { + sess.WriteLine(fmt.Sprintf(" %s", t)) + } + return + } + + if len(parts) == 1 { + current := g.getCurrentColor(sess, target) + source := g.colorSource(sess, target) + sess.WriteLine(fmt.Sprintf("\n%s = %s [source: %s]", target, current, source)) + return + } + + value := strings.Join(parts[1:], " ") + + if len(parts) == 2 && parts[1] == "reset" { + g.saveAccountColor(sess, target, "") + sess.WriteLine(fmt.Sprintf("\n%s reset to default (%s).", target, config.DefaultColors()[target])) + return + } + + if value == "off" { + g.saveAccountColor(sess, target, "off") + sess.WriteLine(fmt.Sprintf("\n%s set to off.", target)) + return + } + + spec := color.Parse(value) + if spec.Empty() && value != "" { + sess.WriteLine(fmt.Sprintf("\nInvalid color string: %s", value)) + sess.WriteLine("Format: fg=<color> bg=<color> bold dim italic underline") + sess.WriteLine("Example: fg=green bg=black bold") + return + } + + g.saveAccountColor(sess, target, value) + sess.WriteLine(fmt.Sprintf("\n%s set to %s.", target, value)) +} + +func (g *Game) saveAccountColor(sess *net.Session, target, value string) { + if value == "" { + if sess.Account.Colors != nil { + delete(sess.Account.Colors, target) + } + } else { + if sess.Account.Colors == nil { + sess.Account.Colors = make(map[string]string) + } + sess.Account.Colors[target] = value + } + + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.WriteLine("\nError loading account.") + return + } + if value == "" { + if acc.Colors != nil { + delete(acc.Colors, target) + } + } else { + if acc.Colors == nil { + acc.Colors = make(map[string]string) + } + acc.Colors[target] = value + } + g.AccountStore.SaveAccount(acc) +} + +func (g *Game) getCurrentColor(sess *net.Session, category string) string { + if sess.Account != nil && sess.Account.Colors != nil { + if val, ok := sess.Account.Colors[category]; ok { + if val == "off" { + return "off" + } + if val != "" { + return val + } + } + } + if g.ColorConfig != nil { + if val, ok := (*g.ColorConfig)[category]; ok && val != "" { + return val + } + } + return config.DefaultColors()[category] +} + +func (g *Game) colorSource(sess *net.Session, category string) string { + if sess.Account != nil && sess.Account.Colors != nil { + if _, ok := sess.Account.Colors[category]; ok { + return "account" + } + } + if g.ColorConfig != nil { + if _, ok := (*g.ColorConfig)[category]; ok { + return "default" + } + } + return "builtin" +} + +var colorCategoryOrder = []string{ + "room_name", + "room_number", + "room_desc", + "direction", + "exit_direction", + "exit_name", + "mob_name", + "friendly_npc", + "hostile_npc", + "damage", + "enemy_hp", + "character_hp", + "xp", + "level_up", + "item", + "player_name", + "death", + "victory", + "miss", + "error", +} + +func showColorTable(g *Game, sess *net.Session) { + p := sess.Player.(*player.Player) + table := &Table{ + Columns: []string{"Target", "Color", "Source"}, + } + for _, cat := range colorCategoryOrder { + val := g.getCurrentColor(sess, cat) + source := g.colorSource(sess, cat) + table.Rows = append(table.Rows, []string{cat, val, source}) + } + for _, line := range table.Render(p.OptionBool("unicode")) { + sess.WriteLine(line) + } +} diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 2bf00ce..b08eadf 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "thirdcollapse/internal/color" "thirdcollapse/internal/net" "thirdcollapse/internal/player" ) @@ -54,6 +53,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) { if def != nil { name = def.Name } + coloredName := g.itemColorize(sess, def, name) p.ActionState = &ActionState{Type: ActionDropping, TargetName: name} if def != nil && def.Stackable { @@ -63,9 +63,9 @@ func (g *Game) doDropAllNamed(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.", coloredName)) } else { - sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name)) + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, coloredName)) } return } @@ -82,16 +82,15 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) { } g.AccountStore.SaveCharacter(p) if total == 1 { - sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) + sess.WriteLine(fmt.Sprintf("You drop a %s.", coloredName)) } else { - sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, name)) + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, coloredName)) } } 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) @@ -151,9 +150,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.", color.Wrap(mode, "green", name))) + sess.WriteLine(fmt.Sprintf("You drop a %s.", g.itemColorize(sess, def, name))) } else { - sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, color.Wrap(mode, "green", name))) + sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, g.itemColorize(sess, def, name))) } return } diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go index cda39b1..4947d2b 100644 --- a/internal/game/cmd_equipment.go +++ b/internal/game/cmd_equipment.go @@ -23,6 +23,6 @@ func (g *Game) doEquipment(sess *net.Session) { if err == nil { name = def.Name } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, def, name))) } } diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 9591657..e75c59e 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -3,7 +3,6 @@ package game import ( "fmt" - "thirdcollapse/internal/color" "thirdcollapse/internal/net" "thirdcollapse/internal/player" ) @@ -11,7 +10,6 @@ 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.") @@ -87,7 +85,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, color.Wrap(mode, "green", def.Name), slot.Quantity)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity)) return } } @@ -103,9 +101,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.", color.Wrap(mode, "green", def.Name))) - } else { - sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, color.Wrap(mode, "green", def.Name))) + sess.WriteLine(fmt.Sprintf("You pick up a %s.", g.itemColorize(sess, def, def.Name))) + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, g.itemColorize(sess, def, def.Name))) } return } @@ -202,6 +200,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if def != nil { name = def.Name } + coloredName := g.itemColorize(sess, def, name) if def != nil && def.Stackable { for i := 0; i < 28; i++ { @@ -214,7 +213,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { } slot.Quantity += removed g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, name, slot.Quantity)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, coloredName, slot.Quantity)) return } } @@ -231,9 +230,9 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: removed}) g.AccountStore.SaveCharacter(p) if removed == 1 { - sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) + sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName)) } else { - sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", removed, name)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", removed, coloredName)) } return } @@ -256,12 +255,12 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { if picked == 0 { sess.WriteLine("Your inventory is full.") } else if picked == 1 { - sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) + sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName)) } else { - sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, name)) + sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, coloredName)) } if picked < available { - sess.WriteLine(fmt.Sprintf("You're only able to take %d x %s!", picked, name)) + sess.WriteLine(fmt.Sprintf("You're only able to take %d x %s!", picked, coloredName)) } } diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go index 9cc4ac6..7017b18 100644 --- a/internal/game/cmd_inventory.go +++ b/internal/game/cmd_inventory.go @@ -26,10 +26,11 @@ func (g *Game) doInventory(sess *net.Session) { if err == nil { name = def.Name } + coloredName := g.itemColorize(sess, def, name) if slot.Quantity > 1 { - sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name)) + sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, coloredName)) } else { - sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name)) + sess.WriteLine(fmt.Sprintf(" %2d) %s", num, coloredName)) } } sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty)) diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 992f3d0..bd738be 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -2,13 +2,14 @@ package game import ( "fmt" + "regexp" "sort" "strconv" "strings" - "thirdcollapse/internal/color" "thirdcollapse/internal/combat" "thirdcollapse/internal/net" + "thirdcollapse/internal/object" "thirdcollapse/internal/player" "thirdcollapse/internal/world" ) @@ -21,17 +22,20 @@ func (g *Game) doLook(sess *net.Session) { return } - mode := g.colorMode(sess) sess.WriteLines( "", - fmt.Sprintf("%s (#%d)", color.Wrap(mode, "cyan", room.Name), room.ID), + fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))), ) descWidth := p.OptionInt("room_desc_width") if descWidth <= 0 { descWidth = 70 } - descLines := wrapText(g.colorTag(sess, room.Description), descWidth) + rawLines := wrapText(room.Description, descWidth) + descLines := make([]string, len(rawLines)) + for i, l := range rawLines { + descLines[i] = g.colorize(sess, "room_desc", l) + } wroteDesc := false if p.OptionString("tiny_map") != "off" { mapLines := buildTinyMap(g, p.RoomID, mapGlyphsForPlayer(p.OptionBool("unicode"))) @@ -46,7 +50,7 @@ func (g *Game) doLook(sess *net.Session) { } } - mobs := g.MobStore.MobsInRoom(p.RoomID) + mobs := g.MobStore.MobsInRoom(p.RoomID) if len(mobs) > 0 { sort.Slice(mobs, func(i, j int) bool { iDamaged := mobs[i].HP < mobs[i].MaxHP @@ -57,10 +61,11 @@ func (g *Game) doLook(sess *net.Session) { return mobs[i].InstanceID < mobs[j].InstanceID }) sess.WriteLine("") + playerLevel := p.CombatLevel() for _, m := range mobs { hp := "" if m.HP < m.MaxHP { - hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP) + hp = fmt.Sprintf(" [%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(m.HP)), m.MaxHP) } var desc string if combat.IsMobInCombat(m.InstanceID) { @@ -77,7 +82,13 @@ func (g *Game) doLook(sess *net.Session) { if !m.Unique { displayName = "A " + m.Name } - sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc)) + npcColor := "hostile_npc" + if m.Protected { + npcColor = "friendly_npc" + } + mobLevel := mobCombatLevel(m) + levelStr := g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel)) + sess.WriteLine(fmt.Sprintf(" %s %s%s%s", g.colorize(sess, npcColor, displayName), levelStr, hp, desc)) } } @@ -138,15 +149,17 @@ func (g *Game) doLook(sess *net.Session) { } roomDesc := def.InRoomDescription + coloredName := g.objColorize(sess, def, def.Name) + coloredPlural := g.objColorize(sess, def, def.Name+"s") if len(freshIdxs) > 0 { var line string if roomDesc != "" { line = roomDesc } else if len(freshIdxs) == 1 { - line = "A " + def.Name + " is here." + line = fmt.Sprintf("A %s is here.", coloredName) } else { - line = fmt.Sprintf("%d %ss are here.", len(freshIdxs), def.Name) + line = fmt.Sprintf("%d %s are here.", len(freshIdxs), coloredPlural) } var suffix string if multi && (len(timed) > 0 || len(depleted) > 0) { @@ -164,7 +177,7 @@ func (g *Game) doLook(sess *net.Session) { if roomDesc != "" { line = roomDesc } else { - line = "A " + def.Name + " is here." + line = fmt.Sprintf("A %s is here.", coloredName) } tag := "" if multi { @@ -182,7 +195,7 @@ func (g *Game) doLook(sess *net.Session) { if roomDesc != "" { line = roomDesc } else { - line = "A " + def.Name + " is here." + line = fmt.Sprintf("A %s is here.", coloredName) } tag := "" if multi { @@ -227,14 +240,15 @@ func (g *Game) doLook(sess *net.Session) { if err == nil { name = def.Name } + coloredName := g.itemColorize(sess, def, name) prefix := "" if info.Quantity > 1 { - prefix = fmt.Sprintf(" %d x %s", info.Quantity, name) + prefix = fmt.Sprintf(" %d x %s", info.Quantity, coloredName) } else { - prefix = fmt.Sprintf(" %s", name) + prefix = fmt.Sprintf(" %s", coloredName) } - if len(prefix) > maxPrefix { - maxPrefix = len(prefix) + if visibleLen(prefix) > maxPrefix { + maxPrefix = visibleLen(prefix) } var parts []string if info.ReservedFor != "" { @@ -256,7 +270,8 @@ func (g *Game) doLook(sess *net.Session) { for _, l := range lines { if l.reserved != "" { - sess.WriteLine(fmt.Sprintf("%-*s%s", maxPrefix+1, l.prefix, l.reserved)) + pad := maxPrefix + 1 + (len(l.prefix) - visibleLen(l.prefix)) + sess.WriteLine(fmt.Sprintf("%-*s%s", pad, l.prefix, l.reserved)) } else { sess.WriteLine(l.prefix) } @@ -265,22 +280,36 @@ func (g *Game) doLook(sess *net.Session) { if len(room.Exits) > 0 { sess.WriteLine("") - if p.OptionBool("exits") { + if p.OptionBool("exits") { sess.WriteLine("Exits:") + type exitLine struct { + dir string + targetName string + } + var lines []exitLine + maxDirLen := 0 for _, dir := range world.ExitOrder { exitDef, ok := room.Exits[dir] if !ok { continue } + coloredDir := g.colorize(sess, "exit_direction", string(dir)) targetRoom, err := g.World.LoadRoom(exitDef.Room) - targetName := fmt.Sprintf("#%d", exitDef.Room) + targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) if err == nil { - targetName = targetRoom.Name + targetName = g.colorize(sess, "exit_name", targetRoom.Name) } if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) { targetName += " (blocked)" } - sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName)) + lines = append(lines, exitLine{coloredDir, targetName}) + if visibleLen(coloredDir) > maxDirLen { + maxDirLen = visibleLen(coloredDir) + } + } + for _, l := range lines { + pad := maxDirLen + (len(l.dir) - visibleLen(l.dir)) + sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.targetName)) } } else { sess.Write("Exits: ") @@ -290,7 +319,7 @@ func (g *Game) doLook(sess *net.Session) { if !first { sess.Write(", ") } - sess.Write(string(dir)) + sess.Write(g.colorize(sess, "exit_direction", string(dir))) first = false } } @@ -355,9 +384,15 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } } if best != nil { + npcColor := "hostile_npc" + if best.Protected { + npcColor = "friendly_npc" + } + mobLevel := mobCombatLevel(best) + levelStr := g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel)) sess.WriteLines( "", - fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)), + fmt.Sprintf("%s %s", g.colorize(sess, npcColor, best.Name), levelStr), ) if best.IdleDescription != "" { sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription)) @@ -423,7 +458,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } sess.WriteLines( "", - def.Name, + g.itemColorize(sess, def, def.Name), fmt.Sprintf(" %s", def.Description), fmt.Sprintf(" Value: %d credits", def.Value), ) @@ -469,10 +504,13 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { + myP := sess.Player.(*player.Player) + theirLevel := p.CombatLevel() + levelStr := g.levelColorize(sess, myP.CombatLevel(), theirLevel, fmt.Sprint(theirLevel)) sess.WriteLines( "", p.Name, - fmt.Sprintf(" Combat Level: %d", p.CombatLevel()), + fmt.Sprintf(" Combat Level: %s", levelStr), fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()), "", ) @@ -490,10 +528,12 @@ func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { continue } name := itemID + var itemDef *object.ItemDef if def, err := g.ItemStore.Load(itemID); err == nil { name = def.Name + itemDef = def } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, itemDef, name))) } if p.Description != "" { @@ -509,17 +549,31 @@ func (g *Game) doExits(sess *net.Session) { sess.WriteLine("There are no exits here.") return } + type exitLine struct { + dir string + name string + } + var lines []exitLine + maxDirLen := 0 for _, dir := range world.ExitOrder { exitDef, ok := room.Exits[dir] if !ok { continue } + coloredDir := g.colorize(sess, "exit_direction", string(dir)) targetRoom, err := g.World.LoadRoom(exitDef.Room) - targetName := fmt.Sprintf("#%d", exitDef.Room) + targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room)) if err == nil { - targetName = targetRoom.Name + targetName = g.colorize(sess, "exit_name", targetRoom.Name) + } + lines = append(lines, exitLine{coloredDir, targetName}) + if visibleLen(coloredDir) > maxDirLen { + maxDirLen = visibleLen(coloredDir) } - sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName)) + } + for _, l := range lines { + pad := maxDirLen + (len(l.dir) - visibleLen(l.dir)) + sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.name)) } } @@ -552,6 +606,12 @@ func mobInstanceIdx(mob *world.MobInstance, roomMobs []*world.MobInstance) int { return 0 } +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func visibleLen(s string) int { + return len(ansiRe.ReplaceAllString(s, "")) +} + func wrapText(text string, width int) []string { if width <= 0 { return []string{text} @@ -563,7 +623,7 @@ func wrapText(text string, width int) []string { var lines []string current := words[0] for _, word := range words[1:] { - if len(current)+1+len(word) <= width { + if visibleLen(current)+1+visibleLen(word) <= width { current += " " + word } else { lines = append(lines, current) @@ -596,7 +656,11 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine if leftMap { sess.WriteLine(fmt.Sprintf("%s %s", mapLine, desc)) } else { - sess.WriteLine(fmt.Sprintf("%-*s %s", mapWidth, desc, mapLine)) + pad := mapWidth + (len(desc) - visibleLen(desc)) + if pad < 0 { + pad = 0 + } + sess.WriteLine(fmt.Sprintf("%-*s %s", pad, desc, mapLine)) } } } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 65a2875..11aa17e 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -3,7 +3,6 @@ package game import ( "fmt" - "thirdcollapse/internal/color" "thirdcollapse/internal/combat" "thirdcollapse/internal/net" "thirdcollapse/internal/player" @@ -65,8 +64,6 @@ 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 { @@ -81,14 +78,14 @@ func (g *Game) doMove(sess *net.Session, dir string) { } } - sess.WriteLine(fmt.Sprintf("\nYou walk %s.", color.Wrap(mode, "cyan", string(exitDir)))) + sess.WriteLine(fmt.Sprintf("\nYou walk %s.", g.colorize(sess, "direction", 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(color.Wrap(mode, "cyan", targetRoom.Name)) + sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name)) } } diff --git a/internal/game/color.go b/internal/game/color.go index 45df212..f93f097 100644 --- a/internal/game/color.go +++ b/internal/game/color.go @@ -2,7 +2,9 @@ package game import ( "thirdcollapse/internal/color" + "thirdcollapse/internal/config" "thirdcollapse/internal/net" + "thirdcollapse/internal/object" "thirdcollapse/internal/player" ) @@ -13,10 +15,66 @@ func (g *Game) colorMode(sess *net.Session) string { return "none" } -func (g *Game) colorize(sess *net.Session, name, text string) string { - return color.Wrap(g.colorMode(sess), name, text) +func (g *Game) resolveColor(sess *net.Session, category string) color.ColorSpec { + if sess.Account != nil && sess.Account.Colors != nil { + if val, ok := sess.Account.Colors[category]; ok { + if val == "off" { + return color.ColorSpec{} + } + if val != "" { + return color.Parse(val) + } + } + } + if g.ColorConfig != nil { + if val, ok := (*g.ColorConfig)[category]; ok && val != "" { + return color.Parse(val) + } + } + if val, ok := config.DefaultColors()[category]; ok && val != "" { + return color.Parse(val) + } + return color.ColorSpec{} +} + +func (g *Game) colorize(sess *net.Session, category, text string) string { + spec := g.resolveColor(sess, category) + return color.Render(g.colorMode(sess), spec, text) +} + +func levelColorSpec(myLevel, theirLevel int) color.ColorSpec { + diff := theirLevel - myLevel + switch { + case diff == 0: + return color.Parse("fg=white") + case diff > 0 && diff < 5: + return color.Parse("fg=bright_yellow") + case diff >= 5: + return color.Parse("fg=bright_red") + case diff < 0 && diff > -5: + return color.Parse("fg=yellow") + default: + return color.Parse("fg=green") + } +} + +func (g *Game) levelColorize(sess *net.Session, myLevel, theirLevel int, text string) string { + spec := levelColorSpec(myLevel, theirLevel) + return color.Render(g.colorMode(sess), spec, text) } -func (g *Game) colorTag(sess *net.Session, text string) string { - return color.Tag(g.colorMode(sess), text) +func (g *Game) objColorize(sess *net.Session, objDef *object.ObjectDef, text string) string { + if objDef != nil && objDef.Color != "" { + spec := color.Parse(objDef.Color) + return color.Render(g.colorMode(sess), spec, text) + } + return text +} + +func (g *Game) itemColorize(sess *net.Session, itemDef *object.ItemDef, text string) string { + if itemDef != nil && itemDef.Color != "" { + spec := color.Parse(itemDef.Color) + return color.Render(g.colorMode(sess), spec, text) + } + return g.colorize(sess, "item", text) } diff --git a/internal/game/doc.go b/internal/game/doc.go deleted file mode 100644 index e276994..0000000 --- a/internal/game/doc.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package game is the core session handler, command dispatch, login flow, -// and action system for the MUD. -// -// The Game struct holds all world state: room/world access, item/object -// stores, mob instances, behavior definitions, the player hub, and the -// tick engine. HandleSession routes incoming input through the login -// state machine into the in-game command dispatch. -// -// Commands are dispatched via handleGameCommand in game.go, which -// resolves account aliases before switching on the command word. -// Most commands cancel the player's ongoing action (gathering, etc.). -// -// Action system: StartAction normalizes verbs, resolves object/mob -// targets, loads behaviors, and routes to type-specific handlers. -// Actions that take time (gather, use, burn, stoke) tick via -// AdvanceActions, called each 600ms tick. -// -// Tick handlers: ProcessQueuedCommands, DisconnectTick, RegenTick, -// WanderTick, SharedDepletionTick, and FireTick run each game tick to -// advance world simulation. -// -// File organization: -// cmd_*.go — player command handlers (doLook, doMove, doGet, etc.) -// action_*.go — action lifecycle (startGather, advanceBurn, talk, toggle, etc.) -// login.go — account creation, authentication, character management -// map.go — BFS graph builder, tiny + full-map rendering -// tick.go — per-tick world updates (disconnect, regen, wander, woodcutting) -// utils.go — shared types and helper functions -// help.go — help topic loading and display -package game diff --git a/internal/game/game.go b/internal/game/game.go index 3c00d7e..456419f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -9,6 +9,7 @@ import ( "thirdcollapse/internal/action" "thirdcollapse/internal/combat" + "thirdcollapse/internal/config" "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" @@ -42,6 +43,7 @@ type Game struct { Hub *net.Hub Ticks *engine.Engine WorldFlags map[string]any + ColorConfig *config.ColorsConfig dataDir string restTimers map[string]uint64 charsMu sync.Mutex @@ -52,7 +54,7 @@ type Game struct { pendingDepletions []pendingDepletion } -func New(dataDir string) *Game { +func New(dataDir string, colorConfig *config.ColorsConfig) *Game { return &Game{ World: world.New(dataDir), ObjectStore: object.NewObjectStore(dataDir), @@ -62,6 +64,7 @@ func New(dataDir string) *Game { BehaviorStore: action.NewStore(dataDir), Ticks: engine.New(), WorldFlags: make(map[string]any), + ColorConfig: colorConfig, dataDir: dataDir, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), @@ -123,7 +126,7 @@ func classifyCommand(cmd string) CommandClass { case "say", "score", "sc", "inventory", "i", "inv", "equipment", "eq", "look", "l", "exits", "help", "map", "option", "options", "alias", "unalias", - "description", "desc", "queued": + "description", "desc", "queued", "color", "colors": return ClassInstant case "wear", "wield", "remove", "unwear", "unwield", "style": return ClassFree @@ -140,16 +143,6 @@ func classifyCommand(cmd string) CommandClass { 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)) } @@ -307,6 +300,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doDescription(sess) case "option", "options": g.doOption(sess, strings.Join(args, " ")) + case "color", "colors": + g.doColor(sess, strings.Join(args, " ")) case "exits": g.doExits(sess) case "map": diff --git a/internal/game/help.go b/internal/game/help.go index 2e9caa4..4d716ad 100644 --- a/internal/game/help.go +++ b/internal/game/help.go @@ -17,6 +17,47 @@ type HelpDef struct { Content string `yaml:"description"` } +type cmdEntry struct { + Name string + Type string + Desc string +} + +var commandList = []cmdEntry{ + {"alias", "Instant", "Create command shortcuts"}, + {"attack / kill", "Active", "Attack a mob"}, + {"burn", "Active", "Start a fire"}, + {"chop / cut", "Active", "Chop trees (Woodcutting)"}, + {"color / colors", "Instant", "Customize display colors"}, + {"description / desc", "Instant", "Set your character description"}, + {"drop", "Active", "Drop items to the ground"}, + {"equipment / eq", "Instant", "Show equipped items"}, + {"exits", "Instant", "List available exits"}, + {"fish", "Active", "Fish at fishing spots (Fishing)"}, + {"get / take / pick", "Active", "Pick up items from the ground"}, + {"help", "Instant", "Show help topics"}, + {"inventory / i / inv", "Instant", "Show your inventory"}, + {"look / l", "Instant", "Look around or examine things"}, + {"map", "Instant", "Display an ASCII map of the area"}, + {"mine", "Active", "Mine rocks (Mining)"}, + {"north / south / east / west / up / down", "Active", "Move in a direction"}, + {"option / options", "Instant", "View or change settings"}, + {"pull / push", "Active", "Toggle levers and switches"}, + {"queued", "Instant", "Show pending tick actions"}, + {"quit", "Active", "Rest and disconnect"}, + {"remove / unwear / unwield", "Free", "Unequip items"}, + {"say", "Instant", "Chat with players in your room"}, + {"score / sc", "Instant", "View your stats and skills"}, + {"search", "Active", "Search items for loot"}, + {"stoke", "Active", "Add logs to a fire"}, + {"style", "Free", "Change combat style"}, + {"talk / speak / ask", "Active", "Talk to NPCs"}, + {"unalias", "Instant", "Remove command shortcuts"}, + {"use", "Active", "Use an object (crafting)"}, + {"walk", "Active", "Pathfind to a room or multi-step walk"}, + {"wear / wield", "Free", "Equip items"}, +} + func LoadHelp(dataDir string) ([]HelpDef, error) { dir := filepath.Join(dataDir, "help") entries, err := os.ReadDir(dir) @@ -43,39 +84,35 @@ func LoadHelp(dataDir string) ([]HelpDef, error) { } func (g *Game) doHelp(sess *net.Session, topic string) { - helps, err := LoadHelp(g.dataDir) - if err != nil || len(helps) == 0 { - sess.WriteLine("No help available.") - return - } - if topic == "" { - cats := make(map[string][]HelpDef) - var catOrder []string - for _, h := range helps { - if _, ok := cats[h.Category]; !ok { - catOrder = append(catOrder, h.Category) - } - cats[h.Category] = append(cats[h.Category], h) - } - unicode := true if p, _ := sess.Player.(*player.Player); p != nil { unicode = p.OptionBool("unicode") } sess.WriteLine("") - for _, cat := range catOrder { - t := &Table{Title: cat} - for _, h := range cats[cat] { - t.Rows = append(t.Rows, []string{h.Name, firstLine(h.Content)}) - } - for _, line := range t.Render(unicode) { - sess.WriteLine(line) - } - sess.WriteLine("") + t := &Table{ + Title: "Commands", + Columns: []string{"Command", "Type", "Description"}, + } + for _, c := range commandList { + t.Rows = append(t.Rows, []string{c.Name, c.Type, c.Desc}) + } + for _, line := range t.Render(unicode) { + sess.WriteLine(line) } - sess.WriteLine("Use 'help <command>' for details.") + sess.WriteLine("") + sess.WriteLine("Command types: Instant (runs immediately), Free (queued before active),") + sess.WriteLine("Active (replaces current action, queued per tick).") + sess.WriteLine("") + sess.WriteLine("Use 'help <command>' for detailed usage of a specific command.") + sess.WriteLine("Use 'option' to view and change settings, 'color' to customize colors.") + return + } + + helps, err := LoadHelp(g.dataDir) + if err != nil || len(helps) == 0 { + sess.WriteLine("No help available.") return } diff --git a/internal/game/login_account.go b/internal/game/login_account.go index 7243955..3ad1fee 100644 --- a/internal/game/login_account.go +++ b/internal/game/login_account.go @@ -68,10 +68,14 @@ func (g *Game) handlePassword(sess *net.Session, input string) { PasswordHash: acc.PasswordHash, Characters: acc.Characters, Aliases: acc.Aliases, + Colors: acc.Colors, } if sess.Account.Aliases == nil { sess.Account.Aliases = make(map[string]string) } + if sess.Account.Colors == nil { + sess.Account.Colors = make(map[string]string) + } g.showMenu(sess) } diff --git a/internal/game/prompt.go b/internal/game/prompt.go new file mode 100644 index 0000000..2fc36ac --- /dev/null +++ b/internal/game/prompt.go @@ -0,0 +1,133 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/color" + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +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 + } + } + + prompt = g.expandPromptColors(sess, prompt) + prompt = g.expandPromptVars(sess, prompt) + return prompt +} + +func (g *Game) expandPromptColors(sess *net.Session, text string) string { + mode := g.colorMode(sess) + for { + start := strings.Index(text, "{") + if start < 0 { + break + } + + end := strings.Index(text[start+1:], "}") + if end < 0 { + break + } + end += start + 1 + specStr := text[start+1 : end] + + if specStr == "" || specStr == "/" { + break + } + + spec := color.Parse(specStr) + if spec.Empty() { + break + } + + closeStart := strings.Index(text[end+1:], "{/}") + if closeStart < 0 { + break + } + closeStart += end + 1 + closeEnd := closeStart + 3 + + inner := text[end+1 : closeStart] + colored := color.Render(mode, spec, inner) + + text = text[:start] + colored + text[closeEnd:] + } + return text +} + +func (g *Game) expandPromptVars(sess *net.Session, text string) string { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil { + return text + } + + text = strings.ReplaceAll(text, "%%", "\x00") + text = strings.ReplaceAll(text, "%h", fmt.Sprint(p.HP)) + text = strings.ReplaceAll(text, "%H", fmt.Sprint(p.MaxHP())) + text = strings.ReplaceAll(text, "%c", fmt.Sprint(p.Credits)) + text = strings.ReplaceAll(text, "%i", fmt.Sprint(p.FreeSlots())) + text = strings.ReplaceAll(text, "%s", attackStyleShort(p.AttackStyle)) + text = strings.ReplaceAll(text, "%S", attackStyleLong(p.AttackStyle)) + + mobHP, mobMaxHP := "", "" + if cs := combat.GetCombat(p.Name); cs != nil { + mob := g.MobStore.GetInstance(cs.MobID) + if mob != nil && mob.HP > 0 { + mobHP = fmt.Sprint(mob.HP) + mobMaxHP = fmt.Sprint(mob.MaxHP) + } + } + text = strings.ReplaceAll(text, "%m", mobHP) + text = strings.ReplaceAll(text, "%M", mobMaxHP) + + for _, sk := range player.AllSkills { + tag := string(sk) + text = strings.ReplaceAll(text, "%X_"+tag, fmt.Sprint(p.Skills[sk])) + text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(xpToLevel(p, sk))) + } + + text = strings.ReplaceAll(text, "\x00", "%") + return text +} + +func attackStyleShort(s player.AttackStyle) string { + switch s { + case player.Accurate: + return "acc" + case player.Aggressive: + return "agg" + case player.Defensive: + return "def" + case player.Balanced: + return "bal" + } + return "acc" +} + +func attackStyleLong(s player.AttackStyle) string { + switch s { + case player.Accurate: + return "accurate" + case player.Aggressive: + return "aggressive" + case player.Defensive: + return "defensive" + case player.Balanced: + return "balanced" + } + return "accurate" +} + +func xpToLevel(p *player.Player, sk player.SkillName) int { + currentXP := p.Skills[sk] + currentLevel := p.Level(sk) + nextLevelXP := player.XPForLevel(currentLevel + 1) + return nextLevelXP - currentXP +} diff --git a/internal/net/doc.go b/internal/net/doc.go deleted file mode 100644 index 13e7f71..0000000 --- a/internal/net/doc.go +++ /dev/null @@ -1,15 +0,0 @@ -// Package net provides transport-agnostic network connections, session -// management, and a multi-listener server. -// -// The Conn interface abstracts TCP (telnet) and WebSocket connections, -// handling IAC echo control for password suppression. The Session type -// tracks connection state through the login flow (account name, password, -// menu navigation, character selection, and in-game play). -// -// The Hub tracks which sessions are in which rooms, enabling room-scoped -// messaging. The Server manages telnet, HTTP, and HTTPS listeners and -// dispatches incoming input to the game handler. -// -// Session I/O helpers (Write, WriteLine, WriteLines) handle CRLF line -// endings required by telnet clients. -package net diff --git a/internal/net/server.go b/internal/net/server.go index 084641f..d909cc2 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -47,6 +47,7 @@ type AccountEntry struct { PasswordHash string Characters []string Aliases map[string]string + Colors map[string]string } type Server struct { diff --git a/internal/object/doc.go b/internal/object/doc.go deleted file mode 100644 index 89f6db9..0000000 --- a/internal/object/doc.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package object defines item and object definitions with their YAML-backed -// loading stores. -// -// ItemDef covers all game items: equipment stats, weapon types, tool -// properties (tool_type, tool_speed for gathering/firemaking), firemaking -// fields (burn_ticks, fire_level, fire_xp), and stackable/quality metadata. -// -// ObjectDef covers interactive world objects: their behavior reference, -// hidden flag for unlisted-but-interactable objects, in-room description -// overrides, removal items, and arbitrary props. -// -// ItemStore and ObjectStore load definitions from YAML files in the -// data/items/ and data/objects/ directories, caching results in memory. -package object diff --git a/internal/object/item.go b/internal/object/item.go index f4e2f8b..ecdb1e9 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -33,6 +33,7 @@ const ( type ItemDef struct { ID string `yaml:"id"` Name string `yaml:"name"` + Color string `yaml:"color"` Aliases []string `yaml:"aliases"` Description string `yaml:"description"` Value int `yaml:"value"` diff --git a/internal/object/object.go b/internal/object/object.go index 2810e07..46e460e 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -3,6 +3,7 @@ package object type ObjectDef struct { ID string `yaml:"id"` Name string `yaml:"name"` + Color string `yaml:"color"` BehaviorID string `yaml:"behavior"` Hidden bool `yaml:"hidden"` InRoomDescription string `yaml:"inroom_description"` diff --git a/internal/player/account.go b/internal/player/account.go index 679c5bc..36fab43 100644 --- a/internal/player/account.go +++ b/internal/player/account.go @@ -5,4 +5,5 @@ type Account struct { PasswordHash string `yaml:"password_hash"` Characters []string `yaml:"characters"` Aliases map[string]string `yaml:"aliases"` + Colors map[string]string `yaml:"colors"` } diff --git a/internal/player/doc.go b/internal/player/doc.go deleted file mode 100644 index a163bd7..0000000 --- a/internal/player/doc.go +++ /dev/null @@ -1,14 +0,0 @@ -// Package player defines character data, skills, equipment, inventory, -// and account management for the game. -// -// The Player struct is the full character sheet: 23 skills with RSC-style -// XP progression, 28-slot inventory, 11-slot equipment, combat level -// calculation, and death mechanics. -// -// Accounts store authentication (salted sha256 passwords), character lists, -// and command aliases. Validation functions enforce name and password rules. -// The AccountStore manages persistence to YAML files under data/players/. -// -// Player options (15 settings) support bool, string, and int types with -// defined defaults and valid values. -package player diff --git a/internal/player/player.go b/internal/player/player.go index 47c2c37..4a6f065 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -115,7 +115,7 @@ var OptionDefs = []OptionDef{ {"reserve", OptBool, true, nil, "Show full reserved item details"}, {"depletion", OptBool, false, nil, "Show depletion and despawn timers"}, {"despawn", OptBool, false, nil, "Show ground item despawn timers"}, - {"color", OptString, "none", []string{"none", "ansi", "xterm256"}, "Color output mode"}, + {"color", OptString, "ansi", []string{"none", "ansi", "xterm256"}, "Color output mode"}, {"mapwidth", OptInt, 30, nil, "Map width for the map command"}, {"mapheight", OptInt, 20, nil, "Map height for the map command"}, {"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, diff --git a/internal/world/doc.go b/internal/world/doc.go deleted file mode 100644 index cc11535..0000000 --- a/internal/world/doc.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package world manages rooms, exits, ground items, mob instances, -// and object instance state for the game world. -// -// Rooms are loaded from YAML files on every access (live-editable). Exits -// support conditional blocking via the action.Condition system. Ground -// items have independent despawn timers and combat-loot reservations. -// -// Object instance state (ObjState) tracks per-instance properties: -// depletion with regen timers, shared depletion for trees with countdown, -// quality for fire objects, and wandering for fishing spots. -// -// MobDef and MobInstance cover mob definitions and runtime instances. -// The MobStore manages YAML loading, instance seeding, and per-room -// mob lists. Mobs wander via legal (unconditioned) room exits. -// -// WordPrefixMatch provides bidirectional prefix matching used by -// commands and object lookups. -package world |
