aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md28
-rw-r--r--README.md33
-rw-r--r--cmd/mud/main.go3
-rw-r--r--data/help/attack.yaml2
-rw-r--r--data/help/color.yaml4
-rw-r--r--data/help/combat.yaml24
-rw-r--r--data/help/movement.yaml26
-rw-r--r--data/help/prompt.yaml1
-rw-r--r--data/help/remove.yaml6
-rw-r--r--data/help/walk.yaml4
-rw-r--r--data/items/cape_of_agility.yaml6
-rw-r--r--data/items/graceful_boots.yaml6
-rw-r--r--data/items/graceful_cape.yaml6
-rw-r--r--data/items/graceful_gloves.yaml6
-rw-r--r--data/items/graceful_hat.yaml6
-rw-r--r--data/items/graceful_legs.yaml6
-rw-r--r--data/items/graceful_torso.yaml6
-rw-r--r--data/rooms/1.yaml22
-rw-r--r--internal/combat/state.go26
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/game/action.go1
-rw-r--r--internal/game/action_state.go2
-rw-r--r--internal/game/cmd_attack.go9
-rw-r--r--internal/game/cmd_move.go73
-rw-r--r--internal/game/cmd_prompt.go9
-rw-r--r--internal/game/cmd_remove.go48
-rw-r--r--internal/game/cmd_walk.go7
-rw-r--r--internal/game/color.go10
-rw-r--r--internal/game/game.go36
-rw-r--r--internal/game/tick.go38
-rw-r--r--internal/object/item.go2
-rw-r--r--internal/player/player.go24
32 files changed, 406 insertions, 76 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 7dc4c47..d3eec03 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
The House of Icarus — a Runescape-like sci-fi MUD written in Go. Data-driven via YAML files. No database, no ORM.
-Set on a terraformed asteroid where technology has regressed. "Science" replaces Prayer, "Technology" replaces Magic, Scavenging replaces Runecrafting.
+Set on a terraformed asteroid where technology has regressed. "Technology" replaces Prayer, "Science" replaces Magic, Scavenging replaces Runecrafting.
## Build & Run
@@ -34,7 +34,7 @@ internal/
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
+ combat/ Combat state tracking, OSRS-style combat formulas
object/ ItemDef, ObjectDef, item/object YAML loaders, EquipSlot, WeaponType, ItemStats
engine/ Tick scheduler — configurable ms interval, subscriber pattern, ToTicks()
color/ xterm-256 color system with auto ANSI downgrade, numeric palette (0-255), gradients, inline tags
@@ -55,7 +55,7 @@ internal/
| `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 |
+| `tick.go` | DisconnectTick, RegenTick, WanderTick, SharedDepletionTick, MoveTick, VisualTick |
| `map.go` | BFS graph builder, tiny map, full map, display utilities (strip, trim) |
| `types.go` | EquipSlots, itemMatch, xpGain, deathDrop |
| `utils.go` | Helper functions (plural, parseQty, parseChoiceIndex, formatPickupList, etc.) |
@@ -160,13 +160,13 @@ Item and object YAML `color` fields also support gradient syntax: `color: "g:196
|---------|-------------|
| `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 |
+| `remove` / `unwear` / `unwield <item>` | Unequip item back to inventory. `remove all` unequips everything |
| `eat <item>` | Eat food to heal (3-tick cooldown, can eat during combat) |
### Active (replaces previous active, queued per tick)
| Command | Description |
|---------|-------------|
-| `n` / `s` / `e` / `w` / `u` / `d` | Movement (conditional exits, interrupts combat/actions) |
+| `n` / `s` / `e` / `w` / `u` / `d` | Movement (multi-tick, equipment-dependent speed, flee during combat) |
| `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 |
@@ -234,7 +234,7 @@ The `verbSkill` map maps verb → skill name for default target resolution:
| `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 |
+| `moving` | n/s/e/w/u/d | Multi-tick movement state |
| `picking_up` | get | One-tick pickup state |
| `dropping` | drop | One-tick drop state |
@@ -341,7 +341,7 @@ See `internal/game/action_burn.go`.
## Combat System
-**Combat lock:** `LockedTicks: 15` — players cannot move for 15 ticks after entering combat. Decremented each tick.
+**Fleeing combat:** Players can move in any direction to attempt to flee. Movement takes the normal tick delay (default 4, reduced by equipment). During the countdown, the mob continues attacking. If the mob lands a hit, the escape fails ("Can't escape!") and combat resumes. If no hit lands, the player escapes. With Cape of Agility, fleeing is instant. The `run_countdown` option shows tick-by-tick countdown messages.
**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.
@@ -353,7 +353,7 @@ See `internal/game/action_burn.go`.
**Mob combat level:** `0.25 * (attack + strength + defense + maxHP)`
-**Player combat level:** Ranged-or-melee dominant calculation. Includes Science (0.25) and Technology (0.125).
+**Player combat level:** Ranged-or-melee dominant calculation. Includes Technology (0.25) and Science (0.125).
**Mob respawn:** Default 30 ticks. Broadcasts spawn messages if `mob_spawn` option is on.
@@ -383,8 +383,12 @@ Account-wide options set via `option <name> <value>`. Options are stored in the
| `queue_silently` | bool | true | Suppress queue messages |
| `room_desc_width` | int | 70 | Room desc line wrap width |
| `unicode` | bool | true | Unicode box-drawing characters |
+| `run_countdown` | bool | false | Show countdown messages when fleeing combat |
+| `visual_ticks` | bool | false | Display a tick marker every game tick |
+| `visual_tick_count` | int | 0 | Cycle length for tick counter (0 = no counter) |
+| `visual_tick_text` | string | "TICK" | Text displayed for visual ticks |
-Prompt is NOT an option — it is a per-character setting via the `prompt` command. Default: `"> "`. Stored in character YAML as `prompt:`.
+Prompt is NOT an option — it is a per-character setting via the `prompt` command. Default: `"> "`. `prompt none` sets it to blank. Stored in character YAML as `prompt:`.
New accounts are asked "Should I disable color? [y/N]" after password creation. Pressing enter defaults to No (xterm256 enabled). Choosing Y sets color to "none".
@@ -401,7 +405,7 @@ From `internal/object/item.go`:
| `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" |
+| `weapon_type` | "melee" / "ranged" / "science" |
| `stats` | attack/strength/defense/science/technology bonuses |
| `speed` | Attack speed |
| `tool_type` | Tool type string for gather requirements |
@@ -519,5 +523,7 @@ Implemented: Mining, Fishing, Woodcutting, Firemaking. The rest are stubs (skill
- 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.
+- `remove all` unequips everything if inventory has room.
+- Movement takes multiple ticks (default 4, reduced by Graceful set and Cape of Agility). Cape of Agility makes movement instant (1 tick). Graceful pieces each reduce by 0.25 ticks; full set bonus adds 0.5 for 2 ticks total. Cape overrides Graceful.
+- During combat, movement is a flee attempt. Mob hits during the countdown abort the flee ("Can't escape!").
- Level-up messages output `*** You are now level N <skill>! ***` across all skills that gain XP.
diff --git a/README.md b/README.md
index d1360a7..9b8192c 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,8 @@ There is not much here yet.
- 600ms game ticks.
- 28 slot inventory.
- Classless. All attributes are skills that automatically level 1-99 as you use them.
-- 100% solo friendly just like you remember from 2001. Grind and chat with friends.
-- Ironman-style, slow, grind up your own gear progression. Vendors don't sell craftable items.
+- Solo friendly just like you remember from 2001. Grind and chat with friends.
+- Ironman-style, slow, grind up your own gear progression. Vendors *don't* sell craftable items.
- A detailed casino for you to waste all the credits you grind up for.
## Skills
@@ -61,11 +61,14 @@ Then just telnet to the port configured in `config.yaml`.
The `thoi` binary checks for a file named config.yaml in the local directory, or else it creates one.
-The game is designed to run with 600ms game ticks. If you want the game to "respond instantly", that's not possible. **The ticks are a game mechanic.** Techniques like as science flicking, science/gear switches, the Agility/APS system, and certain power-skilling methods would not work with instant response or short ticks.
```
game:
tick_length: 600
+
+No. The skilling grind has always been a part of RS and many enjoy it still. I play RS because I can switch between chill stuff and active play, and because I like to work towards long-term goals.
+
+Not to mention, all those skills add value. Agility can a
```
The telnet/http/https options are pretty self-explanatory. THOI does not handle HTTPS certs for you. Use something like [certbot](https://certbot.eff.org/) on your server. The HTTP(S) server is just for a simple web client.
@@ -105,6 +108,30 @@ Those who embraced AI control of society and tried to stop the Neoluddites throu
**Craggers**
Colonists who don't take a side in the conflict one way or the other. Everyone from criminals escaping their past to once well-paid Earth engineers overseeing terraforming operations.
+# FAQ
+
+**Can I improve how fast the game responds?**
+
+THOI is designed to run with 600ms game ticks. If you want the game to "respond instantly" to commands, that's not possible and would break numerous things. Ticks are a game mechanic, not a limitation. You'll see that commands like `say`, `score`, or `style` take place immediately without game ticks. Ticks enables advanced techniques like tech flicking (enabling/disabling tech on the same tick to gain a buff without draining battery) and power skilling (restarting an action on certain ticks to avoid delays).
+
+**Why is movement so slow?**
+
+Distance between nodes is an important mechanic (e.g. mining node to bank to furnace). It completely determines the pace of the game. The 4-tick delay in movement simulates the real distance walking between areas and prevents the map from needing more filler areas. It also gives mobs 4-ticks to hit you and prevent you from escaping. There are *numerous* ways to improve movement:
+
+1. The `walk` command allows you to auto-walk to a certain room with one command like `walk 101` to walk to room #101. The number of rooms away you can walk increases with your Agility level.
+2. The `aps` command allows you to auto-walk towards an APS node. You can use this even if the APS node is outside your normal walk range.
+3. The Graceful Set reduces the delay by 0.25 ticks per piece, or 2 ticks if you have the full set.
+4. The Cape of Agility at level 99 Agility lets you move one room per tick with no delay!
+5. Higher technology levels allow you to fast-travel.
+6. You can use `recall` once every 3000 ticks (30 minutes) to return to room #1.
+7. Do stuff while walking! Train Fletching or Technology! Reprocess junk for credits! Talk to your fellow gamers!
+
+THOI is a game of slow progression and many aspects including movement are designed to be "semi-AFK", taking a while but not demanding your full attention.
+
+**Why can't I buy scrap/feathers/etc.?**
+
+THOI is a game about self-sufficiency on a forsaken asteroid. Supply ships aren't arriving to restock shops. More importantly, shops don't stock items produced by skills. A helmet shop removes the value of smithing your own helmet upgrade. A scrap shop reduces the value of scavenging your own scrap. Skills are not necessary chores to get back to combat, they are the game itself. Quality of life will have to be balanced, but the expected grinds should be between UIM and Ironman level to use a Runescape analogy.
+
# AI Disclosure
Huge technical parts of this codebase were ~~completely vibe coded~~ ~~agentically engineered~~ written with LLM assistance.
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index 18fd171..b8024ed 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -40,10 +40,10 @@ func main() {
defer g.Ticks.Stop()
g.Ticks.Subscribe(1, func() bool {
+ g.MoveTick()
g.ProcessQueuedCommands()
g.World.Tick()
g.MobStore.Tick()
- g.TickCombat()
g.RegenTick()
g.DisconnectTick()
g.WanderTick()
@@ -52,6 +52,7 @@ func main() {
g.AdvanceActions()
g.ConsumeTick()
g.BroadcastRespawns()
+ g.VisualTick()
return true
})
diff --git a/data/help/attack.yaml b/data/help/attack.yaml
index 96a4bcd..fb65ef6 100644
--- a/data/help/attack.yaml
+++ b/data/help/attack.yaml
@@ -12,4 +12,4 @@ description: |
name (e.g. "attack 1.man"). Plain "attack man" targets the first.
Attacks occur at weapon speed on the 600ms tick. Use "style" to change
- combat style. You are locked in place when combat starts.
+ combat style. Move in any direction to attempt to flee combat.
diff --git a/data/help/color.yaml b/data/help/color.yaml
index 5927e9a..6082183 100644
--- a/data/help/color.yaml
+++ b/data/help/color.yaml
@@ -77,6 +77,10 @@ description: |
drop_message Mob loot drops
credits_pickup Currency/credit messages
+ Visual tick colors:
+ visual_tick Visual tick marker text (dim blue)
+ visual_first_tick First tick in a numbered cycle (bright red)
+
Resolution order:
1. Account override (set via "color" command)
2. Server default (set in config.yaml)
diff --git a/data/help/combat.yaml b/data/help/combat.yaml
index 6336fae..b02fb1a 100644
--- a/data/help/combat.yaml
+++ b/data/help/combat.yaml
@@ -5,9 +5,29 @@ description: |
- 600ms tick engine with per-weapon attack speeds
- Attack, Strength, and Defense rolls determine hit/miss
- - Combat triangle: Melee > Ranged > Technology > Melee
+ - Combat triangle: Melee > Ranged > Science > Melee
- 4 XP per point of damage, distributed by combat style
- - You are locked in place for 15 ticks when combat starts
- Damaged mobs show current HP in look (damaged listed first)
- Mobs regenerate 1 HP every 100 ticks (60 seconds)
- Players also regenerate 1 HP every 100 ticks
+
+ Fleeing combat:
+ Move in any direction (n/s/e/w/u/d) to attempt to flee.
+ The movement countdown begins (default 4 ticks). If the mob
+ lands a hit during the countdown, you get "Can't escape!" and
+ remain in combat. If the mob misses or doesn't attack, you
+ successfully escape after the countdown.
+
+ Equipment that reduces movement time (Cape of Agility, Graceful
+ set) also reduces the flee countdown. With the Cape of Agility,
+ fleeing is instant.
+
+ Enable "run_countdown" option to see tick-by-tick messages
+ while fleeing.
+
+ Mob level colors in room display and broadcasts:
+ Green Mob is 5+ levels below you
+ Green-yellow Mob is 1-4 levels below you
+ White Mob is equal level
+ Orange Mob is 1-4 levels above you
+ Red Mob is 5+ levels above you
diff --git a/data/help/movement.yaml b/data/help/movement.yaml
index 91aefa6..19a09b9 100644
--- a/data/help/movement.yaml
+++ b/data/help/movement.yaml
@@ -6,8 +6,26 @@ description: |
Usage: north, south, east, west, up, down
Aliases: n, s, e, w, u, d
- You cannot move during the combat lock period after combat starts.
- Moving interrupts combat and any active gathering or crafting action.
+ Movement takes 4 game ticks by default. Certain equipment reduces
+ this delay:
- With "description" toggled on, moving shows the full room description
- instead of just the room name. Use "toggle" to change this setting.
+ Cape of Agility Instant movement (1 tick)
+ Graceful set Each piece reduces movement by 0.25 ticks.
+ Full set (6 pieces) gives an extra 0.5 bonus,
+ for 2 ticks total. Uses fractional ticks.
+
+ The Cape of Agility overrides all Graceful bonuses.
+
+ Moving interrupts any active gathering or crafting action.
+
+ During combat, movement becomes a flee attempt. The mob continues
+ to attack during the movement countdown. If the mob lands a hit
+ before the countdown finishes, the escape fails ("Can't escape!")
+ and combat resumes. If no hit lands, the player successfully
+ escapes. See 'help combat' for details.
+
+ The "run_countdown" option shows tick-by-tick countdown messages
+ when fleeing combat.
+
+ With "description" toggled on, moving shows the full room
+ description instead of just the room name.
diff --git a/data/help/prompt.yaml b/data/help/prompt.yaml
index 9af6b01..f3c23f4 100644
--- a/data/help/prompt.yaml
+++ b/data/help/prompt.yaml
@@ -6,6 +6,7 @@ description: |
Usage:
prompt Show current prompt
prompt <value> Set a custom prompt
+ prompt none Set prompt to blank
prompt reset Restore default "> "
Your prompt string can include variables and color blocks.
diff --git a/data/help/remove.yaml b/data/help/remove.yaml
index 757bb27..cce21f4 100644
--- a/data/help/remove.yaml
+++ b/data/help/remove.yaml
@@ -4,8 +4,12 @@ description: |
Unequip an item and return it to your inventory.
Usage: remove <item>, unwear <item>, unwield <item>
+ remove all, unwear all
Removes the named item (or the item in the named slot) and places it
in your inventory. If your inventory is full, you cannot remove it.
- Examples: remove axe, remove main_hand
+ "remove all" removes all equipped items at once. If there is not
+ enough inventory space for everything, nothing is removed.
+
+ Examples: remove axe, remove main_hand, remove all
diff --git a/data/help/walk.yaml b/data/help/walk.yaml
index 6c34ecd..11b8338 100644
--- a/data/help/walk.yaml
+++ b/data/help/walk.yaml
@@ -8,7 +8,9 @@ description: |
Direction mode:
Provide a string of direction characters (n, s, e, w, u, d).
- Each character queues one movement step per game tick.
+ Each character queues one movement step. Each step takes the
+ normal movement tick delay (default 4 ticks per step, reduced
+ by movement equipment like Graceful or Cape of Agility).
Examples:
walk nnewn Walk north, north, east, west, north (5 ticks)
diff --git a/data/items/cape_of_agility.yaml b/data/items/cape_of_agility.yaml
new file mode 100644
index 0000000..5567e73
--- /dev/null
+++ b/data/items/cape_of_agility.yaml
@@ -0,0 +1,6 @@
+id: cape_of_agility
+name: cape of agility
+color: "g:223,231,223"
+description: "A lightweight cape that allows the wearer to move with extraordinary speed."
+value: 500
+equip_slot: back
diff --git a/data/items/graceful_boots.yaml b/data/items/graceful_boots.yaml
new file mode 100644
index 0000000..71e8f84
--- /dev/null
+++ b/data/items/graceful_boots.yaml
@@ -0,0 +1,6 @@
+id: graceful_boots
+name: graceful boots
+color: "230"
+description: "Lightweight boots. Part of the graceful set."
+value: 80
+equip_slot: feet
diff --git a/data/items/graceful_cape.yaml b/data/items/graceful_cape.yaml
new file mode 100644
index 0000000..1d7c8f0
--- /dev/null
+++ b/data/items/graceful_cape.yaml
@@ -0,0 +1,6 @@
+id: graceful_cape
+name: graceful cape
+color: "230"
+description: "A lightweight cape. Part of the graceful set."
+value: 100
+equip_slot: back
diff --git a/data/items/graceful_gloves.yaml b/data/items/graceful_gloves.yaml
new file mode 100644
index 0000000..311ded5
--- /dev/null
+++ b/data/items/graceful_gloves.yaml
@@ -0,0 +1,6 @@
+id: graceful_gloves
+name: graceful gloves
+color: "230"
+description: "Lightweight gloves. Part of the graceful set."
+value: 80
+equip_slot: hands
diff --git a/data/items/graceful_hat.yaml b/data/items/graceful_hat.yaml
new file mode 100644
index 0000000..faec1e3
--- /dev/null
+++ b/data/items/graceful_hat.yaml
@@ -0,0 +1,6 @@
+id: graceful_hat
+name: graceful hat
+color: "230"
+description: "A lightweight hat. Part of the graceful set."
+value: 100
+equip_slot: head
diff --git a/data/items/graceful_legs.yaml b/data/items/graceful_legs.yaml
new file mode 100644
index 0000000..31eeebf
--- /dev/null
+++ b/data/items/graceful_legs.yaml
@@ -0,0 +1,6 @@
+id: graceful_legs
+name: graceful legs
+color: "230"
+description: "Lightweight leg armour. Part of the graceful set."
+value: 150
+equip_slot: legs
diff --git a/data/items/graceful_torso.yaml b/data/items/graceful_torso.yaml
new file mode 100644
index 0000000..ec1edb7
--- /dev/null
+++ b/data/items/graceful_torso.yaml
@@ -0,0 +1,6 @@
+id: graceful_torso
+name: graceful torso
+color: "230"
+description: "A lightweight top. Part of the graceful set."
+value: 150
+equip_slot: torso
diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml
index ba42be0..aaf6dda 100644
--- a/data/rooms/1.yaml
+++ b/data/rooms/1.yaml
@@ -11,3 +11,25 @@ objects:
- id: lumby_fountain
mobs:
- "newbie_trainer"
+spawns:
+ - item_id: cape_of_agility
+ quantity: 1
+ respawn_ticks: 60
+ - item_id: graceful_hat
+ quantity: 1
+ respawn_ticks: 60
+ - item_id: graceful_torso
+ quantity: 1
+ respawn_ticks: 60
+ - item_id: graceful_legs
+ quantity: 1
+ respawn_ticks: 60
+ - item_id: graceful_gloves
+ quantity: 1
+ respawn_ticks: 60
+ - item_id: graceful_boots
+ quantity: 1
+ respawn_ticks: 60
+ - item_id: graceful_cape
+ quantity: 1
+ respawn_ticks: 60
diff --git a/internal/combat/state.go b/internal/combat/state.go
index 43b693b..9c8d346 100644
--- a/internal/combat/state.go
+++ b/internal/combat/state.go
@@ -3,10 +3,9 @@ package combat
import "sync"
type State struct {
- PlayerName string
- MobID string
- Active bool
- LockedTicks int
+ PlayerName string
+ MobID string
+ Active bool
}
var (
@@ -19,10 +18,9 @@ func EnterCombat(playerName, mobID string) {
mu.Lock()
defer mu.Unlock()
combatants[playerName] = &State{
- PlayerName: playerName,
- MobID: mobID,
- Active: true,
- LockedTicks: 15,
+ PlayerName: playerName,
+ MobID: mobID,
+ Active: true,
}
mobTargets[mobID] = playerName
}
@@ -56,15 +54,3 @@ func GetMobTarget(mobID string) string {
defer mu.Unlock()
return mobTargets[mobID]
}
-
-func TickCombat() {
- mu.Lock()
- defer mu.Unlock()
- for _, state := range combatants {
- if state.LockedTicks > 0 {
- state.LockedTicks--
- }
- }
-}
-
-
diff --git a/internal/config/config.go b/internal/config/config.go
index d2065d2..af85c61 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -46,6 +46,8 @@ func DefaultColors() ColorsConfig {
"eat_food": "156",
"drop_message": "186",
"credits_pickup": "220",
+ "visual_tick": "33 dim",
+ "visual_first_tick": "196 bold",
}
}
diff --git a/internal/game/action.go b/internal/game/action.go
index 04e2b0a..c08295c 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -199,6 +199,7 @@ func (g *Game) CancelAction(p *player.Player) {
p.Action = nil
p.ActionState = nil
p.WalkSequence = nil
+ p.ClearMoveState()
}
func (g *Game) AdvanceActions() {
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index c5d086e..b28e170 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -45,7 +45,7 @@ func (a *ActionState) Description() string {
case ActionCombating:
return "fighting a " + a.TargetName
case ActionMoving:
- return "walking in from the " + a.Direction
+ return "heading " + a.Direction
case ActionUsing:
return "using a " + a.TargetName
case ActionTalking:
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 359e0ac..d7fb365 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -272,6 +272,12 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst
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))
+
+ if p.MoveTicks > 0 {
+ p.ClearMoveState()
+ p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
+ sess.WriteLine("Can't escape!")
+ }
} else {
attacker := mob.Name
if !mob.Unique {
@@ -288,6 +294,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if p.HP <= 0 {
sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!"))
+ p.ClearMoveState()
g.dropItemsOnDeath(p)
p.HP = p.MaxHP()
p.RoomID = 1
@@ -476,7 +483,7 @@ func (g *Game) respawnMob(instanceID string) {
if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mob_spawn") {
mobLvl := mobCombatLevel(inst)
levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s %s spawns in the area.", mobDisplayName(inst, false), levelStr)))
+ sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob_name", mobDisplayName(inst, false)), levelStr))
}
}
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index a140003..2a3e698 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -4,7 +4,9 @@ import (
"fmt"
"thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/engine"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
"thehouseoficarus/internal/player"
)
@@ -45,17 +47,74 @@ func (g *Game) doMove(sess *net.Session, dir string) {
return
}
- if cs := combat.GetCombat(p.Name); cs != nil && cs.LockedTicks > 0 {
- sess.WriteLine(fmt.Sprintf("You are locked in combat for another %d tick%s!", cs.LockedTicks, plural(cs.LockedTicks)))
+ inCombat := combat.GetCombat(p.Name) != nil
+
+ if !inCombat && p.Action != nil {
+ g.CancelAction(p)
+ }
+
+ ticks := g.moveTicks(p)
+ p.MoveDirection = string(exitDir)
+ p.MoveTarget = targetID
+
+ if ticks <= 0 {
+ g.completeMove(sess, p)
return
}
- g.stopCombat(p.Name)
+ p.MoveTicks = ticks
+ p.ActionState = &ActionState{Type: ActionMoving, Direction: string(exitDir)}
- if p.Action != nil {
- g.CancelAction(p)
+ if inCombat && p.OptionBool("run_countdown") {
+ if p.MoveTicks > 1 {
+ sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks))
+ } else {
+ sess.WriteLine("Running next tick!")
+ }
+ }
+}
+
+var gracefulItems = map[string]bool{
+ "graceful_hat": true,
+ "graceful_torso": true,
+ "graceful_legs": true,
+ "graceful_gloves": true,
+ "graceful_boots": true,
+ "graceful_cape": true,
+}
+
+func (g *Game) moveTicks(p *player.Player) int {
+ if id, ok := p.Equipment[object.SlotBack]; ok && id == "cape_of_agility" {
+ return 0
}
+ count := 0
+ for _, id := range p.Equipment {
+ if gracefulItems[id] {
+ count++
+ }
+ }
+
+ base := 4.0 - float64(count)*0.25
+ if count == 6 {
+ base -= 0.5
+ }
+
+ return engine.ToTicks(base) - 1
+}
+
+func (g *Game) completeMove(sess *net.Session, p *player.Player) {
+ exitDir := p.MoveDirection
+ targetID := p.MoveTarget
+
+ p.ClearMoveState()
+
+ if combat.GetCombat(p.Name) != nil {
+ g.stopCombat(p.Name)
+ }
+
+ p.Action = nil
+
oldRoom := p.RoomID
p.RoomID = targetID
g.AccountStore.SaveCharacter(p)
@@ -78,8 +137,8 @@ func (g *Game) doMove(sess *net.Session, dir string) {
}
}
- sess.WriteLine(fmt.Sprintf("\nYou walk %s.", g.colorize(sess, "direction", string(exitDir))))
- p.ActionState = &ActionState{Type: ActionMoving, Direction: string(exitDir)}
+ sess.WriteLine(fmt.Sprintf("\nYou walk %s.", g.colorize(sess, "direction", exitDir)))
+ p.ActionState = &ActionState{Type: ActionMoving, Direction: exitDir}
if p.OptionBool("description") {
g.doLook(sess)
} else {
diff --git a/internal/game/cmd_prompt.go b/internal/game/cmd_prompt.go
index 81c3409..6cc45f9 100644
--- a/internal/game/cmd_prompt.go
+++ b/internal/game/cmd_prompt.go
@@ -16,7 +16,7 @@ func (g *Game) doPrompt(sess *net.Session, input string) {
prompt = "> "
}
sess.WriteLine(fmt.Sprintf("\nPrompt: %s", prompt))
- sess.WriteLine("Use 'prompt <value>' to change. Use 'prompt reset' to restore default.")
+ sess.WriteLine("Use 'prompt <value>' to change. Use 'prompt none' for blank. Use 'prompt reset' to restore default.")
return
}
@@ -27,6 +27,13 @@ func (g *Game) doPrompt(sess *net.Session, input string) {
return
}
+ if input == "none" {
+ p.Prompt = " "
+ g.AccountStore.SaveCharacter(p)
+ sess.WriteLine("\nPrompt set to blank.")
+ return
+ }
+
p.Prompt = input
g.AccountStore.SaveCharacter(p)
sess.WriteLine(fmt.Sprintf("\nPrompt set to: %s", input))
diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go
index 61fdc29..8f1a413 100644
--- a/internal/game/cmd_remove.go
+++ b/internal/game/cmd_remove.go
@@ -13,6 +13,10 @@ func (g *Game) doRemove(sess *net.Session, input string) {
p := sess.Player.(*player.Player)
lower := strings.ToLower(strings.TrimSpace(input))
+ if lower == "all" {
+ g.doRemoveAll(sess)
+ return
+ }
if lower == "" {
sess.WriteLine("Remove what?")
return
@@ -74,3 +78,47 @@ func (g *Game) doRemove(sess *net.Session, input string) {
}
sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name)))
}
+
+func (g *Game) doRemoveAll(sess *net.Session) {
+ p := sess.Player.(*player.Player)
+
+ if len(p.Equipment) == 0 {
+ sess.WriteLine("You aren't wearing anything.")
+ return
+ }
+
+ if len(p.Equipment) > p.FreeSlots() {
+ sess.WriteLine("You don't have room to remove everything!")
+ return
+ }
+
+ g.CancelAction(p)
+
+ for _, slot := range EquipSlots {
+ itemID, ok := p.Equipment[slot]
+ if !ok {
+ continue
+ }
+
+ freeSlot := p.FirstFreeSlot()
+ delete(p.Equipment, slot)
+ invSlot := &player.InventorySlot{ItemID: itemID, Quantity: 1}
+ if q, ok := p.EquipQuality[itemID]; ok {
+ invSlot.Quality = q
+ if def, _ := g.ItemStore.Load(itemID); def != nil {
+ invSlot.MaxQuality = def.MaxQuality
+ }
+ delete(p.EquipQuality, itemID)
+ }
+ p.SetInvSlot(freeSlot, invSlot)
+
+ name := itemID
+ def, _ := g.ItemStore.Load(itemID)
+ if def != nil {
+ name = def.Name
+ }
+ sess.WriteLine(fmt.Sprintf("You remove %s.", g.itemColorize(sess, def, name)))
+ }
+
+ g.AccountStore.SaveCharacter(p)
+}
diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go
index 4ff6187..2104782 100644
--- a/internal/game/cmd_walk.go
+++ b/internal/game/cmd_walk.go
@@ -92,18 +92,21 @@ func (g *Game) startWalk(sess *net.Session, p *player.Player, dirs []string) {
}
func (g *Game) advanceWalk(sess *net.Session, p *player.Player) {
+ if p.MoveTicks > 0 {
+ return
+ }
if len(p.WalkSequence) == 0 {
return
}
dir := p.WalkSequence[0]
- oldRoom := p.RoomID
remaining := condensePath(p.WalkSequence[1:])
p.WalkSequence = p.WalkSequence[1:]
+ oldRoom := p.RoomID
g.doMove(sess, dir)
- if p.RoomID == oldRoom {
+ if p.MoveTicks == 0 && p.RoomID == oldRoom {
p.WalkSequence = nil
p.ActionState = nil
return
diff --git a/internal/game/color.go b/internal/game/color.go
index 4fd8000..c3b3d83 100644
--- a/internal/game/color.go
+++ b/internal/game/color.go
@@ -46,15 +46,15 @@ func levelColorSpec(myLevel, theirLevel int) color.ColorSpec {
diff := theirLevel - myLevel
switch {
case diff == 0:
- return color.Parse("7")
+ return color.Parse("231")
case diff > 0 && diff < 5:
- return color.Parse("11")
+ return color.Parse("208")
case diff >= 5:
- return color.Parse("9")
+ return color.Parse("196")
case diff < 0 && diff > -5:
- return color.Parse("3")
+ return color.Parse("190")
default:
- return color.Parse("2")
+ return color.Parse("34")
}
}
diff --git a/internal/game/game.go b/internal/game/game.go
index 9d07ead..5af35be 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -448,7 +448,9 @@ func (g *Game) ProcessQueuedCommands() {
case ActionGathering, ActionCombating, ActionUsing, ActionTalking,
ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionCooking:
default:
- p.ActionState = nil
+ if as.Type != ActionMoving || p.MoveTicks <= 0 {
+ p.ActionState = nil
+ }
}
}
@@ -483,11 +485,14 @@ func (g *Game) ProcessQueuedCommands() {
if !ok || p == nil {
continue
}
+ if p.MoveTicks > 0 {
+ p.ClearMoveState()
+ }
wasBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil
if len(parts) > 0 {
g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args)
}
- isBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil
+ isBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil || p.MoveTicks > 0
_, isResting := g.restTimers[p.Name]
if !isResting && (wasBusy || !isBusy) {
g.writePrompt(qc.Session)
@@ -501,7 +506,7 @@ func (g *Game) ProcessQueuedCommands() {
continue
}
g.advanceWalk(sess, p)
- if len(p.WalkSequence) == 0 {
+ if len(p.WalkSequence) == 0 && p.MoveTicks == 0 {
g.writePrompt(sess)
}
}
@@ -516,6 +521,27 @@ type pendingDepletion struct {
playerNames []string
}
-func (g *Game) TickCombat() {
- combat.TickCombat()
+func (g *Game) MoveTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil || p.MoveTicks <= 0 {
+ continue
+ }
+ p.MoveTicks--
+ if p.MoveTicks > 0 {
+ if combat.GetCombat(p.Name) != nil && p.OptionBool("run_countdown") {
+ if p.MoveTicks > 1 {
+ sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks))
+ } else {
+ sess.WriteLine("Running next tick!")
+ }
+ }
+ } else {
+ g.completeMove(sess, p)
+ g.writePrompt(sess)
+ }
+ }
}
diff --git a/internal/game/tick.go b/internal/game/tick.go
index ab82254..8c40c96 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -129,16 +129,18 @@ func (g *Game) WanderTick() {
}
if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") {
if m.level > 0 {
- sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s (level %d) leaves.", m.name, m.level)))
+ levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level))
+ sess.WriteLine(fmt.Sprintf("\n%s %s leaves.", g.colorize(sess, "mob_name", m.name), levelStr))
} else {
- sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s moves away.", m.name)))
+ sess.WriteLine(fmt.Sprintf("\n%s moves away.", g.colorize(sess, "mob_name", m.name)))
}
}
if p.RoomID == m.toRoom && p.OptionBool("mob_enter") {
if m.level > 0 {
- sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s (level %d) enters.", m.name, m.level)))
+ levelStr := g.levelColorize(sess, p.CombatLevel(), m.level, fmt.Sprintf("(level %d)", m.level))
+ sess.WriteLine(fmt.Sprintf("\n%s %s enters.", g.colorize(sess, "mob_name", m.name), levelStr))
} else {
- sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s drifts in.", m.name)))
+ sess.WriteLine(fmt.Sprintf("\n%s drifts in.", g.colorize(sess, "mob_name", m.name)))
}
}
}
@@ -213,3 +215,31 @@ func (g *Game) ConsumeTick() {
}
}
}
+
+func (g *Game) VisualTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil || !p.OptionBool("visual_ticks") {
+ continue
+ }
+ text := p.OptionString("visual_tick_text")
+ count := p.OptionInt("visual_tick_count")
+ if count <= 0 {
+ sess.WriteLine(g.colorize(sess, "visual_tick", text))
+ } else {
+ p.VisualTickCurrent++
+ if p.VisualTickCurrent > count {
+ p.VisualTickCurrent = 1
+ }
+ msg := fmt.Sprintf("%s %d", text, p.VisualTickCurrent)
+ if p.VisualTickCurrent == 1 {
+ sess.WriteLine(g.colorize(sess, "visual_first_tick", msg))
+ } else {
+ sess.WriteLine(g.colorize(sess, "visual_tick", msg))
+ }
+ }
+ }
+}
diff --git a/internal/object/item.go b/internal/object/item.go
index 6078712..efcb2c5 100644
--- a/internal/object/item.go
+++ b/internal/object/item.go
@@ -27,7 +27,7 @@ type WeaponType string
const (
WeaponMelee WeaponType = "melee"
WeaponRanged WeaponType = "ranged"
- WeaponTechnology WeaponType = "technology"
+ WeaponScience WeaponType = "science"
)
type ItemDef struct {
diff --git a/internal/player/player.go b/internal/player/player.go
index 1cd8677..48c9359 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -123,6 +123,10 @@ var OptionDefs = []OptionDef{
{"queue_silently", OptBool, true, nil, "Suppress messages for queued tick actions"},
{"room_desc_width", OptInt, 70, nil, "Maximum width for room descriptions"},
{"unicode", OptBool, true, nil, "Unicode box-drawing characters"},
+ {"run_countdown", OptBool, false, nil, "Show countdown messages when fleeing combat"},
+ {"visual_ticks", OptBool, false, nil, "Display a tick marker every game tick"},
+ {"visual_tick_count", OptInt, 0, nil, "Cycle length for tick counter (0 = no counter)"},
+ {"visual_tick_text", OptString, "Tick", nil, "Text displayed for visual ticks"},
}
var optionByName map[string]*OptionDef
@@ -153,10 +157,20 @@ type Player struct {
Options map[string]any `yaml:"-"`
Flags map[string]any `yaml:"flags"`
RegenerateTick int
- Action *action.Action `yaml:"-"`
- ActionState any `yaml:"-"`
- WalkSequence []string `yaml:"-"`
+ Action *action.Action `yaml:"-"`
+ ActionState any `yaml:"-"`
+ WalkSequence []string `yaml:"-"`
ConsumeCooldown int `yaml:"-"`
+ MoveTicks int `yaml:"-"`
+ MoveDirection string `yaml:"-"`
+ MoveTarget int `yaml:"-"`
+ VisualTickCurrent int `yaml:"-"`
+}
+
+func (p *Player) ClearMoveState() {
+ p.MoveTicks = 0
+ p.MoveDirection = ""
+ p.MoveTarget = 0
}
func (p *Player) OptionBool(name string) bool {
@@ -291,7 +305,7 @@ func (p *Player) AddSkillXP(s SkillName, amount int) int {
}
func (p *Player) CombatLevel() int {
- base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Science))
+ base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Technology))
att := float64(p.Level(Attack))
str := float64(p.Level(Strength))
@@ -301,7 +315,7 @@ func (p *Player) CombatLevel() int {
base += 0.25 * (att + str)
}
- base += 0.125 * float64(p.Level(Technology))
+ base += 0.125 * float64(p.Level(Science))
return int(base)
}