From a6f0ad79e2e3a5b7c11eef1ffc3233f3a2766f77 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sun, 14 Jun 2026 22:38:23 -0400 Subject: feat: fractional ticks and server game_speed implemented. potentially insanely janky. --- AGENTS.md | 39 +++++- TODO.md | 6 + cmd/mud/main.go | 10 +- data/drops/christmas_cracker_hat.yaml | 14 +++ data/drops/christmas_cracker_misc.yaml | 10 ++ data/help/actions.yaml | 35 ++++++ data/help/left-tinymap.yaml | 11 -- data/help/left_tinymap.yaml | 11 ++ data/help/queue_actions_silently.yaml | 15 +++ data/help/queued.yaml | 17 +++ data/help/search.yaml | 17 ++- data/help/ticks.yaml | 24 ++++ data/items/birds_nest.yaml | 3 + data/items/bread.yaml | 6 + data/items/christmas_cracker.yaml | 10 ++ data/items/party_hat_blue.yaml | 7 ++ data/items/party_hat_green.yaml | 7 ++ data/items/party_hat_pink.yaml | 7 ++ data/items/party_hat_red.yaml | 7 ++ data/items/party_hat_white.yaml | 7 ++ data/items/party_hat_yellow.yaml | 7 ++ internal/action/behavior.go | 8 +- internal/config/config.go | 6 +- internal/engine/tick.go | 25 +++- internal/game/action.go | 3 + internal/game/action_burn.go | 20 +-- internal/game/action_gather.go | 140 ++++++++++++++++----- internal/game/action_search.go | 122 +++++++++++++++++++ internal/game/action_state.go | 61 ++++++++++ internal/game/action_talk.go | 2 + internal/game/action_toggle.go | 2 + internal/game/action_use.go | 8 +- internal/game/cmd_attack.go | 14 ++- internal/game/cmd_drop.go | 3 + internal/game/cmd_get.go | 6 + internal/game/cmd_look.go | 40 +++--- internal/game/cmd_move.go | 1 + internal/game/cmd_queued.go | 66 ++++++++++ internal/game/cmd_quit.go | 3 +- internal/game/cmd_search.go | 68 ++--------- internal/game/doc.go | 5 +- internal/game/game.go | 215 +++++++++++++++++++++++++++++---- internal/game/map_test.go | 6 +- internal/game/tick.go | 34 +++--- internal/game/utils.go | 23 ---- internal/object/item.go | 10 +- internal/player/player.go | 5 +- internal/world/mob.go | 10 +- internal/world/room.go | 6 +- internal/world/world.go | 26 ++-- 50 files changed, 957 insertions(+), 251 deletions(-) create mode 100644 data/drops/christmas_cracker_hat.yaml create mode 100644 data/drops/christmas_cracker_misc.yaml create mode 100644 data/help/actions.yaml delete mode 100644 data/help/left-tinymap.yaml create mode 100644 data/help/left_tinymap.yaml create mode 100644 data/help/queue_actions_silently.yaml create mode 100644 data/help/queued.yaml create mode 100644 data/help/ticks.yaml create mode 100644 data/items/bread.yaml create mode 100644 data/items/christmas_cracker.yaml create mode 100644 data/items/party_hat_blue.yaml create mode 100644 data/items/party_hat_green.yaml create mode 100644 data/items/party_hat_pink.yaml create mode 100644 data/items/party_hat_red.yaml create mode 100644 data/items/party_hat_white.yaml create mode 100644 data/items/party_hat_yellow.yaml create mode 100644 internal/game/action_search.go create mode 100644 internal/game/action_state.go create mode 100644 internal/game/cmd_queued.go diff --git a/AGENTS.md b/AGENTS.md index 2897be8..0b829a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,13 @@ make vet # go vet ./... Binary accepts `--config ` (defaults to `config.yaml`). If no config found, starts telnet on :4000. +Config options: +```yaml +game: + tick_length: 600 # ms per game tick (default 600, min 50) + game_speed: 1.0 # multiplier for all tick timers (default 1.0) +``` + Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`. ## Package Map @@ -42,7 +49,8 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern + | `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) | -| `tick.go` | DisconnectTick, RegenTick, WanderTick, WoodcuttingTick | +| `action_state.go` | ActionState type, ActionType consts, Description for look display | +| `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) | | `utils.go` | Helper functions (plural, parseQty, parseChoiceIndex, formatPickupList, etc.) | @@ -62,6 +70,8 @@ internal/engine/ Tick scheduler — 600ms interval, subscriber pattern + **Tick-driven.** The world runs on a 600ms tick. Combat, gathering, regen, wandering, respawns, shared depletion, woodcutting timers all advance per tick. See `cmd/mud/main.go` for the subscription loop. +**Fractional ticks.** All YAML timer fields (tool_speed, speed, base_wait, deplete_timer, etc.) accept `float64` values. `engine.FractionalTicks(base, speed)` probabilistically rounds to an integer — a tool_speed of 4.5 means each action cycle has a 50% chance of 4 ticks and 50% of 5 ticks. The `game_speed` config option scales all timers uniformly via `g.computeTicks()`. + ## Two State Systems - **World flags** (`set_flags` / checked with `flag`): Shared by all players. A door opened by one player is open for everyone. @@ -148,7 +158,7 @@ Firemaking is implemented as standalone actions (not behavior-YAML-driven) in `i **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. -**Shared depletion** (higher-tier trees): Set `deplete_timer: ` 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. `WoodcuttingTick` manages this in `internal/game/tick.go`. +**Shared depletion** (higher-tier trees): Set `deplete_timer: ` 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`. ## Data Files @@ -168,6 +178,31 @@ Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YA See `WORLDBUILDING.md` for full YAML format reference with examples. +## Tick-Based Action Queue + +All gameplay commands are queued for the next 600ms game tick instead of executing immediately. Commands are classified into three types: + +| 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 | + +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 + +**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. + +**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. + +Queue feedback is controlled by the `queue_actions_silently` player option (default on). + ## Places to Be Careful - `StartAction` searches objects first, then mobs. A mob must have `behavior:` set on its YAML def to be interactable. diff --git a/TODO.md b/TODO.md index d9cba9b..1a91969 100644 --- a/TODO.md +++ b/TODO.md @@ -130,6 +130,11 @@ Aliases resolve before built-in commands and support argument passthrough (`alia - [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 @@ -147,6 +152,7 @@ Aliases resolve before built-in commands and support argument passthrough (`alia - [x] `pull / push ` — toggle interactions (levers, gates) - [x] `alias / unalias ` — 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 ` — search bird's nests for loot - [x] `burn [item]` — start a fire from logs on ground or in inventory (smart default) diff --git a/cmd/mud/main.go b/cmd/mud/main.go index 4f11721..cc8c96b 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -34,18 +34,22 @@ func main() { } g := game.New(dataDir) - g.MapWidth = cfg.Game.MaxWidth - g.Ticks.Start() + g.GameSpeed = cfg.Game.GameSpeed + if g.GameSpeed <= 0 { + g.GameSpeed = 1.0 + } + g.Ticks.Start(cfg.Game.TickLength) defer g.Ticks.Stop() g.Ticks.Subscribe(1, func() bool { + g.ProcessQueuedCommands() g.World.Tick() g.MobStore.Tick() combat.TickCombat() g.RegenTick() g.DisconnectTick() g.WanderTick() - g.WoodcuttingTick() + g.SharedDepletionTick() g.FireTick() g.AdvanceActions() g.BroadcastRespawns() diff --git a/data/drops/christmas_cracker_hat.yaml b/data/drops/christmas_cracker_hat.yaml new file mode 100644 index 0000000..bb7bde8 --- /dev/null +++ b/data/drops/christmas_cracker_hat.yaml @@ -0,0 +1,14 @@ +id: christmas_cracker_hat +drops: + - item_id: party_hat_blue + weight: 1 + - item_id: party_hat_pink + weight: 1 + - item_id: party_hat_yellow + weight: 1 + - item_id: party_hat_green + weight: 1 + - item_id: party_hat_red + weight: 1 + - item_id: party_hat_white + weight: 1 diff --git a/data/drops/christmas_cracker_misc.yaml b/data/drops/christmas_cracker_misc.yaml new file mode 100644 index 0000000..6ee6979 --- /dev/null +++ b/data/drops/christmas_cracker_misc.yaml @@ -0,0 +1,10 @@ +id: christmas_cracker_misc +drops: + - item_id: ashes + weight: 1 + - item_id: bread + weight: 1 + - item_id: bronze_pickaxe + weight: 1 + - item_id: "" + weight: 3 diff --git a/data/help/actions.yaml b/data/help/actions.yaml new file mode 100644 index 0000000..7a2cbd3 --- /dev/null +++ b/data/help/actions.yaml @@ -0,0 +1,35 @@ +name: "actions" +category: "General" +description: | + The action queue system resolves all gameplay commands on the 600ms game tick + instead of instantly. Commands fall into three classes: + + Instant actions — execute immediately, bypassing the tick queue entirely: + say, score, inventory, equipment, look, exits, help, map, option, + alias, unalias, description, queued + + Free actions — stackable, all execute before active actions: + wear, wield, remove, unwear, unwield, style + You can queue multiple free actions. They execute in the order you typed them. + + Active actions — only the most recent survives: + All other commands (move, attack, mine, chop, fish, cut, use, talk, + pull, push, burn, stoke, get, drop, search, quit) + If you queue two active actions, the newer one replaces the older one. + + Order of execution each tick: + 1. Free actions (per player, in order) + 2. Active actions (globally, sorted by real-time timestamp) + + This means free actions always take effect before active actions, even if + you typed the active one first. For example, "attack goblin" then "style + aggressive" in the same tick means style applies first, then combat starts. + + Conflict resolution: + - If two players attack the same mob on the same tick, the first to + queue the command gets the kill. + - If multiple players successfully harvest a shared-resource object on + the same tick, ALL players get their drops. + - If two players try to pick up the same item, the first queued gets it. + + See also: help queued diff --git a/data/help/left-tinymap.yaml b/data/help/left-tinymap.yaml deleted file mode 100644 index 689aa89..0000000 --- a/data/help/left-tinymap.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: "left-tinymap" -category: "Options" -description: | - Show the mini-map on the left side of room descriptions. - - Type: boolean (on/off) - Default: off - - When both tinymap and left-tinymap are enabled, the - mini-map appears on the left side of the room description - rather than the right. diff --git a/data/help/left_tinymap.yaml b/data/help/left_tinymap.yaml new file mode 100644 index 0000000..6810dd1 --- /dev/null +++ b/data/help/left_tinymap.yaml @@ -0,0 +1,11 @@ +name: "left_tinymap" +category: "Options" +description: | + Show the mini-map on the left side of room descriptions. + + Type: boolean (on/off) + Default: off + + When both tinymap and left_tinymap are enabled, the + mini-map appears on the left side of the room description + rather than the right. diff --git a/data/help/queue_actions_silently.yaml b/data/help/queue_actions_silently.yaml new file mode 100644 index 0000000..8101c5c --- /dev/null +++ b/data/help/queue_actions_silently.yaml @@ -0,0 +1,15 @@ +name: "queue_actions_silently" +category: "Options" +description: | + Controls whether queued game actions produce confirmation messages. + + When on (default): No message is shown when you queue an action. The action + simply executes on the next game tick without fanfare. + + When off: A brief "You prepare to X." message is shown for each queued + action, confirming that it has been added to the queue. + + Usage: option queue_actions_silently on + option queue_actions_silently off + + See also: help queued, help actions, help option diff --git a/data/help/queued.yaml b/data/help/queued.yaml new file mode 100644 index 0000000..6859b65 --- /dev/null +++ b/data/help/queued.yaml @@ -0,0 +1,17 @@ +name: "queued" +category: "General" +description: | + Shows your pending actions that will execute on the next game tick. + + Usage: queued + + All gameplay actions are resolved once per tick (600ms). Free actions + stack (you can queue several), while active actions replace each other. + Instant actions execute immediately. + + The queued command shows: + - Free actions you've stacked up (e.g. wear, remove, style) + - Your active action that will execute after all free actions + - Your position in the global active action queue (for conflict resolution) + + See also: help actions diff --git a/data/help/search.yaml b/data/help/search.yaml index 42eec52..96bd008 100644 --- a/data/help/search.yaml +++ b/data/help/search.yaml @@ -1,12 +1,19 @@ name: "search" category: "Items" description: | - Search an item in your inventory. + Search a searchable item in your inventory. Usage: search - Example: search nest, search birds nest + Example: search nest, search cracker - Opens a searchable item (currently only bird's nests) and rolls on its - drop table for loot. The item is consumed in the process. Items found - go to inventory or fall to the ground if full. + Searching takes a few game ticks to complete. On each tick, you will + see progress messages until the action finishes and you receive your + reward. If you are interrupted during a search (by combat, movement, + or another action), the item is NOT consumed. + + Currently searchable items: + bird's nest - Credits (various amounts) + christmas cracker - Random party hat + bonus item + + Items found go to inventory or fall to the ground if full. diff --git a/data/help/ticks.yaml b/data/help/ticks.yaml new file mode 100644 index 0000000..fd128c2 --- /dev/null +++ b/data/help/ticks.yaml @@ -0,0 +1,24 @@ +name: "ticks" +category: "General" +description: | + All timed game actions use a tick-based system. Each game tick runs at a + configurable interval (default 600ms). Actions like gathering, combat, and + crafting advance on each tick. + + Fractional ticks allow tool speeds and timers to be non-integer values. + For example, a tool with speed 4.5 has a 50% chance of taking 4 ticks + and a 50% chance of taking 5 ticks per action cycle. The average + converges to 4.5 over many actions. This applies to: + + - Tool speed (gathering, firemaking) + - Weapon speed (combat) + - Production wait timers (use behaviors) + - Search timers + - Depletion and respawn timers + + The game_speed config option scales ALL tick timers proportionally. + A game_speed of 2.0 makes everything resolve twice as fast; 0.5 makes + everything twice as slow. + + Use the "option" command to configure tick-related display settings. + Use "help option" for a list of all player options. diff --git a/data/items/birds_nest.yaml b/data/items/birds_nest.yaml index 674cdaa..844426f 100644 --- a/data/items/birds_nest.yaml +++ b/data/items/birds_nest.yaml @@ -4,3 +4,6 @@ aliases: ["nest", "birds", "bird"] description: "A bird's nest. You can search it for treasure." value: 5 stackable: false +search_table: birds_nest_drop +search_ticks: 3 +search_message: "digging through the bird's nest" diff --git a/data/items/bread.yaml b/data/items/bread.yaml new file mode 100644 index 0000000..f1c64f1 --- /dev/null +++ b/data/items/bread.yaml @@ -0,0 +1,6 @@ +id: bread +name: bread +aliases: ["loaf", "bread"] +description: "A fresh loaf of bread. Smells wonderful." +value: 2 +stackable: false diff --git a/data/items/christmas_cracker.yaml b/data/items/christmas_cracker.yaml new file mode 100644 index 0000000..42dbf26 --- /dev/null +++ b/data/items/christmas_cracker.yaml @@ -0,0 +1,10 @@ +id: christmas_cracker +name: christmas cracker +aliases: ["cracker", "christmas"] +description: "A festive party cracker. Pull it apart to see what's inside!" +value: 10 +stackable: true +search_table: christmas_cracker_hat +search_misc_table: christmas_cracker_misc +search_ticks: 3 +search_message: "digging through the christmas cracker" diff --git a/data/items/party_hat_blue.yaml b/data/items/party_hat_blue.yaml new file mode 100644 index 0000000..81ad988 --- /dev/null +++ b/data/items/party_hat_blue.yaml @@ -0,0 +1,7 @@ +id: party_hat_blue +name: blue party hat +aliases: ["party hat", "blue hat"] +description: "A festive blue party hat." +value: 2 +equip_slot: head +stackable: false diff --git a/data/items/party_hat_green.yaml b/data/items/party_hat_green.yaml new file mode 100644 index 0000000..907da90 --- /dev/null +++ b/data/items/party_hat_green.yaml @@ -0,0 +1,7 @@ +id: party_hat_green +name: green party hat +aliases: ["party hat", "green hat"] +description: "A festive green party hat." +value: 2 +equip_slot: head +stackable: false diff --git a/data/items/party_hat_pink.yaml b/data/items/party_hat_pink.yaml new file mode 100644 index 0000000..accd9a0 --- /dev/null +++ b/data/items/party_hat_pink.yaml @@ -0,0 +1,7 @@ +id: party_hat_pink +name: pink party hat +aliases: ["party hat", "pink hat"] +description: "A festive pink party hat." +value: 2 +equip_slot: head +stackable: false diff --git a/data/items/party_hat_red.yaml b/data/items/party_hat_red.yaml new file mode 100644 index 0000000..911d07b --- /dev/null +++ b/data/items/party_hat_red.yaml @@ -0,0 +1,7 @@ +id: party_hat_red +name: red party hat +aliases: ["party hat", "red hat"] +description: "A festive red party hat." +value: 2 +equip_slot: head +stackable: false diff --git a/data/items/party_hat_white.yaml b/data/items/party_hat_white.yaml new file mode 100644 index 0000000..e4bd555 --- /dev/null +++ b/data/items/party_hat_white.yaml @@ -0,0 +1,7 @@ +id: party_hat_white +name: white party hat +aliases: ["party hat", "white hat"] +description: "A festive white party hat." +value: 2 +equip_slot: head +stackable: false diff --git a/data/items/party_hat_yellow.yaml b/data/items/party_hat_yellow.yaml new file mode 100644 index 0000000..eabd3e8 --- /dev/null +++ b/data/items/party_hat_yellow.yaml @@ -0,0 +1,7 @@ +id: party_hat_yellow +name: yellow party hat +aliases: ["party hat", "yellow hat"] +description: "A festive yellow party hat." +value: 2 +equip_slot: head +stackable: false diff --git a/internal/action/behavior.go b/internal/action/behavior.go index e0b8cdf..c7ed0c4 100644 --- a/internal/action/behavior.go +++ b/internal/action/behavior.go @@ -4,7 +4,7 @@ type GatherConfig struct { Skill string `yaml:"skill"` Level int `yaml:"level"` XP int `yaml:"xp"` - BaseWait int `yaml:"base_wait"` + BaseWait float64 `yaml:"base_wait"` Tools []string `yaml:"tools"` Bait string `yaml:"bait"` Success SuccessFormula `yaml:"success"` @@ -13,10 +13,10 @@ type GatherConfig struct { ExhaustedMessage string `yaml:"exhausted_message"` FailMsg string `yaml:"fail_message"` Drops []DropEntry `yaml:"drops"` - RespawnTimer int `yaml:"respawn_timer"` + RespawnTimer float64 `yaml:"respawn_timer"` RespawnMsg string `yaml:"respawn_message"` RespawnBroadcast string `yaml:"respawn_broadcast"` - DepleteTimer int `yaml:"deplete_timer"` + DepleteTimer float64 `yaml:"deplete_timer"` NestChance int `yaml:"nest_chance"` } @@ -64,7 +64,7 @@ type Condition struct { type UseConfig struct { Message string `yaml:"message"` - Wait int `yaml:"wait"` + Wait float64 `yaml:"wait"` Consume map[string]int `yaml:"consume"` Reward DropEntry `yaml:"reward"` FailMsg string `yaml:"fail_message"` diff --git a/internal/config/config.go b/internal/config/config.go index 1682a01..d397fae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,8 @@ type Config struct { } type GameConfig struct { - MaxWidth int `yaml:"maxWidth"` + TickLength int `yaml:"tick_length"` + GameSpeed float64 `yaml:"game_speed"` } type TelnetConfig struct { @@ -37,7 +38,8 @@ type HTTPSConfig struct { func Default() *Config { return &Config{ Game: GameConfig{ - MaxWidth: 70, + TickLength: 600, + GameSpeed: 1.0, }, Telnet: TelnetConfig{ Enabled: true, diff --git a/internal/engine/tick.go b/internal/engine/tick.go index d6d470a..419867d 100644 --- a/internal/engine/tick.go +++ b/internal/engine/tick.go @@ -1,12 +1,11 @@ package engine import ( + "math/rand" "sync" "time" ) -const TickDuration = 600 * time.Millisecond - type Callback func() bool type subscriber struct { @@ -49,14 +48,17 @@ func (e *Engine) Unsubscribe(id uint64) { delete(e.subscribers, id) } -func (e *Engine) Start() { +func (e *Engine) Start(tickLengthMs int) { e.mu.Lock() defer e.mu.Unlock() if e.running { return } e.running = true - e.ticker = time.NewTicker(TickDuration) + if tickLengthMs < 50 { + tickLengthMs = 50 + } + e.ticker = time.NewTicker(time.Duration(tickLengthMs) * time.Millisecond) e.stopCh = make(chan struct{}) go func() { @@ -103,3 +105,18 @@ func (e *Engine) processTick() { } } } + +func FractionalTicks(base, speed float64) int { + if speed <= 0 { + speed = 1 + } + value := base / speed + if value < 1 { + value = 1 + } + floor := int(value) + if rand.Float64() < value-float64(floor) { + return floor + 1 + } + return floor +} diff --git a/internal/game/action.go b/internal/game/action.go index 7d2549f..ce29559 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -171,6 +171,7 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { func (g *Game) CancelAction(p *player.Player) { p.Action = nil + p.ActionState = nil } func (g *Game) AdvanceActions() { @@ -194,6 +195,8 @@ func (g *Game) AdvanceActions() { g.advanceBurn(sess, p) case "stoke": g.advanceStoke(sess, p) + case "search": + g.advanceSearch(sess, p) } } } diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index d57307a..917a6fe 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -102,6 +102,8 @@ func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fro phase = 0 } + p.ActionState = &ActionState{Type: ActionBurning} + p.Action = &action.Action{ Type: "burn", TargetID: itemID, @@ -138,11 +140,13 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) { sess.WriteLine("You begin to tend to the fire.") + p.ActionState = &ActionState{Type: ActionStoking} + p.Action = &action.Action{ Type: "stoke", TargetID: itemID, TargetName: logDef.Name, - WaitLeft: 10, + WaitLeft: g.computeTicks(10), Data: map[string]any{ "item_id": itemID, "fire_key": fireKey, @@ -155,7 +159,7 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) { func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { data := p.Action.Data itemID := data["item_id"].(string) - toolSpeed := data["tool_speed"].(int) + toolSpeed := data["tool_speed"].(float64) phase := data["phase"].(int) logDef, err := g.ItemStore.Load(itemID) @@ -178,7 +182,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { g.broadcastDrop(p, logDef.Name) data["phase"] = 1 - p.Action.WaitLeft = toolSpeed + p.Action.WaitLeft = g.computeTicks(toolSpeed) return } @@ -192,7 +196,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { if phase == 1 { sess.WriteLine(fmt.Sprintf("You try burning the %s on the ground...", logDef.Name)) data["phase"] = 2 - p.Action.WaitLeft = toolSpeed + p.Action.WaitLeft = g.computeTicks(toolSpeed) return } @@ -236,7 +240,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { sess.WriteLine("You can't seem to get a fire going.") data["phase"] = 1 - p.Action.WaitLeft = toolSpeed + p.Action.WaitLeft = g.computeTicks(toolSpeed) } } @@ -244,7 +248,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { data := p.Action.Data itemID := data["item_id"].(string) fireKey := data["fire_key"].(string) - burnTicks := data["burn_ticks"].(int) + burnTicks := data["burn_ticks"].(float64) xp := data["xp"].(int) st := g.World.GetObjStateByKey(fireKey) @@ -289,10 +293,10 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { return } - p.Action.WaitLeft = 10 + p.Action.WaitLeft = g.computeTicks(10.0) } -func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed int, ok bool) { +func (g *Game) findFireTool(sess *net.Session, p *player.Player) (toolSpeed float64, ok bool) { toolSpeed = -1 var toolItemID string diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 201a619..512be81 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -29,13 +29,13 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje if cfg.DepletedMessage != "" { msg := cfg.DepletedMessage if p.OptionBool("depletion") { - msg += fmt.Sprintf(" (%d ticks to respawn)", st.DepleteTimer) + msg += fmt.Sprintf(" (%d ticks to respawn)", int(st.DepleteTimer)) } sess.WriteLine(msg) } else if cfg.DepleteTimer > 0 { - sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, st.DepleteTimer)) + sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, int(st.DepleteTimer))) } else { - sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer)) + sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, int(st.DepleteTimer))) } return } @@ -43,7 +43,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje wait := cfg.BaseWait if len(cfg.Tools) > 0 { - toolSpeed := -1 + var toolSpeed float64 = -1 if itemID, ok := p.Equipment[object.SlotMainHand]; ok { def, err := g.ItemStore.Load(itemID) if err == nil { @@ -107,6 +107,39 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje return } + toolName := "" + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + if def, err := g.ItemStore.Load(itemID); err == nil { + for _, t := range cfg.Tools { + if def.ToolType == t { + toolName = def.Name + break + } + } + } + } + if toolName == "" { + for _, slot := range p.Inventory { + if slot == nil || slot.Quantity <= 0 { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil { + continue + } + for _, t := range cfg.Tools { + if def.ToolType == t { + toolName = def.Name + break + } + } + if toolName != "" { + break + } + } + } + p.ActionState = &ActionState{Type: ActionGathering, TargetName: obj.Name, ToolName: toolName} + data := map[string]any{ "behavior_id": obj.BehaviorID, "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), @@ -135,7 +168,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } step := p.Action.Data["step"].(int) - wait := p.Action.Data["effective_wait"].(int) + wait := p.Action.Data["effective_wait"].(float64) instanceKey := p.Action.Data["instance_key"].(string) _, shared := p.Action.Data["deplete_timer"] @@ -153,21 +186,21 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) p.Action.Data["step"] = 1 - p.Action.WaitLeft = wait + p.Action.WaitLeft = g.computeTicks(wait) return } if step == 2 { st := g.World.GetObjStateByKey(instanceKey) if st != nil && st.Depleted { - p.Action.WaitLeft = 3 + p.Action.WaitLeft = g.computeTicks(3) return } if cfg.RespawnMsg != "" { sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) } p.Action.Data["step"] = 0 - p.Action.WaitLeft = wait + p.Action.WaitLeft = g.computeTicks(wait) return } @@ -231,23 +264,30 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { g.checkBirdNest(sess, p, cfg, freeSlot) - if shared { - st := g.World.GetObjStateByKey(instanceKey) - if st != nil && st.SharedTimer <= 0 { - g.depleteSharedTree(sess, p, st, cfg, instanceKey) - return - } + if shared { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.SharedTimer <= 0 { + g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ + instanceKey: instanceKey, + behaviorID: behaviorID, + targetName: p.Action.TargetName, + playerNames: []string{p.Name}, + }) + p.Action.Data["step"] = 2 + p.Action.WaitLeft = g.computeTicks(1) + return } + } if !shared && drop.Depletes { - delay := cfg.RespawnTimer + delay := g.computeTicks(cfg.RespawnTimer) if delay <= 0 { delay = 10 } st := g.World.GetObjStateByKey(instanceKey) if st != nil { st.Depleted = true - st.DepleteTimer = delay + st.DepleteTimer = float64(delay) } for _, other := range g.Hub.PlayersInRoom(p.RoomID) { @@ -265,7 +305,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } p.Action.Data["step"] = 2 - p.Action.WaitLeft = 1 + p.Action.WaitLeft = g.computeTicks(1) return } @@ -275,7 +315,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { return } p.Action.Data["step"] = 0 - p.Action.WaitLeft = wait + p.Action.WaitLeft = g.computeTicks(wait) return } } @@ -287,7 +327,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { return } p.Action.Data["step"] = 0 - p.Action.WaitLeft = wait + p.Action.WaitLeft = g.computeTicks(wait) } func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry { @@ -303,34 +343,68 @@ func filterDropsByLevel(drops []action.DropEntry, level int) []action.DropEntry return eligible } -func (g *Game) depleteSharedTree(sess *net.Session, p *player.Player, st *world.ObjState, cfg *action.GatherConfig, instanceKey string) { +func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, targetName string, playerNames []string) { st.Depleted = true - st.DepleteTimer = cfg.RespawnTimer + st.DepleteTimer = float64(g.computeTicks(cfg.RespawnTimer)) st.SharedTimer = 0 msg := cfg.ExhaustedMessage if msg == "" { - msg = fmt.Sprintf("The %s falls to the ground!", p.Action.TargetName) + msg = fmt.Sprintf("The %s falls to the ground!", targetName) } - sess.WriteLine(fmt.Sprintf("\n%s", msg)) - - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other == sess { + for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { + op, ok := sess.Player.(*player.Player) + if !ok || op.Action == nil || op.Action.Type != "gather" { continue } - op, ok := other.Player.(*player.Player) - if !ok || op.Action == nil || op.Action.Type != "gather" { + if op.Action.Data["instance_key"] != g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) { + continue + } + isDepleter := false + for _, name := range playerNames { + if op.Name == name { + isDepleter = true + break + } + } + if isDepleter { + sess.WriteLine(fmt.Sprintf("\n%s", msg)) continue } - if op.Action.Data["instance_key"] == instanceKey { - other.WriteLine(fmt.Sprintf("\n%s", msg)) - g.CancelAction(op) + sess.WriteLine(fmt.Sprintf("\n%s", msg)) + g.CancelAction(op) + } +} + +func (g *Game) flushPendingDepletions() { + if len(g.pendingDepletions) == 0 { + return + } + + merged := make(map[string]*pendingDepletion) + for _, pd := range g.pendingDepletions { + if existing, ok := merged[pd.instanceKey]; ok { + existing.playerNames = append(existing.playerNames, pd.playerNames...) + } else { + copy := pd + merged[pd.instanceKey] = © + } + } + + for _, pd := range merged { + st := g.World.GetObjStateByKey(pd.instanceKey) + if st == nil || st.Depleted { + continue + } + cfg, err := g.BehaviorStore.LoadGather(pd.behaviorID) + if err != nil { + continue } + g.depleteSharedTree(st, cfg, pd.targetName, pd.playerNames) } - p.Action.Data["step"] = 2 - p.Action.WaitLeft = 1 + g.pendingDepletions = nil } func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.GatherConfig, currentDropSlot int) { diff --git a/internal/game/action_search.go b/internal/game/action_search.go new file mode 100644 index 0000000..07bae44 --- /dev/null +++ b/internal/game/action_search.go @@ -0,0 +1,122 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/action" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, slotIdx int) { + def, err := g.ItemStore.Load(itemID) + if err != nil || def.SearchTable == "" { + sess.WriteLine("You can't search that.") + return + } + + ticks := def.SearchTicks + if ticks <= 0 { + ticks = 3 + } + + p.Action = &action.Action{ + Type: "search", + TargetID: itemID, + TargetName: def.Name, + WaitLeft: g.computeTicks(ticks), + Data: map[string]any{ + "item_id": itemID, + "slot_idx": slotIdx, + "started": false, + }, + } + + p.ActionState = &ActionState{Type: ActionSearching, TargetName: def.Name} +} + +func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { + data := p.Action.Data + itemID := data["item_id"].(string) + slotIdx := data["slot_idx"].(int) + started := data["started"].(bool) + + def, err := g.ItemStore.Load(itemID) + if err != nil { + sess.WriteLine("Something went wrong.") + g.CancelAction(p) + return + } + + if !started { + msg := def.SearchMessage + if msg == "" { + msg = fmt.Sprintf("digging through the %s", def.Name) + } + sess.WriteLine(fmt.Sprintf("You begin %s.", msg)) + data["started"] = true + return + } + + slot := p.InvSlot(slotIdx) + if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 { + sess.WriteLine("The item is gone.") + g.CancelAction(p) + return + } + + if slot.Quantity > 1 { + slot.Quantity-- + } else { + p.SetInvSlot(slotIdx, nil) + } + + dt, err := g.BehaviorStore.LoadDropTable(def.SearchTable) + if err == nil && len(dt.Drops) > 0 { + drop := g.BehaviorStore.ResolveDrop(dt.Drops) + if drop != nil && drop.ItemID != "" { + g.giveSearchLoot(sess, p, drop) + } + } + + if def.SearchMiscTable != "" { + dt2, err := g.BehaviorStore.LoadDropTable(def.SearchMiscTable) + if err == nil && len(dt2.Drops) > 0 { + drop := g.BehaviorStore.ResolveDrop(dt2.Drops) + if drop != nil && drop.ItemID != "" { + g.giveSearchLoot(sess, p, drop) + } + } + } + + g.AccountStore.SaveCharacter(p) + g.CancelAction(p) +} + +func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) { + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + name := drop.ItemID + if def, err := g.ItemStore.Load(drop.ItemID); err == nil { + name = def.Name + } + + if drop.ItemID == "credits" { + p.Credits += qty + sess.WriteLine(fmt.Sprintf("You find %d credits.", qty)) + return + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) + sess.WriteLine(fmt.Sprintf("You find %s. It falls to the ground.", name)) + return + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + sess.WriteLine(fmt.Sprintf("You find %s.", name)) +} diff --git a/internal/game/action_state.go b/internal/game/action_state.go new file mode 100644 index 0000000..fba5319 --- /dev/null +++ b/internal/game/action_state.go @@ -0,0 +1,61 @@ +package game + +import "fmt" + +type ActionType string + +const ( + ActionIdle ActionType = "" + ActionGathering ActionType = "gathering" + ActionCombating ActionType = "combating" + ActionMoving ActionType = "moving" + ActionUsing ActionType = "using" + ActionTalking ActionType = "talking" + ActionToggling ActionType = "toggling" + ActionBurning ActionType = "burning" + ActionStoking ActionType = "stoking" + ActionPickingUp ActionType = "picking_up" + ActionDropping ActionType = "dropping" + ActionSearching ActionType = "searching" + ActionResting ActionType = "resting" +) + +type ActionState struct { + Type ActionType + TargetName string + ToolName string + Direction string +} + +func (a *ActionState) Description() string { + if a == nil || a.Type == ActionIdle { + return "" + } + switch a.Type { + case ActionGathering: + return "mining a " + a.TargetName + case ActionCombating: + return "fighting a " + a.TargetName + case ActionMoving: + return "walking in from the " + a.Direction + case ActionUsing: + return "using a " + a.TargetName + case ActionTalking: + return "talking to " + a.TargetName + case ActionToggling: + return fmt.Sprintf("pulling a %s", a.TargetName) + case ActionBurning: + return "trying to start a fire" + case ActionStoking: + return "tending to a fire" + case ActionPickingUp: + return "picking up " + a.TargetName + case ActionDropping: + return "dropping " + a.TargetName + case ActionSearching: + return "digging through a " + a.TargetName + case ActionResting: + return "resting" + } + return "" +} diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index 4980c5a..8aa95be 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -19,6 +19,7 @@ func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobI } if node, ok := cfg.Nodes["start"]; ok { + p.ActionState = &ActionState{Type: ActionTalking, TargetName: mob.Name} p.Action = &action.Action{ Type: "talk", TargetID: mob.DefID, @@ -39,6 +40,7 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object } if node, ok := cfg.Nodes["start"]; ok { + p.ActionState = &ActionState{Type: ActionTalking, TargetName: obj.Name} p.Action = &action.Action{ Type: "talk", TargetID: obj.ID, diff --git a/internal/game/action_toggle.go b/internal/game/action_toggle.go index 3818d0d..f20d03c 100644 --- a/internal/game/action_toggle.go +++ b/internal/game/action_toggle.go @@ -34,6 +34,8 @@ func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.Obje g.WorldFlags[flag] = val } + p.ActionState = &ActionState{Type: ActionToggling, TargetName: obj.Name} + sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) if g.Hub != nil { diff --git a/internal/game/action_use.go b/internal/game/action_use.go index c47492f..8987ee0 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -33,11 +33,13 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD return } + p.ActionState = &ActionState{Type: ActionUsing, TargetName: obj.Name} + p.Action = &action.Action{ Type: "use", TargetID: obj.ID, TargetName: obj.Name, - WaitLeft: 1, + WaitLeft: g.computeTicks(1), Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, } @@ -76,7 +78,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { if cfg.FailMsg != "" { sess.WriteLine(cfg.FailMsg) } - p.Action.WaitLeft = cfg.Wait + p.Action.WaitLeft = g.computeTicks(cfg.Wait) return } } @@ -117,5 +119,5 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { return } - p.Action.WaitLeft = cfg.Wait + p.Action.WaitLeft = g.computeTicks(cfg.Wait) } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 2f79c16..ef80189 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -131,8 +131,9 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" } sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr)) + p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} - g.Ticks.Subscribe(playerSpeed, func() bool { + g.Ticks.Subscribe(g.computeTicks(playerSpeed), func() bool { cs := combat.GetCombat(p.Name) if cs == nil || !cs.Active { return false @@ -150,7 +151,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn return true }) - g.Ticks.Subscribe(mob.Speed, func() bool { + g.Ticks.Subscribe(g.computeTicks(mob.Speed), func() bool { cs := combat.GetCombat(p.Name) if cs == nil || !cs.Active { return false @@ -277,6 +278,7 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { g.combatPadWidth = 0 combat.LeaveCombat(p.Name) + p.ActionState = nil if p.HP <= 0 { sess.WriteLine("\nOh dear, you are dead!") @@ -340,9 +342,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } } - respawnTicks := mob.RespawnTicks + respawnTicks := g.computeTicks(mob.RespawnTicks) if respawnTicks <= 0 { - respawnTicks = 30 + respawnTicks = g.computeTicks(30) } instanceID := mob.InstanceID g.Ticks.Subscribe(respawnTicks, func() bool { @@ -467,12 +469,12 @@ func (g *Game) respawnMob(instanceID string) { } } -func (g *Game) playerWeaponSpeed(p *player.Player) int { +func (g *Game) playerWeaponSpeed(p *player.Player) float64 { if itemID, ok := p.Equipment[object.SlotMainHand]; ok { def, err := g.ItemStore.Load(itemID) if err == nil && def.Speed > 0 { return def.Speed } } - return 5 + return 5.0 } diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 149b86b..dc506f0 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -11,6 +11,7 @@ import ( func (g *Game) doDropAll(sess *net.Session) { p := sess.Player.(*player.Player) g.CancelAction(p) + p.ActionState = &ActionState{Type: ActionDropping, TargetName: "inventory"} count := 0 for _, slot := range p.Inventory { @@ -52,6 +53,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) { if def != nil { name = def.Name } + p.ActionState = &ActionState{Type: ActionDropping, TargetName: name} if def != nil && def.Stackable { slot := p.InvSlot(matches[0].Slot) @@ -113,6 +115,7 @@ func (g *Game) doDrop(sess *net.Session, input string) { if def != nil { name = def.Name } + p.ActionState = &ActionState{Type: ActionDropping, TargetName: name} if qty == 0 { if def != nil && def.Stackable { diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 78a9621..7ec183f 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -39,10 +39,16 @@ func (g *Game) doGet(sess *net.Session, input string) { if itemID == "credits" { g.pickupCredits(sess, p, itemID) + p.ActionState = &ActionState{Type: ActionPickingUp, TargetName: "credits"} return } def, _ := g.ItemStore.Load(itemID) + name := itemID + if def != nil { + name = def.Name + } + p.ActionState = &ActionState{Type: ActionPickingUp, TargetName: name} available := 0 if gqty, ok := ground[itemID]; ok { diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index a840579..3b81f80 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -25,8 +25,12 @@ func (g *Game) doLook(sess *net.Session) { room.Name, ) - if p.OptionBool("tinymap") && g.MapWidth > 0 { - descLines := wrapText(room.Description, g.MapWidth) + mapWidth := p.OptionInt("max_room_description_width") + if mapWidth <= 0 { + mapWidth = 70 + } + if p.OptionBool("tinymap") && mapWidth > 0 { + descLines := wrapText(room.Description, mapWidth) mapLines := buildTinyMap(g, p.RoomID) if len(mapLines) == 7 { g.writeLookSideBySide(sess, p, descLines, mapLines) @@ -107,10 +111,10 @@ func (g *Game) doLook(sess *net.Session) { instances = append(instances, instInfo{ idx: i + 1, depleted: st.Depleted, - sharedMax: st.SharedMax, + sharedMax: int(st.SharedMax), sharedCur: st.SharedTimer, - respawnIn: st.DepleteTimer, - quality: st.Quality, + respawnIn: int(st.DepleteTimer), + quality: int(st.Quality), }) } multi := len(instances) > 1 @@ -302,8 +306,10 @@ func (g *Game) doLook(sess *net.Session) { } line += fmt.Sprintf(" (fighting %s)", name) } - } else if desc := playerActionDescription(op); desc != "" { - line += ", " + desc + } else if as, ok := op.ActionState.(*ActionState); ok && as != nil { + if desc := as.Description(); desc != "" { + line += ", " + desc + } } sess.WriteLine(line + ".") } @@ -380,15 +386,15 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { for _, ist := range instances { if ist.Depleted { if len(instances) > 1 { - sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, ist.DepleteTimer)) + sess.WriteLine(fmt.Sprintf(" %s %d: depleted, respawns in %d ticks.", def.Name, ist.Index+1, int(ist.DepleteTimer))) } else { - sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", ist.DepleteTimer)) + sess.WriteLine(fmt.Sprintf(" Depleted, respawns in %d ticks.", int(ist.DepleteTimer))) } - } else if ist.SharedMax > 0 && ist.SharedTimer < ist.SharedMax { + } else if ist.SharedMax > 0 && float64(ist.SharedTimer) < ist.SharedMax { if len(instances) > 1 { - sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%d.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax)) + sess.WriteLine(fmt.Sprintf(" %s %d: despawn timer %d/%.0f.", def.Name, ist.Index+1, ist.SharedTimer, ist.SharedMax)) } else { - sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%d ticks.", ist.SharedTimer, ist.SharedMax)) + sess.WriteLine(fmt.Sprintf(" Despawn timer: %d/%.0f ticks.", ist.SharedTimer, ist.SharedMax)) } } } @@ -396,7 +402,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { for _, ist := range instances { if ist.Quality > 0 { if qdesc, ok := def.Props["quality_description"].(string); ok && qdesc != "" { - qdesc = strings.ReplaceAll(qdesc, "{quality}", fmt.Sprintf("%d", ist.Quality)) + qdesc = strings.ReplaceAll(qdesc, "{quality}", fmt.Sprintf("%.0f", ist.Quality)) sess.WriteLine(fmt.Sprintf(" %s", qdesc)) } } @@ -568,7 +574,11 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine if len(mapLines) > total { total = len(mapLines) } - leftMap := p.OptionBool("left-tinymap") + leftMap := p.OptionBool("left_tinymap") + mapWidth := p.OptionInt("max_room_description_width") + if mapWidth <= 0 { + mapWidth = 70 + } for i := 0; i < total; i++ { desc := "" if i < len(descLines) { @@ -581,7 +591,7 @@ 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", g.MapWidth, desc, mapLine)) + sess.WriteLine(fmt.Sprintf("%-*s %s", mapWidth, desc, mapLine)) } } } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index 9ef7a00..0b6571e 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -79,6 +79,7 @@ func (g *Game) doMove(sess *net.Session, dir string) { } sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) + p.ActionState = &ActionState{Type: ActionMoving, Direction: string(exitDir)} if p.OptionBool("description") { g.doLook(sess) } else { diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go new file mode 100644 index 0000000..040ff31 --- /dev/null +++ b/internal/game/cmd_queued.go @@ -0,0 +1,66 @@ +package game + +import ( + "fmt" + "sort" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doQueued(sess *net.Session) { + p := sess.Player.(*player.Player) + + freeCmds := g.freeQueue[p.Name] + activeCmd := g.activeQueue[p.Name] + + if len(freeCmds) == 0 && activeCmd == nil { + sess.WriteLine("\nNo actions queued.") + return + } + + sess.WriteLine("") + + if len(freeCmds) > 0 { + sess.WriteLine(" Queued free actions (will execute in order):") + for i, qc := range freeCmds { + cmd := qc.Command + if qc.Args != "" { + cmd += " " + qc.Args + } + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, cmd)) + } + } + + if activeCmd != nil { + if len(freeCmds) > 0 { + sess.WriteLine("") + } + cmd := activeCmd.Command + if activeCmd.Args != "" { + cmd += " " + activeCmd.Args + } + sess.WriteLine(fmt.Sprintf(" Queued active action (executes after free actions):")) + sess.WriteLine(fmt.Sprintf(" %s", cmd)) + } + + sess.WriteLine("") + sess.WriteLine(fmt.Sprintf(" All queued actions take effect on the next game tick (600ms).")) + sess.WriteLine(fmt.Sprintf(" Free actions stack. Only the most recent active action survives.")) + + var names []string + for name := range g.activeQueue { + names = append(names, name) + } + sort.Strings(names) + pos := 0 + for i, name := range names { + if name == p.Name { + pos = i + 1 + break + } + } + if pos > 0 && len(names) > 1 { + sess.WriteLine(fmt.Sprintf("\n Your active action will resolve as queue position #%d of %d.", pos, len(names))) + } +} diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 51e1fee..90e7019 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -15,10 +15,11 @@ func (g *Game) doQuit(sess *net.Session) { } g.CancelAction(p) + p.ActionState = &ActionState{Type: ActionResting} g.cancelRest(p.Name) ticksLeft := 10 - id := g.Ticks.Subscribe(1, func() bool { + id := g.Ticks.Subscribe(g.computeTicks(1), func() bool { switch ticksLeft { case 10: sess.WriteLine("You sit down to rest...") diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go index 341685e..d9a5b1e 100644 --- a/internal/game/cmd_search.go +++ b/internal/game/cmd_search.go @@ -10,6 +10,7 @@ import ( func (g *Game) doSearch(sess *net.Session, input string) { p := sess.Player.(*player.Player) + g.CancelAction(p) lower := strings.ToLower(strings.TrimSpace(input)) if lower == "" { @@ -23,65 +24,16 @@ func (g *Game) doSearch(sess *net.Session, input string) { return } - itemID := matches[0].ID - slotIdx := matches[0].Slot - - if itemID != "birds_nest" { - sess.WriteLine(fmt.Sprintf("You can't search %s.", matches[0].Name)) - return - } - - slot := p.InvSlot(slotIdx) - if slot == nil || slot.ItemID != "birds_nest" { - return - } - - if slot.Quantity > 1 { - slot.Quantity-- - } else { - p.SetInvSlot(slotIdx, nil) - } - - dt, err := g.BehaviorStore.LoadDropTable("birds_nest_drop") - if err != nil || len(dt.Drops) == 0 { - sess.WriteLine("You search the bird's nest but find nothing.") - g.AccountStore.SaveCharacter(p) - return - } - - drop := g.BehaviorStore.ResolveDrop(dt.Drops) - if drop == nil { - sess.WriteLine("You search the bird's nest but find nothing.") - g.AccountStore.SaveCharacter(p) - return - } - - qty := drop.Quantity - if qty <= 0 { - qty = 1 - } - - if drop.ItemID == "credits" { - p.Credits += qty - sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %d credits.", qty)) - } else { - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, drop.ItemID, qty) - name := drop.ItemID - if def, err := g.ItemStore.Load(drop.ItemID); err == nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s. It falls to the ground.", name)) - } else { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) - name := drop.ItemID - if def, err := g.ItemStore.Load(drop.ItemID); err == nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("You search the bird's nest and find %s.", name)) + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) } + return } - g.AccountStore.SaveCharacter(p) + itemID := matches[0].ID + slotIdx := matches[0].Slot + g.startSearch(sess, p, itemID, slotIdx) } diff --git a/internal/game/doc.go b/internal/game/doc.go index 97814e9..e276994 100644 --- a/internal/game/doc.go +++ b/internal/game/doc.go @@ -15,8 +15,9 @@ // Actions that take time (gather, use, burn, stoke) tick via // AdvanceActions, called each 600ms tick. // -// Tick handlers: DisconnectTick, RegenTick, WanderTick, WoodcuttingTick, -// and FireTick run each game tick to advance world simulation. +// 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.) diff --git a/internal/game/game.go b/internal/game/game.go index cecde2a..37a1f3f 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -2,8 +2,10 @@ package game import ( "fmt" + "sort" "strings" "sync" + "time" "thirdcollapse/internal/action" "thirdcollapse/internal/engine" @@ -13,6 +15,21 @@ import ( "thirdcollapse/internal/world" ) +type CommandClass int + +const ( + ClassInstant CommandClass = iota + ClassFree + ClassActive +) + +type QueuedCommand struct { + Session *net.Session + Command string + Args string + Timestamp time.Time +} + type Game struct { World *world.World ObjectStore *object.ObjectStore @@ -28,22 +45,28 @@ type Game struct { charsMu sync.Mutex loggedInChars map[string]*net.Session combatPadWidth int - MapWidth int + GameSpeed float64 + freeQueue map[string][]QueuedCommand + activeQueue map[string]*QueuedCommand + pendingDepletions []pendingDepletion } func New(dataDir string) *Game { return &Game{ - World: world.New(dataDir), - ObjectStore: object.NewObjectStore(dataDir), - ItemStore: object.NewItemStore(dataDir), - AccountStore: player.NewAccountStore(dataDir), - MobStore: world.NewMobStore(dataDir), - BehaviorStore: action.NewStore(dataDir), - Ticks: engine.New(), - WorldFlags: make(map[string]any), - dataDir: dataDir, - restTimers: make(map[string]uint64), - loggedInChars: make(map[string]*net.Session), + World: world.New(dataDir), + ObjectStore: object.NewObjectStore(dataDir), + ItemStore: object.NewItemStore(dataDir), + AccountStore: player.NewAccountStore(dataDir), + MobStore: world.NewMobStore(dataDir), + BehaviorStore: action.NewStore(dataDir), + Ticks: engine.New(), + WorldFlags: make(map[string]any), + dataDir: dataDir, + restTimers: make(map[string]uint64), + loggedInChars: make(map[string]*net.Session), + freeQueue: make(map[string][]QueuedCommand), + activeQueue: make(map[string]*QueuedCommand), + pendingDepletions: nil, } } @@ -94,17 +117,26 @@ func (g *Game) HandleSession(sess *net.Session, input string) { } } +func classifyCommand(cmd string) CommandClass { + switch cmd { + case "say", "score", "sc", "inventory", "i", "inv", + "equipment", "eq", "look", "l", "exits", "help", + "map", "option", "options", "alias", "unalias", + "description", "desc", "queued": + return ClassInstant + case "wear", "wield", "remove", "unwear", "unwield", "style": + return ClassFree + default: + return ClassActive + } +} + func (g *Game) handleGameCommand(sess *net.Session, input string) { if input == "" { sess.Write("> ") return } - p, _ := sess.Player.(*player.Player) - if p != nil { - g.cancelRest(p.Name) - } - if sess.Account != nil && sess.Account.Aliases != nil { firstSpace := strings.Index(input, " ") var firstWord, rest string @@ -125,7 +157,48 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { parts := strings.Fields(strings.ToLower(input)) cmd := parts[0] - args := parts[1:] + class := classifyCommand(cmd) + + if class == ClassInstant { + g.executeCommand(sess, cmd, parts[1:], input) + sess.Write("\r\n> ") + return + } + + p, _ := sess.Player.(*player.Player) + if p == nil { + return + } + + if class == ClassFree { + g.freeQueue[p.Name] = append(g.freeQueue[p.Name], QueuedCommand{ + Session: sess, + Command: cmd, + Args: strings.Join(parts[1:], " "), + Timestamp: time.Now(), + }) + if !p.OptionBool("queue_actions_silently") { + sess.WriteLine(fmt.Sprintf("\nYou prepare to %s.", cmd)) + } + sess.Write("\r\n> ") + return + } + + g.activeQueue[p.Name] = &QueuedCommand{ + Session: sess, + Command: cmd, + Args: strings.Join(parts[1:], " "), + Timestamp: time.Now(), + } + g.cancelRest(p.Name) + if !p.OptionBool("queue_actions_silently") { + sess.WriteLine(fmt.Sprintf("\nYou prepare to %s.", cmd)) + } + sess.Write("\r\n> ") +} + +func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { + p, _ := sess.Player.(*player.Player) switch cmd { case "get", "take", "grab", "pick": @@ -153,6 +226,9 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { g.doDrop(sess, strings.Join(args, " ")) } case "attack", "kill": + if p == nil { + return + } if len(args) == 0 { target := g.resolveDefaultMob(p.RoomID) if target == "" { @@ -183,9 +259,9 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { if len(args) == 0 { sess.WriteLine("Say what?") } else { - msgStart := strings.Index(strings.ToLower(input), "say ") + 4 - if msgStart >= 4 && msgStart < len(input) { - g.doSay(sess, input[msgStart:]) + msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 + if msgStart >= 4 && msgStart < len(rawInput) { + g.doSay(sess, rawInput[msgStart:]) } else { g.doSay(sess, strings.Join(args, " ")) } @@ -207,7 +283,8 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { g.doExits(sess) case "map": g.doMap(sess) - + case "queued": + g.doQueued(sess) case "help": if len(args) == 0 { g.doHelp(sess, "") @@ -215,6 +292,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { g.doHelp(sess, strings.Join(args, " ")) } case "mine", "chop", "fish", "cut", "use", "pull", "push": + if p == nil { + return + } + g.CancelAction(p) if len(args) == 0 { target := g.resolveDefaultTarget(p.RoomID, cmd) if target == "" { @@ -228,6 +309,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return } case "talk", "speak", "ask": + if p == nil { + return + } + g.CancelAction(p) if len(args) == 0 { sess.WriteLine("Talk to whom?") } else { @@ -235,9 +320,17 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return } case "burn": + if p == nil { + return + } + g.CancelAction(p) g.doBurn(sess, p, strings.Join(args, " ")) return case "stoke": + if p == nil { + return + } + g.CancelAction(p) g.doStoke(sess, p, strings.Join(args, " ")) return case "alias": @@ -247,6 +340,10 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { g.doUnalias(sess, args) return case "search": + if p == nil { + return + } + g.CancelAction(p) if len(args) == 0 { sess.WriteLine("Search what?") } else { @@ -254,14 +351,86 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } return case "wear", "wield": + if p == nil { + return + } g.doWear(sess, strings.Join(args, " ")) return case "remove", "unwear", "unwield": + if p == nil { + return + } g.doRemove(sess, strings.Join(args, " ")) return default: sess.WriteLine("Unknown command.") } +} - sess.Write("\r\n> ") +func (g *Game) ProcessQueuedCommands() { + if g.Hub == nil { + return + } + + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil || p.ActionState == nil { + continue + } + as, ok := p.ActionState.(*ActionState) + if !ok { + continue + } + switch as.Type { + case ActionGathering, ActionCombating, ActionUsing, ActionTalking, + ActionToggling, ActionBurning, ActionStoking, ActionResting: + default: + p.ActionState = nil + } + } + + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil { + continue + } + cmds := g.freeQueue[p.Name] + for _, qc := range cmds { + g.cancelRest(p.Name) + parts := strings.Fields(qc.Command + " " + qc.Args) + if len(parts) > 0 { + g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) + } + } + delete(g.freeQueue, p.Name) + } + + var actives []QueuedCommand + for _, qc := range g.activeQueue { + actives = append(actives, *qc) + } + sort.Slice(actives, func(i, j int) bool { + return actives[i].Timestamp.Before(actives[j].Timestamp) + }) + + for _, qc := range actives { + parts := strings.Fields(qc.Command + " " + qc.Args) + if len(parts) > 0 { + g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) + } + } + g.activeQueue = make(map[string]*QueuedCommand) + + g.flushPendingDepletions() +} + +type pendingDepletion struct { + instanceKey string + behaviorID string + targetName string + playerNames []string +} + +func (g *Game) computeTicks(base float64) int { + return engine.FractionalTicks(base, g.GameSpeed) } diff --git a/internal/game/map_test.go b/internal/game/map_test.go index adba595..3453117 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -9,8 +9,7 @@ import ( func TestBuildTinyMap(t *testing.T) { g := &Game{ - World: world.New("../../data"), - MapWidth: 70, + World: world.New("../../data"), } tests := []struct { @@ -95,8 +94,7 @@ func TestWrapText(t *testing.T) { func TestBuildFullMap(t *testing.T) { g := &Game{ - World: world.New("../../data"), - MapWidth: 70, + World: world.New("../../data"), } tests := []struct { diff --git a/internal/game/tick.go b/internal/game/tick.go index 3c3f1d3..4d037ec 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -82,7 +82,7 @@ func (g *Game) WanderTick() { continue } inst.WanderTickCounter++ - if inst.WanderTickCounter < inst.WanderInterval { + if float64(inst.WanderTickCounter) < inst.WanderInterval { continue } inst.WanderTickCounter = 0 @@ -144,40 +144,36 @@ func (g *Game) WanderTick() { } } -func (g *Game) WoodcuttingTick() { +func (g *Game) SharedDepletionTick() { + activeKeys := make(map[string]bool) + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p == nil || p.Action == nil || p.Action.Type != "gather" { + continue + } + if key, ok := p.Action.Data["instance_key"].(string); ok { + activeKeys[key] = true + } + } + all := g.World.AllSharedObjStates() for _, st := range all { if st.Depleted { continue } key := g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) - choppers := g.countChoppers(st.RoomID, key) - if choppers > 0 { + if activeKeys[key] { if st.SharedTimer > 0 { st.SharedTimer-- } } else { - if st.SharedTimer < st.SharedMax { + if st.SharedTimer < int(st.SharedMax) { st.SharedTimer++ } } } } -func (g *Game) countChoppers(roomID int, instanceKey string) int { - count := 0 - for _, sess := range g.Hub.PlayersInRoom(roomID) { - p, ok := sess.Player.(*player.Player) - if !ok || p.Action == nil || p.Action.Type != "gather" { - continue - } - if key, ok := p.Action.Data["instance_key"].(string); ok && key == instanceKey { - count++ - } - } - return count -} - func (g *Game) legalMobExits(inst *world.MobInstance) []int { room, err := g.World.LoadRoom(inst.RoomID) if err != nil || len(room.Exits) == 0 { diff --git a/internal/game/utils.go b/internal/game/utils.go index e80de24..729ea97 100644 --- a/internal/game/utils.go +++ b/internal/game/utils.go @@ -79,29 +79,6 @@ func formatPickupList(sess *net.Session, picked []string) { } } -func playerActionDescription(p *player.Player) string { - if p.Action == nil { - return "" - } - target := p.Action.TargetName - if idx, ok := p.Action.Data["instance_idx"]; ok { - target += fmt.Sprintf(" [%d]", idx) - } - switch p.Action.Type { - case "gather": - return "mining a " + target - case "talk": - return "talking to " + target - case "use": - return "using a " + target - case "burn": - return "trying to start a fire" - case "stoke": - return "tending to a fire" - } - return "" -} - func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { slot := &player.InventorySlot{ItemID: itemID, Quantity: qty} if def, err := g.ItemStore.Load(itemID); err == nil && def.MaxQuality > 0 { diff --git a/internal/object/item.go b/internal/object/item.go index 18fd034..f4e2f8b 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -40,14 +40,18 @@ type ItemDef struct { EquipSlot EquipSlot `yaml:"equip_slot"` WeaponType WeaponType `yaml:"weapon_type"` Stats ItemStats `yaml:"stats"` - Speed int `yaml:"speed"` + Speed float64 `yaml:"speed"` ToolType string `yaml:"tool_type"` - ToolSpeed int `yaml:"tool_speed"` - BurnTicks int `yaml:"burn_ticks"` + ToolSpeed float64 `yaml:"tool_speed"` + BurnTicks float64 `yaml:"burn_ticks"` FireLevel int `yaml:"fire_level"` FireXP int `yaml:"fire_xp"` Quality int `yaml:"quality"` MaxQuality int `yaml:"max_quality"` + SearchTable string `yaml:"search_table"` + SearchMiscTable string `yaml:"search_misc_table"` + SearchTicks float64 `yaml:"search_ticks"` + SearchMessage string `yaml:"search_message"` } type ItemStats struct { diff --git a/internal/player/player.go b/internal/player/player.go index 332656c..15521a3 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -107,7 +107,7 @@ type OptionDef struct { var OptionDefs = []OptionDef{ {"description", OptBool, true, nil, "Long room descriptions when moving"}, {"tinymap", OptBool, true, nil, "Mini-map display"}, - {"left-tinymap", OptBool, false, nil, "Mini-map on left side of descriptions"}, + {"left_tinymap", OptBool, false, nil, "Mini-map on left side of descriptions"}, {"xpdrops", OptBool, true, nil, "XP drop messages"}, {"exits", OptBool, true, nil, "Long exit display in look"}, {"mobenter", OptBool, true, nil, "Messages when mobs enter the room"}, @@ -121,6 +121,8 @@ var OptionDefs = []OptionDef{ {"mapheight", OptInt, 20, nil, "Map height for the map command"}, {"mappadding", OptString, "none", []string{"none", "x", "y", "xy"}, "Map output padding mode"}, {"automap", OptBool, false, nil, "Show map automatically after moving"}, + {"queue_actions_silently", OptBool, true, nil, "Suppress 'You prepare to...' messages for queued actions"}, + {"max_room_description_width", OptInt, 70, nil, "Maximum width for room descriptions"}, } var optionByName map[string]*OptionDef @@ -151,6 +153,7 @@ type Player struct { Flags map[string]any `yaml:"flags"` RegenerateTick int Action *action.Action `yaml:"-"` + ActionState any `yaml:"-"` } func (p *Player) OptionBool(name string) bool { diff --git a/internal/world/mob.go b/internal/world/mob.go index 59c8a20..c161e10 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -28,11 +28,11 @@ type MobDef struct { Strength int `yaml:"strength"` Defense int `yaml:"defense"` HP int `yaml:"hp"` - Speed int `yaml:"speed"` + Speed float64 `yaml:"speed"` Aggressive bool `yaml:"aggressive"` Protected bool `yaml:"protected"` Unique bool `yaml:"unique"` - RespawnTicks int `yaml:"respawn_ticks"` + RespawnTicks float64 `yaml:"respawn_ticks"` Drops DropTable `yaml:"drops"` } @@ -46,17 +46,17 @@ type MobInstance struct { Attack int Strength int Defense int - Speed int + Speed float64 Aggressive bool Protected bool Unique bool - RespawnTicks int + RespawnTicks float64 RoomID int HomeRoomID int Drops DropTable IdleDescription string WanderRooms []int - WanderInterval int + WanderInterval float64 WanderTickCounter int regenerateTick int } diff --git a/internal/world/room.go b/internal/world/room.go index 46fb876..16fd3fd 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -43,7 +43,7 @@ var ExitOrder = []ExitDir{ type SpawnDef struct { ItemID string `yaml:"item_id"` Quantity int `yaml:"quantity"` - RespawnTicks int `yaml:"respawn_ticks"` + RespawnTicks float64 `yaml:"respawn_ticks"` } type ExitDef struct { @@ -80,7 +80,7 @@ type Room struct { type RoomMob struct { ID string `yaml:"id"` WanderRooms []int `yaml:"wander_rooms"` - WanderInterval int `yaml:"wander_interval"` + WanderInterval float64 `yaml:"wander_interval"` } func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error { @@ -104,5 +104,5 @@ type EnterStep struct { type RoomObject struct { ID string `yaml:"id"` WanderRooms []int `yaml:"wander_rooms"` - WanderInterval int `yaml:"wander_interval"` + WanderInterval float64 `yaml:"wander_interval"` } diff --git a/internal/world/world.go b/internal/world/world.go index ac5345e..a554432 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -54,18 +54,18 @@ type ObjMove struct { type ObjState struct { Depleted bool - DepleteTimer int + DepleteTimer float64 DefID string Name string Index int RoomID int JustRespawned bool WanderRooms []int - WanderInterval int + WanderInterval float64 WanderCounter int - SharedMax int + SharedMax float64 SharedTimer int - Quality int + Quality float64 } func (w *World) ObjStateKey(roomID int, defID string, index int) string { @@ -102,7 +102,7 @@ func (w *World) ensureObjectStatesLocked(roomID int, defIDs []string) { } } -func (w *World) AddObjInstance(roomID int, defID string, quality int) { +func (w *World) AddObjInstance(roomID int, defID string, quality float64) { w.mu.Lock() defer w.mu.Unlock() if w.objStates == nil { @@ -250,7 +250,7 @@ func (w *World) SetObjName(roomID int, defID string, name string) { } } -func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int) { +func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { @@ -261,13 +261,13 @@ func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int } } -func (w *World) SetObjDepleteTimer(roomID int, defID string, max int) { +func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 { st.SharedMax = max - st.SharedTimer = max + st.SharedTimer = int(max) } } } @@ -284,7 +284,7 @@ func (w *World) AllSharedObjStates() []*ObjState { return out } -func (w *World) SetObjDepleted(roomID int, defID string, index int, delay int) { +func (w *World) SetObjDepleted(roomID int, defID string, index int, delay float64) { w.mu.Lock() defer w.mu.Unlock() key := w.objStateKey(roomID, defID, index) @@ -486,7 +486,7 @@ func (w *World) SeedGroundItems(roomID int) { e.quantity += s.Quantity e.respawnQty += s.Quantity e.isSpawn = true - e.respawnDelay = s.RespawnTicks + e.respawnDelay = int(s.RespawnTicks) merged = true break } @@ -496,7 +496,7 @@ func (w *World) SeedGroundItems(roomID int) { itemID: s.ItemID, quantity: s.Quantity, isSpawn: true, - respawnDelay: s.RespawnTicks, + respawnDelay: int(s.RespawnTicks), respawnQty: s.Quantity, } w.groundItems[roomID] = append(w.groundItems[roomID], e) @@ -545,7 +545,7 @@ func (w *World) Tick() { st.Depleted = false st.JustRespawned = true if st.SharedMax > 0 { - st.SharedTimer = st.SharedMax + st.SharedTimer = int(st.SharedMax) } } } @@ -577,7 +577,7 @@ func (w *World) TickObjWander() { continue } st.WanderCounter++ - if st.WanderCounter >= st.WanderInterval { + if float64(st.WanderCounter) >= st.WanderInterval { st.WanderCounter = 0 toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))] if toRoom == st.RoomID { -- cgit v1.2.3