aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md577
-rw-r--r--building_guide/behaviors.md85
-rw-r--r--building_guide/conditions.md5
-rw-r--r--building_guide/mobs.md32
-rw-r--r--data/mobs/flight_attendant.yaml37
-rw-r--r--data/objects/unique/1001_door.yaml3
-rw-r--r--data/objects/unique/1001_instruments.yaml4
-rw-r--r--data/objects/unique/1001_pilot.yaml3
-rw-r--r--data/objects/unique/1001_window.yaml3
-rw-r--r--data/objects/unique/1002_building.yaml3
-rw-r--r--data/objects/unique/1002_crowd.yaml14
-rw-r--r--data/objects/unique/1002_pilot.yaml6
-rw-r--r--data/objects/unique/1002_shuttle.yaml4
-rw-r--r--data/objects/unique/1002_sign.yaml2
-rw-r--r--data/rooms/intro/1001.yaml4
-rw-r--r--data/rooms/intro/1002.yaml14
-rw-r--r--internal/behavior/behavior.go10
-rw-r--r--internal/behavior/types.go6
-rw-r--r--internal/game/act.go2
-rw-r--r--internal/game/act_agility.go2
-rw-r--r--internal/game/act_burn.go6
-rw-r--r--internal/game/act_farm.go38
-rw-r--r--internal/game/act_gather.go8
-rw-r--r--internal/game/act_room.go14
-rw-r--r--internal/game/act_search.go2
-rw-r--r--internal/game/act_steal.go2
-rw-r--r--internal/game/act_talk.go228
-rw-r--r--internal/game/act_use.go2
-rw-r--r--internal/game/cmd_attack.go2
-rw-r--r--internal/game/cmd_bank.go4
-rw-r--r--internal/game/cmd_color.go2
-rw-r--r--internal/game/cmd_consume.go2
-rw-r--r--internal/game/cmd_estate_directory.go2
-rw-r--r--internal/game/cmd_farm.go2
-rw-r--r--internal/game/cmd_global.go2
-rw-r--r--internal/game/cmd_inventory.go1
-rw-r--r--internal/game/cmd_jack.go2
-rw-r--r--internal/game/cmd_move.go4
-rw-r--r--internal/game/cmd_option.go2
-rw-r--r--internal/game/cmd_queued.go2
-rw-r--r--internal/game/cmd_quit.go2
-rw-r--r--internal/game/cmd_say.go2
-rw-r--r--internal/game/cmd_shop.go10
-rw-r--r--internal/game/cmd_tech.go2
-rw-r--r--internal/game/cmd_trigger_transport.go2
-rw-r--r--internal/game/cmd_use.go2
-rw-r--r--internal/game/cmd_who.go2
-rw-r--r--internal/game/combat_attack.go12
-rw-r--r--internal/game/combat_mob.go16
-rw-r--r--internal/game/core_login_char.go2
-rw-r--r--internal/game/game.go6
-rw-r--r--internal/game/look_entities.go2
-rw-r--r--internal/game/render_help.go2
-rw-r--r--internal/game/sys_assassin.go2
-rw-r--r--internal/game/sys_buff.go2
-rw-r--r--internal/game/sys_combat.go2
-rw-r--r--internal/game/sys_hazard.go4
-rw-r--r--internal/game/tick.go12
-rw-r--r--internal/game/triggers.go25
-rw-r--r--internal/net/server.go37
-rw-r--r--internal/validate/checks.go6
61 files changed, 551 insertions, 744 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 6a5053d..6fdb497 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,588 +1,155 @@
# AGENTS.md
-The House of Icarus — a sci-fi MUD inspired by classic retro MMOs, written in Go. Data-driven via YAML files. No database, no ORM.
-
-Set on a terraformed asteroid where technology has regressed. "Technology" replaces Prayer, "Science" replaces Magic, Scavenging replaces Runecrafting.
+The House of Icarus — a sci-fi MUD in Go. YAML-driven, no database. "Technology" replaces Prayer, "Science" replaces Magic, Scavenging replaces Runecrafting.
## Build & Run
-```bash
+```
make build # go build -o thoi ./cmd/mud
make run # build + run with DATA_DIR=./data
make test # DATA_DIR=$(PWD)/data go test ./... -timeout 60s
make vet # go vet ./...
```
-Binary accepts `--config <path>` (defaults to `config.yaml`). If no config found it creates one with defaults (telnet :4000, http :8888, tick 600ms).
-
-Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`.
-
-## Package Map
-
-```
-cmd/mud/main.go Entry point — config loading, multi-listener, tick subscriber registration
-internal/
- behavior/ YAML behavior definitions (GatherConfig, TalkConfig, UseConfig, Condition)
- combat/ Tracker struct + classic formulas (HitChance, MaxHit, AttackRoll)
- config/ YAML config (telnet, telnet_tls, http, https listeners, color targets)
- color/ xterm-256 color system with auto ANSI downgrade, gradients, inline tags
- engine/ Tick scheduler — configurable ms interval, subscriber pattern, ToTicks()
- game/ Game orchestrator (embedded Deps), state machine, command dispatch
- hacking/ Minigame interface + 3 implementations (Wumpus, Mastermind, Liar's Dice)
- item/ ItemDef, ItemStore, EquipSlot, WeaponType, ItemStats (was part of object/)
- net/ Conn interface (tcpConn, wsConn), Hub, Session, session states, IAC echo
- object/ ObjectDef, ObjectStore, UseInteraction, SafespotConfig (room objects only)
- player/ Player struct, skills, inventory, bank, equipment, accounts, XP, validation
- ui/ Pure renderers (progress bars, Unicode tables) — independent of game state
- validate/ Startup integrity checks (duplicate IDs, references, orphan rooms)
- world/ World, RoomDef, MobDef, MobInstance, MobStore, ObjState, ground items
-```
-
-`internal/game/` is organized by file prefix:
+Binary accepts `--config <path>` (defaults to `config.yaml`). Dependencies: `gopkg.in/yaml.v3`, `github.com/gorilla/websocket`.
-| Prefix | Purpose |
-|---|---|
-| `game.go`, `cmd_registry.go`, `tick.go` | Core state machine, command dispatch, tick callbacks |
-| `core_*.go` | Infrastructure — types, utils, login, skill XP, flags, courses, stations |
-| `act_*.go` | Tick-based action lifecycles — gather, firemaking, talk, use, search, farming, agility, etc. |
-| `cmd_*.go` | Command handlers — one file per command or command group |
-| `sys_*.go` | Game subsystems — technology, science, combat/death/aggro, buffs, construction, hazards, labor |
-| `render_*.go` | Session-coupled rendering — colour resolution, prompt, map, combat, help, XP drops |
-| `production_*.go` | Unified production engine + menu/selection UI (split from `core_production.go`) |
-| `combat_*.go` | Player-side attack and mob-side/hit-resolution logic (split from `cmd_attack.go`) |
-| `look_*.go` | Room/entity/target display helpers (split from `cmd_look.go`) |
-| `flagstore.go`, `safespot_manager.go`, `command_queue.go` | Extracted state managers with own mutexes |
-| `validate_source.go` | Adapter that populates the `validate.Source` from Game stores |
+## Prefixes
-The Game struct embeds a `Deps` struct (World, ItemStore, ObjectStore, MobStore, AccountStore, CraftIndex, CourseStore, Ticks, ColorConfig, DataDir) to make injected dependencies explicit while keeping `g.World`, `g.ItemStore` etc. working unchanged.
+File prefixes in `internal/game/`: `game.go/cmd_registry.go/tick.go` (core), `core_*` (infra), `act_*` (actions), `cmd_*` (commands), `sys_*` (subsystems), `render_*` (output), `production_*` (crafting engine), `combat_*` (hits), `look_*` (display).
## Key Conventions
-**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses are YAML files under `data/`. Read from disk on every access — edit and it takes effect immediately, no restart. Crafting information lives in `craft:` blocks on item YAML files — no separate recipe directory. Items are organized into subdirectories by category (items: `equipment/`, `tools/`, etc.; rooms: `town/`, `wilderness/`, etc.; objects: room-specific one-offs in `unique/`, generic shared objects flat). **The filename is the ID for every data type** (objects, items, mobs, rooms, drops, courses, techs, modules, hazards, help): lookup is by filename stem via the recursive path index, and every loader derives the struct's `ID` from that filename. **Do not put a top-level `id:` field in any data file — it is ignored.** (A nested `- id:` under a room's `objects:`/`mobs:` list is different: a *reference* to an object/mob by its filename, and is still required.) All IDs are globally unique across subdirectories via path-indexed loading; subdirectories are organizational only and do not namespace IDs.
-
-**Live state.** Player data (characters, accounts) is written to YAML on every state change. Ground items, mob instances, and object states live in memory only and are lost on restart.
-
-**No database.** Everything is flat YAML files. Account passwords use sha256 + 16-byte random salt.
-
-**Transport-agnostic.** The `Conn` interface (`internal/net/conn.go`) abstracts the transport layer. `tcpConn` wraps a raw `net.Conn` for telnet. `wsConn` wraps `gorilla/websocket.Conn` for the web client. Game code works with `*Session` regardless of transport.
+**YAML-driven.** Rooms, items, objects, mobs, drops, modules, courses under `data/`. Read from disk on every access — edit and it takes effect immediately. **The filename is the ID** for every data type: lookup by filename stem. **Do not put a top-level `id:` field** (ignored). A nested `- id:` under a room's `objects:`/`mobs:` is a *reference* and is still required. Subdirectories are organizational only.
-**Input sanitization.** `HandleSession` strips Unicode control characters from every input line via `player.StripControlCharacters`. Account/character names validated to `[A-Za-z0-9 ]{1,30}`. Input truncated to 1024 bytes. Password echo suppressed via IAC ECHO (telnet) and OSC sequences (web). WebSocket origin checked against host header + loopback.
+**Live state.** Player data persisted to YAML on every change. Ground items, mob instances, object states are in-memory only.
-**Tick-driven.** The world runs on a configurable tick (default 600ms, min 50ms). All game advancement happens per tick.
+**No database.** Everything flat YAML. Passwords use sha256 + 16-byte random salt.
-**Fractional ticks.** All YAML timer fields accept `float64` values. `engine.ToTicks(base)` probabilistically rounds to an integer — a tool_speed of 4.5 means each action cycle has a 50% chance of 4 ticks and 50% of 5 ticks.
+**Transport-agnostic.** `Conn` interface (`internal/net/conn.go`) — telnet and websocket. Game code works with `*Session`.
-**Concurrency.** Every state-bearing package uses its own `sync.Mutex`:
-- `world.World` — groundItems, objStates, seeded, objMoves
-- `world.MobStore` — defs, instances
-- `behavior.Store` — cache
-- `engine.Engine` — subscribers
-- `Game` — charsMu (loggedInChars map), enterMu (enterSeqs map)
-- `net.Hub` — sessions, rooms maps (protects `Add`/`Remove`/`EnterRoom`/`LeaveRoom`/`AllSessions`/`PlayersInRoom` from concurrent access across accept/session/tick goroutines)
-- `net.Session` — per-session writeMu serializes writes to the Conn (protects against interleaved bytes from cross-session broadcasts and tick-driven writes)
-- `FlagStore` — world flags (shared by all players)
-- `SafespotManager` — per-player safespot states (value-copy API avoids lock-copy-pointer anti-pattern)
-- `CommandQueue` — free/active/consume command queues (written from session goroutines, drained by tick goroutine)
-- `combat` — combatants, mobTargets
+**Tick-driven.** 600ms default (min 50ms). All game advancement per tick. YAML timer fields accept `float64`; `engine.ToTicks(base)` probabilistically rounds.
-The pattern is: lock, snapshot/unmarshal, unlock, then process. The engine creates a subscriber snapshot each tick.
+**Concurrency.** Every state package uses its own `sync.Mutex`. Pattern: lock → snapshot/unmarshal → unlock → process. Engine creates subscriber snapshot each tick.
## Two State Systems
-- **World flags** (`set_flags` / checked with `flag`): Shared by all players. A door opened by one player is open for everyone.
-- **Player flags** (`set_player_flags` / checked with `player_flag`): Per-character, saved to character YAML. Quest progress, dialog history.
+- **World flags** (`set_flags` / `flag`): Shared by all players.
+- **Player flags** (`set_player_flags` / `player_flag`): Per-character, saved to YAML.
## Room Scripting
-- **`on_enter` steps** fire when a player enters. Each has a `message` + optional `condition`. A step may also have a `delay` (ticks) and/or `set_flags`/`set_player_flags`. If any surviving step is timed (has a delay or sets a flag) the sequence runs as a scheduled **enter sequence** (driven by `EnterSeqTick`); otherwise messages print synchronously. Step conditions are snapshotted once at entry. An enter sequence persists `Player.EnterSeqRoom` and **resumes on reconnect** if interrupted; plain on_enter never runs on login (except the first room for new characters).
-- **Conditional descriptions** (`description: [{text, condition}]`) on rooms and objects pick the first variant whose condition passes (per-looker), else the first entry with no condition. Accepts either a plain string or a list. For an **object**, if it has a `description` list and no variant matches, the object is **absent** for that player (`look` falls through). Objects also support `aliases: [...]` for extra names. `look` lists candidates when multiple distinct objects match.
-- **Exits** support `set_flags`/`set_player_flags`, applied only on a successful traverse.
+- **`on_enter` steps**: message + optional condition. A step may have `delay` and/or `set_flags`/`set_player_flags`. Timed steps run as scheduled enter sequences; otherwise messages print synchronously. Conditions snapshotted once at entry. Resumes on reconnect if interrupted.
+- **Conditional descriptions** (`description: [{text, condition}]`): first passing variant wins. For objects, if no variant matches the object is absent for that player. Accepts plain string or list.
+- **Exits** can `set_flags`/`set_player_flags` on successful traverse.
## Data Files
```
-data/rooms/<id>.yaml Room definitions (exits, objects, mobs, item_spawns, on_enter, description)
-data/rooms/player_housing/ Player house rooms
-data/items/<id>.yaml Item definitions (stats, equip_slot, tool_type, craft, burn_ticks, search_table, etc.)
-data/mobs/<id>.yaml Mob definitions (combat stats, drops, behavior, steal config; kind: task for worksites)
-data/objects/<id>.yaml Object definitions (name, aliases, behavior, hidden, inroom_description, description, removal_item)
-data/objects/unique/ Room-specific one-off objects, prefixed with the room number (e.g. unique/1001_sign.yaml)
-data/drops/<id>.yaml Shared drop tables (weighted item lists, sub-table references)
-data/hazards/<id>.yaml Shared room hazard definitions (attack_type, attack, max_hit, speed, messages, required_item)
-data/help/<id>.yaml Help topics
-data/modules/<id>.yaml Science module definitions (level, max_hit, base_xp, junk_cost, category)
-data/techs/<id>.yaml Technology definitions (name, level, drain_rate, category, group, effects)
-data/courses/<id>.yaml Agility course definitions (name, required_level, start_room, obstacles)
-data/players/accounts/ Account YAML (gitignored)
-data/players/characters/ Character YAML (gitignored)
+data/rooms/<id>.yaml data/items/<id>.yaml data/mobs/<id>.yaml
+data/objects/<id>.yaml data/objects/unique/ data/drops/<id>.yaml
+data/hazards/<id>.yaml data/help/<id>.yaml data/modules/<id>.yaml
+data/techs/<id>.yaml data/courses/<id>.yaml data/players/accounts|characters/
```
-Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YAML — mob defs are generic. Mobs wander via legal (unconditioned) room exits; objects teleport between rooms in their assigned list.
-
-See `building_guide/` for full YAML format reference with examples.
+Wander config per-instance in room YAML, not on MobDef. See `building_guide/` for full YAML format reference.
## Startup Validation
-At startup, the server runs comprehensive integrity checks on all data files. Errors and warnings are logged to stderr; the server continues starting regardless (a "may behave unexpectedly" banner is printed if errors are found).
-
-**Duplicate ID checks** across all data directories (items, rooms, mobs, objects, drops, courses, modules, help). Detects collision from two files with the same ID in different subdirectories.
-
-**Referential integrity checks:**
-- Room exits → target rooms exist
-- Room mobs/objects/spawns → mob defs/object defs/items exist
-- Mob drops, steal tables, finishing blow items → referenced items/drop tables exist
-- Object removal items, steal tables, guard mobs, use_interaction items → exist
-- Object gather config drops → items/tables exist; bait items exist
-- Object talk config node actions (give_item, take_item, teleport, shop items) → exist
-- Item search tables, farm_product → exist
-- Item craft blocks → consume items, fail products, station objects, tools → exist
-- Drop table sub-references → exist
-- Course start_room and obstacle rooms → exist
-
-**World wiring checks** (config-driven):
-- Orphan rooms (no exit from any room leads to them)
-
-### Config: `startup_validation`
-
-```yaml
-game:
- tick_length: 600
- startup_validation:
- check_sources: [1] # rooms to start reachability walk from
- ignore_unreachable: [] # suppress "no exit leads here" for these rooms
-```
-
-Defaults: `check_sources: [1]`, ignore list empty. Set `check_sources` to include rooms accessible only via teleport (e.g., `[1, 1000]`) to suppress false orphan warnings. Use `ignore_unreachable_to` for rooms that are intentionally isolated.
-
-## Inline Color Tags
-
-Room descriptions and prompts support inline color tags using `{spec}text{/}` syntax.
-
-```yaml
-description: "On the table lies a {182 bold}mysterious vase{/} with a rose in it."
-```
-
-Tag spec format: `{<0-255> [bold] [dim] [underline]}text{/}`.
-
-Gradients: `{g:196,82}gradient text{/}`. Multi-stop: `{g:45,39,59}three stops{/}`. Item and object YAML `color` fields also support gradient syntax: `color: "g:196,208,226"`.
-
-Gradients interpolate in RGB space across the xterm-256 palette. In ANSI mode, each character maps to the nearest ANSI color.
+Comprehensive integrity checks on all data files (errors logged, server still starts). Duplicate ID checks across all directories. Referential integrity: exits→rooms, mobs/objects/spawns→defs, drops/steal→items/tables, craft blocks→items/stations/tools, etc. Configurable orphan-room reachability check (`startup_validation.check_sources`).
## Command Dispatch
-`handleGameCommand()` in `game.go`:
-1. Expands account aliases (aliases can shadow built-in commands)
-2. Splits input, lowercases the command word
-3. Special-cases `equip`/`wear`/`wield` with no args → display equipment directly
-4. Calls `classifyCommand(cmd)` from `cmd_registry.go` → `ClassInstant` / `ClassFree` / `ClassActive` / `ClassUnknown`
-5. ClassInstant executes `executeCommand()` immediately + writes prompt
-6. ClassFree appends to `freeQueue[playerName]` for next tick
-7. ClassActive replaces entry in `activeQueue[playerName]`, sorted by timestamp on execution
-8. ClassUnknown falls through to `verbAliases` and `obstacleVerbs` maps, then prints error
+`handleGameCommand()` in `game.go`: expands account aliases → split input → `classifyCommand()` → `ClassInstant` (executes now + prompt), `ClassFree` (next tick queue), `ClassActive` (sorted timestamp queue), `ClassUnknown` (verbAliases/obstacleVerbs, else error).
## Tick-Based Action Queue
-`ProcessQueuedCommands()` runs each tick:
-1. Clears stale `ActionState` from one-tick actions (move, get, drop)
-2. Executes all free commands per player in queued order
-3. Executes all active commands sorted globally by timestamp (first-to-queue wins conflicts)
-4. Advances walk sequences (one step per tick)
-5. Flushes pending shared depletions
-
-**Conflict resolution:** multi-player attack on same mob, get on same item, etc. — first to queue wins (timestamp-order). Shared depletion awards all successful gatherers on depletion tick.
-
-**ActionState** tracks player activity for `look` display. Timed actions persist until interrupted; one-tick actions (move, get, drop) clear after one tick.
-
-Queue feedback is suppressed by default via the `queue_silently` option.
+`ProcessQueuedCommands()`: clear stale one-tick actions → execute free commands → execute active commands (sorted, first-queued wins conflicts) → advance walks → flush shared depletions. `ActionState` tracks player activity for `look`. Queue feedback suppressed by `queue_silently`.
## Action System
-Actions that interact with objects/mobs route through `startAction()` in `act.go`:
-1. Normalizes verb via `verbAliases` map (mine/chop/fish/cut → gather, talk/speak/ask → talk)
-2. Multiple object types → "That's ambiguous, which one?"
-3. Searches object instances in room (numbered targeting via `N.name`)
-4. Falls back to mob instances if no object matches
-5. Loads the behavior from YAML
-6. Routes to type-specific handler (startGather, startMobTalk, startTalk, startUse)
-
-The `verbSkill` map maps verb → skill name for default target resolution:
-- mine → mining, chop/cut → woodcutting, fish → fishing
-
-### Action Types (from act_state.go)
-
-| ActionType | Trigger | Type |
-|---|---|---|
-| `gathering` | mine/chop/cut/fish | Active |
-| `combating` | attack/kill | Active |
-| `using` | use / pull / push | Active |
-| `talking` | talk/speak/ask | Active |
-| `burning` | burn | Active |
-| `stoking` | stoke | Active |
-| `searching` | search | Active |
-| `resting` | quit | Active |
-| `walking` | walk | Active |
-| `moving` | n/s/e/w/u/d | Active |
-| `picking_up` | get | Active |
-| `dropping` | drop | Active |
-| `producing` | cook/smelt/smith/craft/mix/construct | Active |
-| `fletching` | fletch | Free (background) |
-| `eating` | eat | Free |
-| `cleaning` | clean | Free (background) |
-| `identifying` | identify/id | Active |
-| `stealing` | steal/thieve | Active |
-| `planting` | plant | Active |
-| `harvesting_crop` | harvest | Active |
-| `raking` | rake | Active |
-| `watering` | water | Active |
-| `curing` | cure | Active |
-| `traversing` | obstacle verbs (scramble/jump/etc.) | Active |
-| `hacking` | jack/jackin | Active |
-| `working` | work/attack on a task mob (worksite) | Active |
-
-### Behavior Types (YAML-driven in behavior.go)
-
-| Type | Verbs | Description |
-|---|---|---|
-| `gather` | mine, chop, cut, fish | Resource gathering with skill checks, tool requirements, depletion/respawn |
-| `use` | use | Crafting stations — consume items, produce output, optional skill checks |
-| `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, node actions |
-
-### Hardcoded Actions (not YAML-driven)
-
-| Action | File | Description |
-|---|---|---|
-| burn/stoke | `act_burn.go` | 3-phase firemaking: drop log → try → skill check → create fire. Stoke adds logs. |
-| search | `act_search.go` | Search items with search_table, consumes one, rolls on drop table |
-| cook/smelt/smith/craft/mix | `production_core.go` | Unified recipe-driven production — station checks, timed loops, success/fail, XP, byproducts |
-| fletch | `act_fletch.go` | Background fletching — instant or timed, stackable output |
-| clean | `act_clean.go` | Background herb cleaning via recipe matching |
-| steal | `act_steal.go` | Thieving — pickpocket, stall stealing, guard watching, sneak mode |
-| agility | `act_agility.go` | 3-phase obstacle traversal with fail chance, course lap tracking |
-| farm | `act_farm.go` | Planting, growth stages (500-tick), watering, disease, harvesting |
-| identify | `act_identify.go` | Scavenging — scrap → junk at altars |
-| finishing blow | `act_finishing_blow.go` | Assassin special item on mob at 1 HP |
-
-### GatherConfig Fields
-
-| Field | Description |
-|---|---|
-| `skill` | Skill name for level check |
-| `level` | Required level |
-| `xp` | XP per successful gather |
-| `base_wait` | Base ticks per action cycle |
-| `tools` | Required tool_type list |
-| `bait` | Required bait item |
-| `success` | SuccessFormula (base + per_level * (level - required), capped) |
-| `drops` | Weighted drop entries (item_id, table ref, depletes, quantity, message, level, xp) |
-| `respawn_timer` | Ticks until depleted object respawns |
-| `deplete_timer` | Ticks for shared depletion (trees) |
-| `nest_chance` | 1/N chance for bird's nest alongside normal drop |
-| `gather_message` | Custom "You gather..." message |
-| `depleted_message` | Custom "The rock is depleted" message |
-| `exhausted_message` | Custom "The tree falls" message |
-| `fail_message` | Custom failure message |
-| `respawn_broadcast` | Broadcast message when object respawns |
-
-### SuccessChance Formula
-
-```go
-chance = cfg.Base + (level - requiredLevel) * cfg.PerLevel
-clamped to [0, cfg.Cap]
-```
+Actions route through `startAction()` in `act.go`: normalize verb → resolve target (objects then mobs) → load behavior → route to handler.
-### Node Actions (talk dialog)
-
-| Field | Effect |
-|---|---|
-| `set_flags` | Sets world flags (global) |
-| `set_player_flags` | Sets player-local flags (per-character, saved to YAML) |
-| `give_item` | Gives an item to inventory |
-| `take_item` | Removes an item from inventory |
-| `teleport` | Moves player to a room ID |
-| `heal` | Restores hitpoints |
-| `cost` | Deducts credits from player |
-| `shop` | Opens a buy/sell interface |
-| `assign_task` / `skip_task` / `extend_task` | Assassin task management |
-| `sawmill` | Opens sawmill interface (construction) |
+**Behavior types (YAML-driven):** `gather` (mine/chop/cut/fish), `use` (crafting stations), `talk` (NPC dialog). **Hardcoded actions:** burn/stoke, search, production (cook/smelt/smith/craft/mix/fletch/clean), steal, agility, farm, identify, finishing blow. Gather behaviors use SuccessChance = Base + (level-required)*PerLevel, clamped `[0, Cap]`. Action types and all YAML config fields are self-documenting in the source (`act_state.go`, `behavior.go`, `item/item.go`).
### Condition System
-Used by exits, talk options, on-enter scripts, and use_interactions checks. A bare `flag`/`player_flag` passes when the flag is set to a truthy value; `value` matches a specific value; `not` inverts.
-
-| Field | Scope | Example |
-|---|---|---|
-| `flag` | World (shared by all) | `flag: gate_open` |
-| `player_flag` | Per-character | `player_flag: finished_tutorial` |
-| `value` | Exact value to match (else truthy) | `value: 3` |
-| `has_item` | Player inventory | `has_item: bronze_key` |
-| `min_credits` | Player credits | `min_credits: 100` |
-| `all_of` | All sub-conditions pass | Nested list |
-| `any_of` | Any sub-condition passes | Nested list |
-| `not` | Invert the check | `not: true` |
+Used by exits, talk options, on-enter, use_interactions: `flag` (world), `player_flag` (per-char), `value` (exact match), `has_item`, `min_credits`, `all_of`, `any_of`, `not`. Bare flag/player_flag passes on truthy.
## Two Depletion Mechanics
-**Per-drop depletion** (mining, regular trees): Set `depletes: true` on a drop entry. The resource depletes on first successful gather of that drop.
-
-**Shared depletion** (higher-tier trees): Set `deplete_timer: <ticks>` on the behavior. A shared timer counts down while anyone is chopping; when it hits 0, the next successful gather depletes the tree for all players. Timer regenerates when no one is chopping. Managed by `SharedDepletionTick`.
+- **Per-drop** (`depletes: true`): depletes on first successful gather of that drop.
+- **Shared** (`deplete_timer: <ticks>`): timer counts down while anyone gathers; at 0, next gather depletes for all. Regenerates when idle.
## Ground Item System
-- `DropDespawnTicks = 1000` — dropped items despawn after 1000 ticks
-- `ReserveTicks = 100` — mob loot is reserved for the killer for 100 ticks
-- Items with same ID in the same room are NOT merged — independent despawn timers
-- Spawn items respawn after their delay; nearby matching items are merged on respawn
-- Despawn timers visible via `despawn` option; reserve info via `reserve` option
+`DropDespawnTicks = 1000`. `ReserveTicks = 100` (mob loot reserved for killer). Same-ID items NOT merged. Spawn items respawn with merge. Despawn/reserve visible via options.
## Combat System
-**Attack types.** Weapons have an `attack_type` field (stab/slash/crush/ranged/science). The attack type determines which player attack bonus and which mob defense bonus to use.
-
-**Accuracy formula.** `HitChance(a, d)`: if `a > d` → `1 - (d+2)/(2*(a+1))`, else `a/(2*(d+1))`. Equal rolls ≈ 50%.
+`attack_type` (stab/slash/crush/ranged/science) selects attack/defense bonuses. Ranged consumes 1 ammo/attack. Aggressive mobs auto-attack if `playerLevel <= mobLevel * 2`. Movement during combat = flee attempt (mob hits abort). Death: drop credits + keep 3 most valuable items, respawn at room 1, deactivate techs. Equipment `requirements` checked before equipping. Formulas and XP splits in `combat/tracker.go`.
-**Ranged combat.** Weapon type "ranged" uses Ranged level for attack rolls, consumes 1 ammo per attack. If ammo runs out, combat ends. XP: 75% Ranged, 25% Hitpoints.
+## Labor System (`sys_labor.go`, `sys_hazard.go`)
-**Agile mobs.** Mobs with `aggressive: true` auto-attack players entering their room (1-tick delay) if `playerLevel <= mobLevel * 2`.
+Non-violent reuse of combat engine for worksites. Task mobs (`kind: task`): HP drains to 0 to complete work (progress bar fills). Never attacks back. Commands `work`/`attack`/`kill` route to `doAttack`. Per-type defenses = method efficiency.
-**Equipment requirements.** Items can have `requirements: { accuracy: 20, defense: 10 }` — checked before equipping.
+**Hazards:** Room references shared hazard by ID. `HazardTick()` rolls against everyone in room every `speed` ticks. `required_item` negates. Safespot blocks all hazard damage. Lethal hit calls `killPlayer`. Does NOT interrupt movement. Safe→dangerous prompts `[Y/n]` unless `danger_warning` off.
-**Fleeing combat:** Movement during combat is a flee attempt. Mobs continue attacking during the movement countdown; a hit aborts the flee ("Can't escape!"). Cape of Agility makes fleeing instant.
+## Technology System (`tech.go`)
-**Death mechanic:** Player drops credits to ground. If >3 items total (inventory + equipment), the 3 most valuable are kept, rest dropped. Player teleports to room 1 at full HP. All active techs deactivated.
+Togglable buffs that drain battery. 4 categories: Attack/Strength/etc. tiers (5/10/15% boost), Protection techs (40% reduction), Utility (regen/drain/retribution), Combo. Reserved IDs wired to code: `protect_melee`, `protect_ranged`, `protect_science`, `retribution`. Battery = Technology level. Each tech has drain rate. Equipment TechnologyBonus reduces drain (cap 50%). ActiveTechs resets on reconnect.
-**XP formula:** `baseXP = dmg * 4`, distributed by attack style:
-- Accurate: +3 accuracy, 75% Accuracy XP / 25% HP
-- Aggressive: +3 strength, 75% Strength XP / 25% HP
-- Defensive: +3 defense, 75% Defense XP / 25% HP
-- Balanced: +1 each, 25% each Accuracy/Strength/Defense/HP
-- Ranged styles: varied bonus, 75% Ranged XP / 25% HP
+## Science System (`science.go`)
-**Mob combat level:** `floor((def+hp)/4 + max((atk+str)/4, rng*3/8, sci*3/8))`.
+Mods from `data/modules/`: combat/utility/transport/enchant/processing. `trigger <mod>` fires once. `autotrigger <mod>` auto-fires each round when deck equipped. Deck combat uses weapon speed; mod fires instead if autotrigger set. No deck = manual queuing. View with `mods`/`modules`/`module`.
-**Player combat level:** Ranged-or-melee dominant, includes Technology (0.25) and Science (0.125).
+## Hacking System
-**Mob DropTable:** `Remains` (guaranteed drop item), `Loot` (weighted DropEntry table).
+Minigame interface: 3 games at terminals (levels 1/20/40). Interface in `game/hacking/`.
-## Labor System (sys_labor.go, sys_hazard.go)
-
-A **non-violent reuse** of the entire combat engine for worksites — building solar panels, surveying flora, prospecting rocks — so combat skills/weapons train without the theme of killing.
-
-**Task mobs.** A `MobDef` with `kind: task` (default `combat`). Internally its `HP` drains to 0 to **complete** the work; the player sees a progress bar filling to 100% (`progressBar`, the inverse of `hpBar`). It reuses the whole pipeline: `startCombat`/`playerAttack`/`applyPlayerHit`/`endCombat`, all formulas, drops (= "payment"), respawn, the 1-worker `IsMobInCombat` lock. Differences: it **never attacks back** (`startCombat` skips the mob-attack subscription for tasks), display/messages are labor-flavored, and `ActionWorking` is used for `look`. Progress **decays** when unattended — this is the standard mob HP regen, unchanged, shown inverted. Commands `work` and `attack`/`kill` all route to `doAttack`, which auto-detects the kind. Flavor fields: `verb`, `progress_noun`, `complete_message`. Per-type defenses (`crush_defense`…`science_defense`) become **method efficiency** — every style works, designers steer which is efficient. XP/buffs are the normal style-based combat split (no change).
-
-**Hazards.** A room references a shared hazard by ID: `hazard: <id>` → `data/hazards/<id>.yaml` (`world.HazardDef`, cached via `World.LoadHazard`). `HazardTick()` (registered in `main.go`) rolls an attack against **everyone in a hazardous room every `speed` ticks** (cadence in non-persisted `Player.HazardTimer`, reset on room change) using the same `EffectiveRoll`/`HitCheck`/`MaxHit`/`RollDamage`. Player defends with Defense level + equip bonus selected by `attack_type`; `damageAfterTechProtection` applies the matching protect tech. `required_item` equipped negates it. A safespot blocks **all** hazard damage (`safespotBlocksHazard` = `isSafespotted`, type-agnostic). A lethal hazard hit calls `killPlayer` (extracted from `endCombat`). Hazards **do not interrupt movement** (no flee-abort), and stack with mobs. Entering a safe→dangerous room prompts `[Y/n]` (`StateDangerConfirm`) unless the `danger_warning` option is off.
-
-## Technology System (tech.go)
-
-Tech definitions loaded from `data/techs/` via `LoadTechs()`. Techs in 4 categories, unlocked by Technology level:
-- **Attack/Strength/Defense/Ranged/Science tiers:** I/II/III at 5%/10%/15% level boost
-- **Protection techs:** Kinetic Barrier (melee), Projectile Screen (ranged), Neural Firewall (science) — 40% damage reduction each
-- **Utility:** Nano Repair (2x HP regen), Rapid Repair (4x), Power Saver (20% drain reduction), Dead Man's Switch (25% max HP retribution on death)
-- **Combo:** Overclock (15% atk+str), Fortify (15% def + 2x regen)
-
-Reserved tech IDs whose semantics are coupled to code logic:
-- `protect_melee` / `protect_ranged` / `protect_science` — mapped by attack type in `damageAfterTechProtection`. Must exist in YAML; startup validation enforces this.
-- `retribution` — checked by name in `killPlayer` (cmd_attack.go) for the death retribution mechanic. Must exist in YAML.
-
-The Science tier techs (Neural Focus I/II/III) boost Science accuracy via `techLevelBonus(p, "science")`, which is included in the attack roll of both the melee/ranged `calculatePlayerAttackRoll` and the deck `scienceAttack` path.
-
-Battery = Technology level (float64). Each tech has a drain rate (0.05–0.25 per tick). Equipment TechnologyBonus reduces drain (capped at 50%). Active techs deactivate when battery hits 0. 1-tick "flicking" grace period supported. ActiveTechs is not persisted (resets on reconnect).
-
-## Science System (science.go)
-
-Mods loaded from `data/modules/` — categories: combat, utility, transport, enchant, processing. Each mod has a junk cost. Deck equipment (science weapons) provides junk and removes scrap cost.
-
-**Triggering mods:**
-
-- `trigger <mod>` — triggers a mod. Combat mods in non-deck combat queue as a one-shot attack. Transport/utility/enchant mods resolve immediately. Module matching searches by partial name (all words); if ambiguous, picks the highest-level module the player can cast (at or below their Science level).
-- `autotrigger <mod>` — sets a combat mod to auto-fire each attack round when a deck is equipped. `autotrigger none` disables. Without a deck, autotrigger has no effect.
-
-**Deck combat:** When a deck weapon is equipped, attacks fire at the weapon's speed. If an autotrigger mod is set and the player has the required junk, the mod fires instead of a normal attack. If no autotrigger or out of junk, an error is shown and the player does an unarmed attack with no bonuses.
-
-**Non-deck manual triggers:** `trigger <mod>` during combat without a deck queues one module to replace the next combat attack. Only one can be queued at a time.
+## Skills (23 total, 1-99, classic XP table)
-Mod list viewable with `mods`, `modules`, or `module` command.
+| Category | Skills |
+| ---------- | --------------------------------------------------------------------------- |
+| Combat | Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology, Assassin |
+| Gathering | Fishing, Woodcutting, Mining, Hacking, Scavenging, Thieving |
+| Production | Cooking, Smithing, Crafting, Fletching, Pharmacy, Construction, Farming |
+| Utility | Agility, Firemaking |
-## Hacking System (game/hacking/)
+## Session States
-Minigame interface: `Init(level) string`, `HandleInput(input) (output, done, won)`, `Name() string`.
+20 states in `net/server.go` covering login flow, game, NPC talk, menus (bank/shop/recipe/confirm), and hacking.
-Three minigames at 3 terminal levels (1, 20, 40):
-- **WumpusGame** — Hunt the Rogue AI: 20-node dodecahedron, probes, hazards
-- **MastermindGame** — Code Breaker: 4-digit cipher, 10 guesses, feedback
-- **LiarsDiceGame** — Signal Bluff: hidden dice bidding vs AI, wilds, exact calls
+## ItemDef Fields
-XP scales with Hacking level.
+Full field reference in `item/item.go`. Key fields: equip_slot, weapon_type, attack_type, stats, speed, requirements, tool_type, craft block, farming, potion, search_table.
-## Skills (23 total, 1-99, classic XP table)
+## CraftIndex
-| Category | Skills |
-|---|---|
-| Combat | Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology, Assassin |
-| Gathering | Fishing, Woodcutting, Mining, Hacking, Scavenging, Thieving |
-| Production | Cooking, Smithing, Crafting, Fletching, Pharmacy, Construction, Farming |
-| Utility | Agility, Firemaking |
-
-All fully implemented.
-
-## Option System
-
-Account-wide options set via `option <name> <value>`. Stored in account YAML, shared by all characters on the account.
-
-| Option | Type | Default | Description |
-|---|---|---|---|
-| `description` | bool | true | Long room descriptions when moving |
-| `tiny_map` | string | "right" | off/right/left — mini-map display |
-| `xp_drops` | bool | true | XP drop messages |
-| `exits` | bool | true | Long exit display in look |
-| `mob_enter` | bool | true | Messages when mobs enter |
-| `mob_leave` | bool | true | Messages when mobs leave |
-| `mob_spawn` | bool | true | Messages when mobs spawn |
-| `reserve` | bool | true | Show full reserved item details |
-| `depletion` | bool | false | Show depletion timers |
-| `despawn` | bool | false | Show ground item despawn timers |
-| `color` | string | "xterm256" | none/ansi/xterm256 — color output mode |
-| `map_width` | int | 30 | Map viewport width |
-| `map_height` | int | 20 | Map viewport height |
-| `map_padding` | string | "none" | none/x/y/xy — blank-row stripping |
-| `automap` | bool | false | Auto-show map after moving |
-| `queue_silently` | bool | true | Suppress queue messages |
-| `danger_warning` | bool | true | Confirm `[Y/n]` before entering a hazardous area from a safe one |
-| `room_desc_width` | int | 70 | Room desc line wrap width |
-| `wrap_width` | int | 120 | Wrap ALL output to this many columns (clamped to min 80; applied in `net.Session.WriteLine/WriteLines/Writef`, not `Write`) |
-| `unicode` | bool | true | Unicode box-drawing characters |
-| `run_countdown` | bool | false | Flee countdown messages |
-| `visual_ticks` | bool | false | Tick marker display |
-| `visual_tick_count` | int | 0 | Tick counter cycle length |
-| `visual_tick_text` | string | "Tick" | Text for visual tick marker |
-| `fletch_all` | bool | false | Auto-start fletching |
-| `smith_all` | bool | false | Auto-start smithing |
-| `cook_all` | bool | false | Auto-start cooking |
-| `smelt_all` | bool | false | Auto-start smelting |
-| `mix_all` | bool | false | Auto-start mixing |
-| `construct_all` | bool | false | Auto-start constructing |
-| `craft_all` | bool | false | Auto-start crafting |
-
-Prompt is NOT an option — per-character via the `prompt` command. Default: `"> "`. `prompt none` sets it to blank.
-
-Prompt variables: `%h` (HP), `%H` (max HP), `%b` (battery), `%B` (max battery), `%c` (credits), `%i` (free slots), `%s` (style short), `%S` (style long), `%m` (mob HP), `%M` (mob max HP), `%X_<skill>` (XP), `%x_<skill>` (XP to next).
-
-## Session States (net/server.go)
-
-| State | Purpose |
-|---|---|
-| `StateAccountName` | Entering account name |
-| `StatePassword` | Entering password |
-| `StateNewAccountPass` | Setting password for new account |
-| `StateNewAccountConfirm` | Confirming password |
-| `StateColorChoice` | New account: "Should I disable color?" |
-| `StateMenu` | Main menu |
-| `StateNewCharName` | Naming new character or selecting for connection |
-| `StateRenameAccount` | Renaming account |
-| `StateRenameChar` | Selecting character to rename |
-| `StateRenameCharName` | Entering new character name |
-| `StateDeleteChar` | Confirming character deletion |
-| `StatePurgeAccount` | Confirming account purge |
-| `StateGame` | In-game command mode |
-| `StateChangeDescription` | Setting player description |
-| `StateTalk` | NPC conversation input |
-| `StateDropAllConfirm` | Confirming `drop all` |
-| `StateRecipeChoice` | Recipe/product selection (by number or name) |
-| `StateHowMany` | "How many?" production count |
-| `StateSmithProduct` / `StateFletchProduct` / `StateCraftProduct` / `StateProductChoice` | Product selection table menus |
-| `StateShop` | Shop interface (buy, sell, browse, leave) |
-| `StateBank` | Bank interface (deposit, withdraw, browse, leave) |
-| `StateHacking` | Jacked into a terminal running a minigame |
-| `StateDangerConfirm` | Confirming `[Y/n]` entry into a hazardous room |
-
-## ItemDef Fields (item/item.go)
-
-| Field | Description |
-|---|---|
-| `id` | Unique identifier |
-| `name` | Display name |
-| `aliases` | Alternate name prefixes for matching |
-| `description` | Item description |
-| `value` | Credit value |
-| `stackable` | Can stack in inventory |
-| `equip_slot` | head/neck/torso/legs/hands/feet/back/ammo/main_hand/off_hand/ring |
-| `weapon_type` | melee / ranged / science |
-| `attack_type` | stab / slash / crush / ranged / science |
-| `stats` | Per-type attack/defense bonuses, strength, ranged_strength, science_damage, technology_bonus |
-| `speed` | Attack speed in ticks |
-| `requirements` | Skill level requirements to equip |
-| `tool_type` | Tool type string for gather requirements |
-| `tool_speed` | Speed bonus for gathering |
-| `burn_ticks` | Burn duration for firemaking |
-| `fire_level` | Required firemaking level |
-| `fire_xp` | XP for burning |
-| `quality` / `max_quality` | Default and maximum quality |
-| `search_table` / `search_misc_table` | Drop tables for searching this item |
-| `search_ticks` | Ticks per search action |
-| `search_message` | Custom search message |
-| `craft` | Craft block — type, skill, level, XP, station, tool, consume, output_qty, fail, message, success, steps |
-| `farming` | Farming-related fields (seed type, patch, yield, level, xp) |
-| `potion` | Potion properties (buff levels for attack/strength/defense/agility) |
+`CraftIndex` built once at startup: O(1) lookups via `ByType`, `ByStation`, `ByInput`, `FindByTwoInputs`. No cache invalidation — static after startup.
## Places to Be Careful
-- `startAction` searches objects first, then mobs. A mob must have `behavior:` set on its YAML def to be interactable.
-- Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`. Same pattern for `RoomMob`.
-- `checkCondition` is the single condition evaluator — used by exits, talk options, on-enter scripts, and use_interactions.
-- Object `hidden: true` means the object doesn't appear in room listings but is still interactable.
-- Alias expansion happens before command dispatch and can shadow built-in commands.
+- `startAction` searches objects first, then mobs. Mobs need `behavior:` on YAML def to be interactable.
+- Exits: old `north: 2` still works via custom `UnmarshalYAML`. Same for `RoomMob`.
+- `checkCondition` is the single condition evaluator — shared across exits, talk, on-enter, use_interactions.
+- Object `hidden: true` = not in room listings but still interactable.
+- Alias expansion happens before command dispatch and can shadow built-ins.
- `say` preserves case from raw input.
-- `get`, `drop`, and `quit` cancel the player's active action.
-- `findGroundMatches` and `findInventoryMatches` support bidirectional prefix matching — `"iron ax"` matches `"iron axe"`.
-- Mob wander config is per-instance in room YAML via `RoomMob`, not on `MobDef`.
+- `get`, `drop`, `quit` cancel active action.
+- `findGroundMatches`/`findInventoryMatches` support bidirectional prefix matching.
+- Mob wander config is per-instance in room YAML, not on MobDef.
- Walk distance capped by agility level.
-- `wear all` equips the highest-value item per slot; `remove all` unequips everything.
-- Cape of Agility makes movement instant (1 tick). Graceful pieces each reduce by 0.25 ticks; full set +0.5 bonus for 2 ticks total. Cape overrides Graceful.
-- During combat, movement is a flee attempt. Mob hits during countdown abort the flee.
-- Level-up messages output `*** You are now level N <skill>! ***` across all skills that gain XP.
-- Task mobs (`kind: task`) reuse the combat engine 1:1 — internally HP drains to 0 to complete; the bar is just displayed inverted. They never attack back; danger comes only from a room `hazard:`.
-- Hazard damage uses its own `applyHazardHit` path that deliberately omits the flee-abort (`MoveTicks`) logic, so hazards never interrupt walking. Mob hits still do.
-- A safespot blocks ALL hazard damage regardless of type (`safespotBlocksHazard`), but a melee work/attack step still forces you out of cover (so only ranged/science work keeps you sheltered).
-- `killPlayer` is the shared death path (extracted from `endCombat`); both combat death and lethal hazard hits go through it. `mob` may be nil for a hazard kill.
-- Science (deck) attacks go through `scienceAttack` which calls `combat.EffectiveRoll` for both attack and defense rolls — consistent with melee/ranged. The `techLevelBonus(p, "science")` is included in the attack roll (Neural Focus techs boost science accuracy). The `hpBar`/`progressBar` share a common `renderBar` helper; XP-drop suffixes use `formatXpDrop`/`formatXpDropSingle`.
-
-## Adding a New Skill
-
-1. Add `SkillName` constant + entry in `AllSkills` + `SkillAbbr` in `internal/player/player.go`
-2. Create a gather behavior YAML in `data/behaviors/`
-3. Create an object YAML referencing that behavior in `data/objects/`
-4. Create an item for the tool in `data/items/` (with `tool_type`)
-5. Place the object in a room YAML in `data/rooms/`
-6. Create any resource items in `data/items/`
-
-## Adding a New Production Skill
-
-Production skills share a unified system in `core_production.go`:
-
-1. Create the output item YAML in `data/items/` with a `craft:` block. See `building_guide/recipes.md`.
-2. Create a station object in `data/objects/` if needed
-3. Add a `cmd_<skill>.go` command handler following existing patterns
-4. Add the command to `commandRegistry` in `cmd_registry.go` with the appropriate class
-5. Wire up the `auto_start` option if desired
-
-The unified production cycle handles: HowMany prompt, start message, timed loops, success/fail rolls, XP, output quantity, stackable stacking, byproducts, multi-step messages, and end message. Tool requirements are checked in the command handler before calling `promptHowMany`.
-
-## CraftIndex
-
-`CraftIndex` (`internal/game/craft_index.go`) is built once at startup from all item YAMLs that have a `craft:` block. It provides `O(1)` lookups:
-
-- **`ByType("pharmacy")`** — all pharmacy-craftable items (used by `mix` command)
-- **`ByStation("anvil")`** — all items craftable at that station
-- **`ByInput("guam")`** — all items that consume that ingredient
-- **`FindByTwoInputs(a, b)`** — items consuming both inputs in different consume entries (used by `use` command)
-
-**Key properties:**
-- Built once at startup — `O(n)` one-pass scan over all items
-- All indexes are `O(1)` lookups with zero allocations
-- The CraftIndex stores `*ItemDef` pointers — no RecipeDef copies
-- No cache invalidation — craft data is static after startup
+- `wear all` equips highest-value per slot; `remove all` unequips everything.
+- Cape of Agility: instant movement (1 tick). Graceful pieces reduce by 0.25 each; full set bonus. Cape overrides Graceful.
+- During combat, movement = flee attempt. Mob hits abort flee.
+- Task mobs (`kind: task`) reuse combat engine 1:1; HP drains to 0 to complete. Never attack back — only room hazard provides danger.
+- Hazard damage omits flee-abort — never interrupts walking. Mob hits still do.
+- Safespot blocks ALL hazard damage, but melee work/attack forces you out of cover.
+- `killPlayer` is the shared death path — used by combat and lethal hazards. `mob` may be nil.
+- Science (deck) attacks use `combat.EffectiveRoll` for both rolls. `techLevelBonus(p, "science")` included in attack roll (Neural Focus).
## Adding a New Command
-1. Add a `cmd_*.go` file in `internal/game/` with the handler function `func (g *Game) executeXxx(sess *net.Session, args []string, rawInput string)`
-2. Add the command to `commandRegistry` in `cmd_registry.go` with the appropriate `CommandClass`
-3. Update `data/help/` YAML files
+1. Add `cmd_<name>.go` in `internal/game/` with `func (g *Game) executeXxx(sess *net.Session, args []string, rawInput string)`
+2. Register in `commandRegistry` in `cmd_registry.go`
+3. Add help YAML in `data/help/`
diff --git a/building_guide/behaviors.md b/building_guide/behaviors.md
index a2c1f89..60428d6 100644
--- a/building_guide/behaviors.md
+++ b/building_guide/behaviors.md
@@ -244,9 +244,11 @@ talk:
| Field | Type | Description |
|---|---|---|
| `message` | string or list | NPC dialogue. May be a single string or a list of strings (pick one at random). |
+| `sequence` | []string | NPC monologue — each message shown one at a time, player presses enter to advance. Options appear after the last message. Takes priority over `message` if both present. |
+| `condition` | Condition | Optional. If the condition fails, the node is skipped entirely — its message, action, and options are not shown. Use with `goto` to auto-advance to a different node. |
| `action` | NodeAction | Fires when the node is entered (give items, set flags, etc). |
-| `options` | []TalkOption | Player choices. If empty and `next` is set, auto-advances. |
-| `next` | string | Node ID to auto-advance to when there are no visible options. |
+| `options` | []TalkOption | Player choices. If empty and `goto` is set, auto-advances. |
+| `goto` | string | Node ID to auto-advance to when there are no visible options OR when the node condition fails. |
#### TalkOption fields
@@ -322,6 +324,32 @@ nodes:
If an option has both an `action` and a `goto`, the action runs first, then the player
navigates to the target node (which may also have its own action, fired on entry).
+#### Sequences
+
+`sequence` is a flat list of NPC messages shown one at a time. The player presses
+enter to advance through each message. After the last message, the node's options
+(if any) are displayed. This is the preferred way to deliver NPC monologues — no
+named nodes or `goto` chains needed:
+
+```yaml
+nodes:
+ sign_reminder:
+ sequence:
+ - "\"Yeah, just us two! Not a lot of people heading into the belt these days.\""
+ - "\"But we'll be landing shortly! Please look at the information sign.\""
+ options:
+ - text: "\"Okay, thanks.\""
+ - text: "\"Goodbye.\""
+```
+
+Each `[enter to continue]` prompt is automatic. The node's action fires once on
+entry, before the first sequence message. Node conditions still work normally —
+if the condition fails, the entire sequence is skipped via `goto`.
+
+If a sequence node has no options after the last message and `goto` is set, it
+auto-advances to the target node. Non-empty input during a sequence cancels the
+conversation (just like invalid input does during option selection).
+
#### Randomized messages
`message` accepts either a plain string or a list. A list picks one entry at random each
@@ -341,7 +369,7 @@ nodes:
#### Linear auto-advance
-When a node has no options (or all its options are hidden by conditions) and `next` is set,
+When a node has no options (or all its options are hidden by conditions) and `goto` is set,
the conversation automatically advances to the target node. This creates dialogue chains
without prompting the player:
@@ -349,13 +377,13 @@ without prompting the player:
nodes:
monologue_1:
message: "The elder clears his throat and begins..."
- next: monologue_2
+ goto: monologue_2
monologue_2:
message: "\"Long ago, before the asteroid was settled...\""
- next: monologue_3
+ goto: monologue_3
monologue_3:
message: "\"...a great darkness fell upon these halls.\" He pauses."
- next: choice_point
+ goto: choice_point
choice_point:
message: "\"Do you understand the weight of what I'm telling you?\""
options:
@@ -363,8 +391,49 @@ nodes:
- text: "\"Not really.\""
```
-If a node has both `next` and options, the options take priority — `next` is only used
-when no options are visible. A node with a shop action ignores `next` entirely.
+If a node has both `goto` and options, the options take priority — `goto` is only used
+when no options are visible. A node with a shop action ignores `goto` entirely.
+
+#### Conditional nodes
+
+A node can have its own `condition` that gates the entire node — message, action,
+and options. When the condition fails, the node is skipped and the conversation
+automatically advances to `goto` (if set). This lets you build branching first
+messages or entire mutually-exclusive conversation paths based on player flags,
+items, or any other condition:
+
+```yaml
+nodes:
+ start:
+ condition:
+ player_flag: finished_tutorial
+ not: true
+ message: "\"Welcome, newcomer! Need any help getting started?\""
+ goto: returning_player
+ options:
+ - text: "\"Yes, where do I go?\""
+ goto: directions
+ - text: "\"I'll figure it out.\""
+ returning_player:
+ message: "\"Ah, you're back! I heard you handled that raid beautifully.\""
+ options:
+ - text: "\"The loot was worth it.\""
+ - text: "\"Barely made it out alive.\""
+ directions:
+ message: "\"Head north through the gate and follow the road.\""
+ options:
+ - text: "\"Thanks!\""
+```
+
+When `finished_tutorial` is set, the `start` node's condition fails, so the player
+never sees "Welcome, newcomer" — it jumps straight to `returning_player`. When the
+flag is unset, the condition passes and the player sees the newcomer message and its
+options.
+
+Node conditions compose cleanly with option conditions. When the node condition
+passes, each option's own condition is then checked independently to determine
+visibility — exactly as option conditions always work. When the node condition
+fails, the node (and all its options) are skipped entirely.
#### Shop (buy/sell interface)
diff --git a/building_guide/conditions.md b/building_guide/conditions.md
index d58b35c..853518b 100644
--- a/building_guide/conditions.md
+++ b/building_guide/conditions.md
@@ -1,7 +1,8 @@
## Conditions Reference
-Conditions are used in talk options, exit gates, on-enter scripts, use_interactions
-checks, room descriptions, object descriptions, and trigger value matching.
+Conditions are used in talk option guards, talk node conditions, exit gates,
+on-enter scripts, use_interactions checks, room descriptions, object
+descriptions, and trigger value matching.
### Simple conditions
diff --git a/building_guide/mobs.md b/building_guide/mobs.md
index 32ee408..c7331da 100644
--- a/building_guide/mobs.md
+++ b/building_guide/mobs.md
@@ -95,17 +95,37 @@ idle_descriptions:
talk: # inline talk config
nodes:
start:
- message: "\"Welcome aboard!\""
+ condition: # only show this node when flag is NOT set
+ player_flag: 1001_look_sign
+ not: true
+ message: "\"How can I help you?\""
+ goto: thanks # auto-advance here if condition fails
options:
- - text: "\"What should I do?\""
- goto: check_sign
+ - text: "\"Oh, nothing...\""
+ goto: sign_reminder
- text: "\"Goodbye.\""
- check_sign:
- message: "\"Review the safety information.\""
+ thanks:
+ message: "\"Thank you for flying with us. Be careful on the stairs.\""
options:
- - text: "\"Will do.\""
+ - text: "\"Thanks!\""
+ sign_reminder:
+ sequence:
+ - "\"Yeah, just us two! Not a lot of people heading into the belt these days.\""
+ - "\"But we'll be landing shortly! Look at the information sign so you'll know what to do.\""
+ options:
+ - text: "\"Okay, I'll take a look.\""
```
+When the player has `1001_look_sign` set, the `start` node's condition fails, so
+it skips directly to `thanks` ("Thank you for flying..."). When the flag is unset,
+the player sees "How can I help you?" and can choose to be reminded about the sign
+or say goodbye. The `sign_reminder` node uses a `sequence` so the flight attendant
+delivers two lines, pausing for the player to press enter between them, before
+showing the "Okay" option.
+
+See the [Talk section of behaviors](behaviors.md#talk-dialog-trees) for the full
+dialog tree format, including sequences and conditional nodes.
+
Protected mob with combat stats (for internal testing or debug purposes):
```yaml
name: "Guard"
diff --git a/data/mobs/flight_attendant.yaml b/data/mobs/flight_attendant.yaml
index 81bc3bf..9cb3530 100644
--- a/data/mobs/flight_attendant.yaml
+++ b/data/mobs/flight_attendant.yaml
@@ -1,32 +1,27 @@
-name: Flight attendant
+name: flight attendant
description: A smiling flight attendant in a crisp uniform, ready to assist passengers.
unique: false
protected: true
idle_descriptions:
- - straightens a seat cushion
- - checks the cabin for loose items
- adjusts her name badge
- - smiles politely at passengers
+ - smiles politely at you
talk:
nodes:
start:
- message: '"Welcome aboard! Is there anything I can help you with?"'
+ condition:
+ player_flag: 1001_look_sign
+ not: true
+ message: '"Hi there, how can I help?"'
+ goto: thanks
options:
- - text: '"I''m ready to disembark."'
- goto: disembark
- condition:
- player_flag: 1001_look_sign
- - text: '"What should I do?"'
- goto: check_sign
- condition:
- player_flag: 1001_look_sign
- not: true
+ - text: '"Oh, nothing... Small crowd on this ship. Just looking for conversation."'
+ goto: sign_reminder
- text: '"Goodbye."'
- disembark:
- message: '"Of course! Please proceed down the stairs when you''re ready. Have a wonderful stay."'
+ thanks:
+ message: '"Thank for flying with Denali. Watch your step on the way out please."'
options:
- - text: '"Thanks!"'
- check_sign:
- message: '"Before we land, please review the safety information posted on the wall. Just type {11 bold}look sign{/} to see it."'
- options:
- - text: '"Okay, I''ll take a look."'
+ - text: '"Thank you."'
+ sign_reminder:
+ sequence:
+ - '"Yeah, just us two! Not a lot of people heading into the belt these days."'
+ - '"But we''ll be landing shortly! Please be sure {11 bold}look{/} at the information {11 bold}sign{/} so you''ll be all set for your final destination."'
diff --git a/data/objects/unique/1001_door.yaml b/data/objects/unique/1001_door.yaml
new file mode 100644
index 0000000..f9dbb20
--- /dev/null
+++ b/data/objects/unique/1001_door.yaml
@@ -0,0 +1,3 @@
+name: door
+hidden: true
+description: "A sturdy, locked steel door that keeps passengers out of the cockpit. There is a tiny, thin window at eye level."
diff --git a/data/objects/unique/1001_instruments.yaml b/data/objects/unique/1001_instruments.yaml
new file mode 100644
index 0000000..2d50c4f
--- /dev/null
+++ b/data/objects/unique/1001_instruments.yaml
@@ -0,0 +1,4 @@
+name: instrument panel
+aliases: [cockpit]
+hidden: true
+description: "The cramped cockpit is surrounded by buttons, swiches, dials, and analog gauges. The parts are a mix of technologies from the last century. Part of the panel is obscured by a draped cloth as if to hide it from view. Odd."
diff --git a/data/objects/unique/1001_pilot.yaml b/data/objects/unique/1001_pilot.yaml
new file mode 100644
index 0000000..fccb61f
--- /dev/null
+++ b/data/objects/unique/1001_pilot.yaml
@@ -0,0 +1,3 @@
+name: pilot
+hidden: true
+description: "That's the guy who took you from the Passenger Barge Denali down here to Vesta. A middle-aged man in a neat white jumpsuit with a matching white sort of conductor's hat. He's following a written procedure checklist and seems to take his job seriously."
diff --git a/data/objects/unique/1001_window.yaml b/data/objects/unique/1001_window.yaml
new file mode 100644
index 0000000..e8d76f8
--- /dev/null
+++ b/data/objects/unique/1001_window.yaml
@@ -0,0 +1,3 @@
+name: window
+hidden: true
+description: "A thick trim with rounded bolt heads frames the tiny window. It looks like it was cut into the door at some point, potentially as a simple replacement to computerized security systems. Inside the cockpit there's a pilot finishing up landing procedures on a large panel of analog instruments."
diff --git a/data/objects/unique/1002_building.yaml b/data/objects/unique/1002_building.yaml
new file mode 100644
index 0000000..f7650dc
--- /dev/null
+++ b/data/objects/unique/1002_building.yaml
@@ -0,0 +1,3 @@
+name: building
+aliases: [customs]
+description: "A squat, tunnel-shaped building with no doors that looks somewhat like a lowercase 'n'. It's built from large chunks of gray rock that must've come from the surface of Vesta."
diff --git a/data/objects/unique/1002_crowd.yaml b/data/objects/unique/1002_crowd.yaml
index 78fcbfa..5d0105d 100644
--- a/data/objects/unique/1002_crowd.yaml
+++ b/data/objects/unique/1002_crowd.yaml
@@ -1,17 +1,17 @@
name: crowd of people
-aliases: [people, crowd, group, passengers]
+aliases: [line, group, passengers]
hidden: true
description:
- condition:
all_of:
- - player_flag: denali_intro_done
+ - player_flag: 1002_intro_done
not: true
- - player_flag: denali_lined_up
- text: "The passengers have sorted themselves into a single-file line, tickets clutched in hand, shuffling toward the craft."
+ - player_flag: 1002_lined_up
+ text: "A mass of people still onto the crowded platform, blocking your exit!"
- condition:
all_of:
- - player_flag: denali_intro_done
+ - player_flag: 1002_intro_done
not: true
- - player_flag: denali_lined_up
+ - player_flag: 1002_lined_up
not: true
- text: "A couple dozen anxious passengers mill about the pad, glancing between you and the small building to the north."
+ text: "A dozen or so passengers ready to leave, all with luggage. Their eyes are a mix of impatience, anxiety, and perhaps suspicion."
diff --git a/data/objects/unique/1002_pilot.yaml b/data/objects/unique/1002_pilot.yaml
index 0127180..846cded 100644
--- a/data/objects/unique/1002_pilot.yaml
+++ b/data/objects/unique/1002_pilot.yaml
@@ -3,7 +3,7 @@ hidden: true
description:
- condition:
all_of:
- - player_flag: denali_intro_done
+ - player_flag: 1002_intro_done
not: true
- - player_flag: denali_lined_up
- text: "The shuttle pilot stands by the hatch in a faded flight suit, waving passengers into line and checking tickets with a practiced eye."
+ - player_flag: 1002_lined_up
+ text: "The pilot stands straight as if about to escort royalty looking down his nose a bit at the passengers. He is quickly and professionally checking tickets."
diff --git a/data/objects/unique/1002_shuttle.yaml b/data/objects/unique/1002_shuttle.yaml
index 29af09c..64375f5 100644
--- a/data/objects/unique/1002_shuttle.yaml
+++ b/data/objects/unique/1002_shuttle.yaml
@@ -3,6 +3,6 @@ aliases: [shuttle, ship]
hidden: true
description:
- condition:
- player_flag: denali_intro_done
+ player_flag: 1002_intro_done
not: true
- text: "The battered landing craft that carried you down still ticks and steams as it cools on the pad."
+ text: "The small white craft has short, cute wings, a wide snub-nosed cockpit, and the rear hatch opened into a staircase. The low hum of its power plant vibrates the platform as it idles. You might guess it's a 95 ton Type-QS Passenger Shuttle. Just a guess though."
diff --git a/data/objects/unique/1002_sign.yaml b/data/objects/unique/1002_sign.yaml
new file mode 100644
index 0000000..cdd3633
--- /dev/null
+++ b/data/objects/unique/1002_sign.yaml
@@ -0,0 +1,2 @@
+name: sign
+description: "The sign simply says 'Processing' in bold red letters. Beneath in smaller text it says 'Welcome to Vesta', seemingly added afterwards to try to give the sign some warmth."
diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml
index df3b2ab..c617996 100644
--- a/data/rooms/intro/1001.yaml
+++ b/data/rooms/intro/1001.yaml
@@ -7,6 +7,10 @@ description:
- text: "A dim, windowless shuttle interior with a few uncomfortable rows of steel seats facing forward. A large framed sign hangs next to the closed cockpit door. A piston-powered staircase leads down out the back of the small craft with a smiling attendant ready to help disembark."
objects:
- id: 1001_sign
+ - id: 1001_door
+ - id: 1001_window
+ - id: 1001_instruments
+ - id: 1001_pilot
mobs:
- flight_attendant
exits:
diff --git a/data/rooms/intro/1002.yaml b/data/rooms/intro/1002.yaml
index 6e45ed7..c3116e3 100644
--- a/data/rooms/intro/1002.yaml
+++ b/data/rooms/intro/1002.yaml
@@ -3,18 +3,20 @@ description:
- condition:
player_flag: 1002_intro_done
not: true
- text: "A large, circular, concrete landing pad sits in the middle of a blue ocean with a transport shuttle taking up nearly all of the walkable area. A crowd of perhaps a dozen people are shambling of a squat, doorless building to the north. A sign above the entryway reads \"Vesta Customs\""
- - text: "A large, circular, concrete landing pad sits empty in the middle of a blue ocean surrounded by black netting to catch anyone careless enough to fall off. Waves quietly lick the structural pylons as they roll towards the nearby beach. North is a short, wide concrete through a small, doorless red building with a wooden sign above the entryway reading \"Vesta Customs\""
+ text: "A large, circular, concrete landing pad sits in the middle of a blue ocean with a transport shuttle taking up nearly all of the walkable area. A crowd of perhaps a dozen people are shambling out of a squat, doorless building to the north. A sign above the entryway reads \"Processing\""
+ - text: "A large, circular, concrete landing pad sits empty in the middle of a blue ocean that stretches to the horizon. North is a concrete path through a squat, doorless building with sign above the entryway reading \"Processing\""
objects:
- id: 1002_crowd
- id: 1002_shuttle
- id: 1002_pilot
+ - id: 1002_building
+ - id: 1002_sign
exits:
south:
room: 1001
condition:
player_flag: always_false
- blocked_message: "You don't have a ticket!"
+ blocked_message: "You have no ticket, or reason, to leave Vesta."
north:
room: 1003
condition:
@@ -27,16 +29,16 @@ on_enter:
player_flag: 1002_lined_up
not: true
delay: 7
- message: "A few in the crowd openly look you up and down consideringly, as if you're heading the wrong direction."
+ message: "A young boy the crowd openly look you up and down a little bemused, as if you're heading the wrong direction."
- condition:
player_flag: 1002_lined_up
not: true
delay: 7
- message: "The pilot pops out of the hatch of the craft and calls out, \"Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!\""
+ message: "The pilot pops out of the hatch of the craft and calls out \"Shuttle to Passenger Barge Denali. Tickets out, please! Nice and orderly!\""
- condition:
player_flag: 1002_lined_up
not: true
delay: 7
- message: "The people shamble into a line, dragging their luggage out of the narrow path to clear the way for you."
+ message: "The people all shuffle into a line, dragging their luggage out of the path to clear the way for you."
set_player_flags:
1002_lined_up: true
diff --git a/internal/behavior/behavior.go b/internal/behavior/behavior.go
index f81c73c..e01d8a7 100644
--- a/internal/behavior/behavior.go
+++ b/internal/behavior/behavior.go
@@ -30,10 +30,12 @@ type TalkConfig struct {
}
type TalkNode struct {
- Message MessageList `yaml:"message"`
- Options []TalkOption `yaml:"options"`
- Action *NodeAction `yaml:"action"`
- Next string `yaml:"next,omitempty"`
+ Message MessageList `yaml:"message"`
+ Sequence []string `yaml:"sequence,omitempty"`
+ Condition *Condition `yaml:"condition,omitempty"`
+ Options []TalkOption `yaml:"options"`
+ Action *NodeAction `yaml:"action"`
+ Goto string `yaml:"goto,omitempty"`
}
type TalkOption struct {
diff --git a/internal/behavior/types.go b/internal/behavior/types.go
index f8e65f7..3d55d79 100644
--- a/internal/behavior/types.go
+++ b/internal/behavior/types.go
@@ -110,8 +110,14 @@ type StealData struct {
type TalkData struct {
Node string
Cfg *TalkConfig
+ SeqIndex int
EstateDirectory bool
StealGuard bool
+ PendingChoice string
+ PendingAction *NodeAction
+ CancelTalk bool
+ PendingSeq bool
+ PendingChosen bool
}
type UseData struct {
diff --git a/internal/game/act.go b/internal/game/act.go
index 8920d0c..31d8a33 100644
--- a/internal/game/act.go
+++ b/internal/game/act.go
@@ -230,6 +230,8 @@ func (g *Game) AdvanceActions() {
g.advanceCure(sess, p)
case "obstacle":
g.advanceObstacle(sess, p)
+ case behavior.TypeTalk:
+ g.advanceTalk(sess, p)
default:
if productionActionTypes[string(p.Action.Type)] {
g.advanceProduction(sess, p)
diff --git a/internal/game/act_agility.go b/internal/game/act_agility.go
index 4a68580..d66b9c5 100644
--- a/internal/game/act_agility.go
+++ b/internal/game/act_agility.go
@@ -46,7 +46,7 @@ func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *Obstacle
},
}
- g.broadcastAction(sess, "\n%s starts %s on the %s.", p.Name, gerund, info.CourseName)
+ g.broadcastAction(sess, "%s starts %s on the %s.", p.Name, gerund, info.CourseName)
}
func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) {
diff --git a/internal/game/act_burn.go b/internal/game/act_burn.go
index 861ff93..7420dff 100644
--- a/internal/game/act_burn.go
+++ b/internal/game/act_burn.go
@@ -118,7 +118,7 @@ func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fro
phase = 0
}
- g.broadcastAction(sess, "\n%s starts building a fire.", p.Name)
+ g.broadcastAction(sess, "%s starts building a fire.", p.Name)
p.Action = &behavior.Action{
Type: behavior.TypeBurn,
@@ -156,7 +156,7 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) {
sess.WriteLine("You begin to tend to the fire.")
- g.broadcastAction(sess, "\n%s tends to the fire.", p.Name)
+ g.broadcastAction(sess, "%s tends to the fire.", p.Name)
p.Action = &behavior.Action{
Type: behavior.TypeStoke,
@@ -243,7 +243,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) {
}
sess.WriteLine(msg)
- g.broadcastAction(sess, "\n%s started a fire!", p.Name)
+ g.broadcastAction(sess, "%s started a fire!", p.Name)
return
}
diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go
index a4d96b4..6060dca 100644
--- a/internal/game/act_farm.go
+++ b/internal/game/act_farm.go
@@ -113,8 +113,8 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
if diseased {
g.setPlayerFlag(p, prefix+"_dead", true)
g.setPlayerFlag(p, prefix+"_diseased", false)
- sess.WriteLine(g.colorize(sess, "farm_disease",
- fmt.Sprintf("\nYour %s has died from disease!", g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_disease",
+ fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID))))
g.AccountStore.SaveCharacter(p)
continue
}
@@ -138,23 +138,23 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_ready", true)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_grow",
- fmt.Sprintf("\nYour %s is fully grown and ready to harvest!",
- g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_grow",
+ fmt.Sprintf("Your %s is fully grown and ready to harvest!",
+ g.seedDisplayName(sess, seedID))))
} else {
if !watered && rand.Float64() < 0.10 {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_diseased", true)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_disease",
- fmt.Sprintf("\nYour %s has become diseased!",
- g.seedDisplayName(sess, seedID))))
+ sess.WriteLine(g.colorize(sess, "farm_disease",
+ fmt.Sprintf("Your %s has become diseased!",
+ g.seedDisplayName(sess, seedID))))
} else {
g.setPlayerFlag(p, prefix+"_stage", stage)
g.setPlayerFlag(p, prefix+"_watered", false)
- sess.WriteLine(g.colorize(sess, "farm_grow",
- fmt.Sprintf("\nYour %s has grown to stage %d/%d.",
- g.seedDisplayName(sess, seedID), stage, maxStages)))
+ sess.WriteLine(g.colorize(sess, "farm_grow",
+ fmt.Sprintf("Your %s has grown to stage %d/%d.",
+ g.seedDisplayName(sess, seedID), stage, maxStages)))
}
}
g.AccountStore.SaveCharacter(p)
@@ -395,22 +395,22 @@ func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) st
weeds, _ := p.Flags[prefix+"_weeds"].(bool)
if weeds {
- return "\nIt is overgrown with weeds."
+ return "It is overgrown with weeds."
}
seedID, _ := p.Flags[prefix+"_seed"].(string)
if seedID == "" {
- return "\nThe patch is empty and ready for planting."
+ return "The patch is empty and ready for planting."
}
dead, _ := p.Flags[prefix+"_dead"].(bool)
if dead {
- return "\nThe plant has died. You need to rake it clean."
+ return "The plant has died. You need to rake it clean."
}
diseased, _ := p.Flags[prefix+"_diseased"].(bool)
if diseased {
- return "\nThe plant looks diseased! Use plant cure to save it."
+ return "The plant looks diseased! Use plant cure to save it."
}
ready, _ := p.Flags[prefix+"_ready"].(bool)
@@ -419,7 +419,7 @@ func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) st
if def, err := g.ItemStore.Load(seedID); err == nil {
name = def.Name
}
- return fmt.Sprintf("\nA fully grown %s is ready to harvest!", name)
+ return fmt.Sprintf("A fully grown %s is ready to harvest!", name)
}
stage := intFromFlag(p.Flags, prefix+"_stage")
@@ -432,7 +432,7 @@ func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) st
name = def.Name
}
watered, _ := p.Flags[prefix+"_watered"].(bool)
- suffix := fmt.Sprintf("\nA %s is growing (stage %d/%d).", name, stage, maxStages)
+ suffix := fmt.Sprintf("A %s is growing (stage %d/%d).", name, stage, maxStages)
if watered {
suffix += " It has been watered."
}
@@ -451,9 +451,9 @@ func (g *Game) toolShedLookSuffix(p *player.Player) string {
stored = append(stored, "a watering can")
}
if len(stored) == 0 {
- return "\nThe shed is empty."
+ return "The shed is empty."
}
- return fmt.Sprintf("\nInside: %s.", strings.Join(stored, ", "))
+ return fmt.Sprintf("Inside: %s.", strings.Join(stored, ", "))
}
func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, defID string, index int) {
diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go
index 0c743e7..826b901 100644
--- a/internal/game/act_gather.go
+++ b/internal/game/act_gather.go
@@ -94,7 +94,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje
verb = "chopping"
}
- g.broadcastAction(sess, "\n%s starts %s the %s.", p.Name, verb, obj.Name)
+ g.broadcastAction(sess, "%s starts %s the %s.", p.Name, verb, obj.Name)
d := &behavior.GatherData{
ObjDefID: obj.ID,
@@ -259,7 +259,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
continue
}
if gd, ok := op.Action.Data.(*behavior.GatherData); ok && gd.InstanceKey == instanceKey {
- other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name)))
+ other.WriteLine(fmt.Sprintf("The %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name)))
g.cancelAction(op)
g.writePrompt(other)
}
@@ -330,10 +330,10 @@ func (g *Game) depleteSharedTree(st *world.ObjState, cfg *behavior.GatherConfig,
}
}
if isDepleter {
- sess.WriteLine(fmt.Sprintf("\n%s", msg))
+ sess.WriteLine(msg)
continue
}
- sess.WriteLine(fmt.Sprintf("\n%s", msg))
+ sess.WriteLine(msg)
g.cancelAction(op)
g.writePrompt(sess)
}
diff --git a/internal/game/act_room.go b/internal/game/act_room.go
index ff7a780..ddca261 100644
--- a/internal/game/act_room.go
+++ b/internal/game/act_room.go
@@ -18,7 +18,6 @@ type enterSeq struct {
roomID int
steps []world.EnterStep
wait int
- started bool
}
// runEnterSteps evaluates a room's on_enter script for the player. Steps whose
@@ -144,18 +143,13 @@ func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
if step.Message != "" {
msg := expandTemplate(step.Message, p.Name, nil)
msg = color.ExpandTagsDefault(mode, seqSpec, msg)
- if !seq.started {
- seq.sess.WriteLine("\n" + msg)
- seq.started = true
- } else {
- seq.sess.WriteLine(msg)
- }
+ seq.sess.WriteLine(msg)
}
if step.Broadcast != "" && g.Hub != nil {
msg := expandTemplate(step.Broadcast, p.Name, nil)
msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
for _, other := range g.Hub.PlayersInRoom(seq.roomID) {
- other.WriteLine(g.colorize(other, "broadcast", "\n"+msg))
+ other.WriteLine(g.colorize(other, "broadcast", msg))
}
}
if step.BroadcastGlobal != "" && g.Hub != nil {
@@ -163,7 +157,7 @@ func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
msg = color.ExpandTagsDefault(mode, broadcastSpec, msg)
for _, other := range g.Hub.AllSessions() {
if other.Player != nil {
- other.WriteLine(g.colorize(other, "broadcast", "\n"+msg))
+ other.WriteLine(g.colorize(other, "broadcast", msg))
}
}
}
@@ -264,7 +258,7 @@ func (g *Game) BroadcastRespawns() {
msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name)
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(st.RoomID) {
- sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("\n%s", msg)))
+ sess.WriteLine(g.colorize(sess, "broadcast", msg))
}
}
}
diff --git a/internal/game/act_search.go b/internal/game/act_search.go
index 6af15ca..6f5fa65 100644
--- a/internal/game/act_search.go
+++ b/internal/game/act_search.go
@@ -33,7 +33,7 @@ func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, s
},
}
- g.broadcastAction(sess, "\n%s searches a %s.", p.Name, def.Name)
+ g.broadcastAction(sess, "%s searches a %s.", p.Name, def.Name)
}
func (g *Game) advanceSearch(sess *net.Session, p *player.Player) {
diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go
index 39df9e2..1c76474 100644
--- a/internal/game/act_steal.go
+++ b/internal/game/act_steal.go
@@ -261,7 +261,7 @@ func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string
sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName))
- g.broadcastAction(sess, "\n%s glances around the room.", p.Name)
+ g.broadcastAction(sess, "%s glances around the room.", p.Name)
p.Action = &behavior.Action{
Type: behavior.TypeSteal,
diff --git a/internal/game/act_talk.go b/internal/game/act_talk.go
index 14361f8..c87e3c5 100644
--- a/internal/game/act_talk.go
+++ b/internal/game/act_talk.go
@@ -87,51 +87,63 @@ func (g *Game) handleNodeAction(sess *net.Session, na *behavior.NodeAction) (int
func (g *Game) showTalkNode(sess *net.Session, node behavior.TalkNode) {
p := sess.Player
td, _ := p.Action.Data.(*behavior.TalkData)
-
+ td.CancelTalk = false
+ td.PendingSeq = false
+ td.PendingChoice = ""
+ td.PendingAction = nil
+ td.PendingChosen = false
dialogSpec := g.resolveColor(sess, "dialog")
- msg := node.Message.Pick()
- if msg != "" {
- sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
- }
-
- if node.Action != nil {
- if g.handleNodeAction(sess, node.Action) {
- return
- }
- }
-
cfg := td.Cfg
+
for {
- visible := g.visibleOptions(sess, node.Options)
- if len(visible) > 0 {
- for i, opt := range visible {
- sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
+ if node.Condition == nil || g.checkCondition(sess, node.Condition) {
+ if len(node.Sequence) > 0 {
+ if node.Action != nil {
+ if g.handleNodeAction(sess, node.Action) {
+ return
+ }
+ }
+ td.SeqIndex = 0
+ msg := node.Sequence[0]
+ if msg != "" {
+ sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ }
+ td.SeqIndex = 1
+ if td.SeqIndex < len(node.Sequence) {
+ sess.State = net.StateTalkSequence
+ sess.Write("[enter to continue]\n")
+ return
+ }
+ } else {
+ msg := node.Message.Pick()
+ if msg != "" {
+ sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ }
+ if node.Action != nil {
+ if g.handleNodeAction(sess, node.Action) {
+ return
+ }
+ }
+ }
+ visible := g.visibleOptions(sess, node.Options)
+ if len(visible) > 0 {
+ for i, opt := range visible {
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
+ }
+ sess.State = net.StateTalk
+ g.reprompt(sess)
+ return
}
- sess.State = net.StateTalk
- sess.Write("\nChoice: ")
- return
}
-
- if node.Next != "" && cfg != nil {
- next, ok := cfg.Nodes[node.Next]
+ if node.Goto != "" && cfg != nil {
+ next, ok := cfg.Nodes[node.Goto]
if !ok {
break
}
if td != nil {
- td.Node = node.Next
+ td.Node = node.Goto
}
node = next
-
- msg := node.Message.Pick()
- if msg != "" {
- sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
- }
-
- if node.Action != nil {
- if g.handleNodeAction(sess, node.Action) {
- return
- }
- }
} else {
break
}
@@ -152,6 +164,31 @@ func (g *Game) visibleOptions(sess *net.Session, options []behavior.TalkOption)
return visible
}
+func (g *Game) finishSequence(sess *net.Session, node behavior.TalkNode) {
+ visible := g.visibleOptions(sess, node.Options)
+ if len(visible) > 0 {
+ for i, opt := range visible {
+ sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
+ }
+ sess.State = net.StateTalk
+ g.reprompt(sess)
+ return
+ }
+
+ td, _ := sess.Player.Action.Data.(*behavior.TalkData)
+ if node.Goto != "" && td != nil && td.Cfg != nil {
+ nextNode, ok := td.Cfg.Nodes[node.Goto]
+ if ok {
+ td.Node = node.Goto
+ g.showTalkNode(sess, nextNode)
+ return
+ }
+ }
+
+ g.cancelAction(sess.Player)
+ sess.State = net.StateGame
+}
+
func (g *Game) handleTalkInput(sess *net.Session, input string) {
p := sess.Player
if p == nil || p.Action == nil || p.Action.Type != behavior.TypeTalk {
@@ -160,46 +197,57 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
return
}
- input = strings.TrimSpace(input)
- if input == "" {
- input = "1"
+ if p.Action.WaitLeft > 0 {
+ return
}
- lower := strings.ToLower(input)
- if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" {
+
+ td, ok := p.Action.Data.(*behavior.TalkData)
+ if !ok || td.Cfg == nil {
g.cancelAction(p)
sess.State = net.StateGame
g.writePrompt(sess)
return
}
- if td, ok := p.Action.Data.(*behavior.TalkData); ok && td.EstateDirectory {
- g.handleEstateDirectoryTalk(sess, input)
+ input = strings.TrimSpace(input)
+ if input == "" && sess.State != net.StateTalkSequence {
+ input = "1"
+ }
+
+ if sess.State == net.StateTalkSequence {
+ _, ok := td.Cfg.Nodes[td.Node]
+ if !ok {
+ td.CancelTalk = true
+ p.Action.WaitLeft = 1
+ return
+ }
+ if input == "" {
+ td.PendingSeq = true
+ } else {
+ td.CancelTalk = true
+ }
+ p.Action.WaitLeft = 1
return
}
- td, ok := p.Action.Data.(*behavior.TalkData)
- if !ok || td.Cfg == nil {
- g.cancelAction(p)
- sess.State = net.StateGame
- g.writePrompt(sess)
+ if td.EstateDirectory {
+ g.handleEstateDirectoryTalk(sess, input)
return
}
- cfg := td.Cfg
+ cfg := td.Cfg
nodeKey := td.Node
node, ok := cfg.Nodes[nodeKey]
if !ok {
- g.cancelAction(p)
- sess.State = net.StateGame
- g.writePrompt(sess)
+ td.CancelTalk = true
+ p.Action.WaitLeft = 1
return
}
idx, err := parseChoiceIndex(input)
if err != nil || idx <= 0 {
- g.cancelAction(p)
- sess.State = net.StateGame
- g.writePrompt(sess)
+ td.CancelTalk = true
+ p.Action.WaitLeft = 1
return
}
@@ -218,29 +266,79 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) {
}
if chosen == nil {
+ td.CancelTalk = true
+ p.Action.WaitLeft = 1
+ return
+ }
+
+ td.PendingChoice = chosen.Goto
+ td.PendingAction = chosen.Action
+ td.PendingChosen = true
+ p.Action.WaitLeft = 1
+}
+
+func (g *Game) advanceTalk(sess *net.Session, p *player.Player) {
+ td, ok := p.Action.Data.(*behavior.TalkData)
+ if !ok || td.Cfg == nil {
g.cancelAction(p)
sess.State = net.StateGame
- g.writePrompt(sess)
return
}
- if chosen.Action != nil {
- if g.handleNodeAction(sess, chosen.Action) {
- return
- }
+ if td.CancelTalk {
+ sess.WriteLine("You leave the conversation.")
+ g.cancelAction(p)
+ sess.State = net.StateGame
+ return
}
- if chosen.Goto != "" {
- if nextNode, ok := cfg.Nodes[chosen.Goto]; ok {
- td.Node = chosen.Goto
- g.showTalkNode(sess, nextNode)
+ if td.PendingSeq {
+ td.PendingSeq = false
+ node, ok := td.Cfg.Nodes[td.Node]
+ if !ok {
+ g.cancelAction(p)
+ sess.State = net.StateGame
+ return
+ }
+ dialogSpec := g.resolveColor(sess, "dialog")
+ if td.SeqIndex < len(node.Sequence) {
+ msg := node.Sequence[td.SeqIndex]
+ if msg != "" {
+ sess.WriteLine(color.ExpandTagsDefault(g.colorMode(sess), dialogSpec, msg))
+ }
+ td.SeqIndex++
+ }
+ if td.SeqIndex >= len(node.Sequence) {
+ g.finishSequence(sess, node)
return
}
+ sess.State = net.StateTalkSequence
+ sess.Write("[enter to continue]\n")
+ return
}
- g.cancelAction(p)
- sess.State = net.StateGame
- g.writePrompt(sess)
+ if td.PendingChosen {
+ td.PendingChosen = false
+ choice := td.PendingChoice
+ action := td.PendingAction
+ td.PendingChoice = ""
+ td.PendingAction = nil
+ if action != nil {
+ if g.handleNodeAction(sess, action) {
+ return
+ }
+ }
+ if choice != "" {
+ if nextNode, ok := td.Cfg.Nodes[choice]; ok {
+ td.Node = choice
+ g.showTalkNode(sess, nextNode)
+ return
+ }
+ }
+ g.cancelAction(p)
+ sess.State = net.StateGame
+ return
+ }
}
func (g *Game) enterShop(sess *net.Session, cfg *behavior.ShopConfig) {
diff --git a/internal/game/act_use.go b/internal/game/act_use.go
index 34f28c3..0d8582d 100644
--- a/internal/game/act_use.go
+++ b/internal/game/act_use.go
@@ -34,7 +34,7 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD
return
}
- g.broadcastAction(sess, "\n%s uses the %s.", p.Name, obj.Name)
+ g.broadcastAction(sess, "%s uses the %s.", p.Name, obj.Name)
p.Action = &behavior.Action{
Type: "use",
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 5635fd0..d60bb05 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -52,7 +52,7 @@ func (g *Game) respawnMob(instanceID string) {
if p := sess.Player; p != nil && p.OptionBool("mob_spawn") {
mobLvl := mobCombatLevel(inst)
levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob", mobDisplayName(inst, false)), levelStr))
+ sess.WriteLine(fmt.Sprintf("%s %s spawns in the area.", g.colorize(sess, "mob", mobDisplayName(inst, false)), levelStr))
}
}
}
diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go
index 8d96c5f..c489ad8 100644
--- a/internal/game/cmd_bank.go
+++ b/internal/game/cmd_bank.go
@@ -58,7 +58,7 @@ func (g *Game) handleBankInput(sess *net.Session, input string) {
}
func (g *Game) writeBankPrompt(sess *net.Session) {
- sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "Bank> ")))
+ sess.WritePrompt(g.colorize(sess, "dialog", "Bank> "))
}
func (g *Game) showBankBrowse(sess *net.Session) {
@@ -93,7 +93,7 @@ func (g *Game) showBankBrowse(sess *net.Session) {
}
if len(entries) == 0 {
- sess.WriteLine("\nYour bank is empty.")
+ sess.WriteLine("Your bank is empty.")
return
}
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
index 666a622..33b406d 100644
--- a/internal/game/cmd_color.go
+++ b/internal/game/cmd_color.go
@@ -80,7 +80,7 @@ func (g *Game) saveAccountColor(sess *net.Session, target, value string) {
acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
if err != nil {
- sess.WriteLine("\nError loading account.")
+ sess.WriteLine("Error loading account.")
return
}
if value == "" {
diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go
index 133bf57..423cddb 100644
--- a/internal/game/cmd_consume.go
+++ b/internal/game/cmd_consume.go
@@ -279,5 +279,5 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d
p.ConsumeCooldown = 3
- g.broadcastAction(sess, "\n%s drinks a %s.", p.Name, def.Name)
+ g.broadcastAction(sess, "%s drinks a %s.", p.Name, def.Name)
}
diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go
index 5a4423b..b28e634 100644
--- a/internal/game/cmd_estate_directory.go
+++ b/internal/game/cmd_estate_directory.go
@@ -191,7 +191,7 @@ func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) {
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s disappears in a shimmer of light.", p.Name))
+ other.WriteLine(fmt.Sprintf("%s disappears in a shimmer of light.", p.Name))
}
}
}
diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go
index b601e49..87d3d81 100644
--- a/internal/game/cmd_farm.go
+++ b/internal/game/cmd_farm.go
@@ -446,7 +446,7 @@ func (g *Game) doInspect(sess *net.Session, input string) {
indexStr = fmt.Sprintf(" %d", fp.index+1)
}
- sess.WriteLine(fmt.Sprintf("\n=== %s%s ===", patchName, indexStr))
+ sess.WriteLine(fmt.Sprintf("=== %s%s ===", patchName, indexStr))
weeds, _ := p.Flags[fp.prefix+"_weeds"].(bool)
if weeds {
diff --git a/internal/game/cmd_global.go b/internal/game/cmd_global.go
index d06a233..32577c3 100644
--- a/internal/game/cmd_global.go
+++ b/internal/game/cmd_global.go
@@ -28,7 +28,7 @@ func (g *Game) doGlobal(sess *net.Session, msg string) {
if other.Player == nil {
continue
}
- other.WriteLine(fmt.Sprintf("\n%s%s: %s",
+ other.WriteLine(fmt.Sprintf("%s%s: %s",
g.colorize(other, "global", "[Global] "),
g.colorize(other, "player_name", p.Name),
g.colorize(other, "global", msg)))
diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go
index 5024642..b7b52ed 100644
--- a/internal/game/cmd_inventory.go
+++ b/internal/game/cmd_inventory.go
@@ -12,7 +12,6 @@ func (g *Game) executeInventory(sess *net.Session, args []string, rawInput strin
func (g *Game) doInventory(sess *net.Session) {
p := sess.Player
- sess.WriteLine("")
sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots()))
num := 0
diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go
index a0f102e..efdbba5 100644
--- a/internal/game/cmd_jack.go
+++ b/internal/game/cmd_jack.go
@@ -66,7 +66,7 @@ func (g *Game) doJack(sess *net.Session, target string) {
sess.State = net.StateHacking
- g.broadcastAction(sess, "\n%s jacks into a terminal.", p.Name)
+ g.broadcastAction(sess, "%s jacks into a terminal.", p.Name)
sess.WriteLine(display)
sess.Write("\nhack> ")
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 2939c34..a1b492e 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -164,13 +164,13 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(oldRoom) {
if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir))
+ other.WriteLine(fmt.Sprintf("%s leaves to the %s.", p.Name, exitDir))
}
}
g.Hub.EnterRoom(sess, targetID)
for _, other := range g.Hub.PlayersInRoom(targetID) {
if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name))
+ other.WriteLine(fmt.Sprintf("%s arrives.", p.Name))
}
}
}
diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go
index 05a6ece..d8ef0a4 100644
--- a/internal/game/cmd_option.go
+++ b/internal/game/cmd_option.go
@@ -84,7 +84,7 @@ func (g *Game) doOption(sess *net.Session, input string) {
acc.Options[def.Name] = parsed
g.AccountStore.SaveAccount(acc)
}
- sess.WriteLine(fmt.Sprintf("\n%s set to %s.", def.Name, formatOptionValue(p, def)))
+ sess.WriteLine(fmt.Sprintf("%s set to %s.", def.Name, formatOptionValue(p, def)))
}
func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) {
diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go
index dcda825..549c5fb 100644
--- a/internal/game/cmd_queued.go
+++ b/internal/game/cmd_queued.go
@@ -61,6 +61,6 @@ func (g *Game) doQueued(sess *net.Session) {
}
}
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)))
+ sess.WriteLine(fmt.Sprintf(" 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 2740275..60b1495 100644
--- a/internal/game/cmd_quit.go
+++ b/internal/game/cmd_quit.go
@@ -20,7 +20,7 @@ func (g *Game) doQuit(sess *net.Session) {
g.cancelAction(p)
g.cancelRest(p.Name)
- g.broadcastAction(sess, "\n%s sits down to rest.", p.Name)
+ g.broadcastAction(sess, "%s sits down to rest.", p.Name)
ticksLeft := 10
id := g.Ticks.Subscribe(engine.ToTicks(1), func() bool {
diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go
index f21ed88..b7d3982 100644
--- a/internal/game/cmd_say.go
+++ b/internal/game/cmd_say.go
@@ -28,7 +28,7 @@ func (g *Game) doSay(sess *net.Session, msg string) {
if other == sess {
other.WriteLine(fmt.Sprintf("You say: %s", g.colorize(sess, "say", msg)))
} else {
- other.WriteLine(fmt.Sprintf("\n%s says: %s", g.colorize(other, "player_name", p.Name), g.colorize(other, "say", msg)))
+ other.WriteLine(fmt.Sprintf("%s says: %s", g.colorize(other, "player_name", p.Name), g.colorize(other, "say", msg)))
}
}
}
diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go
index d1b0e9c..91a11c6 100644
--- a/internal/game/cmd_shop.go
+++ b/internal/game/cmd_shop.go
@@ -88,7 +88,7 @@ func (g *Game) cmdShopSell(sess *net.Session, args []string) {
}
func (g *Game) writeShopPrompt(sess *net.Session) {
- sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "> ")))
+ sess.WritePrompt(g.colorize(sess, "dialog", "> "))
}
func (g *Game) shopConfig(sess *net.Session) *behavior.ShopConfig {
@@ -417,16 +417,16 @@ func (g *Game) leaveShop(sess *net.Session) {
sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text))
}
sess.State = net.StateTalk
- sess.Write("\nChoice: ")
+ g.reprompt(sess)
return
}
- if node.Next != "" {
- next, ok := td.Cfg.Nodes[node.Next]
+ if node.Goto != "" {
+ next, ok := td.Cfg.Nodes[node.Goto]
if !ok {
break
}
- td.Node = node.Next
+ td.Node = node.Goto
node = next
} else {
break
diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go
index cb4c9f7..19b5425 100644
--- a/internal/game/cmd_tech.go
+++ b/internal/game/cmd_tech.go
@@ -157,7 +157,7 @@ func (g *Game) doTechList(sess *net.Session) {
if p.QuickTech != "" {
qDef := GetTechDef(p.QuickTech)
if qDef != nil {
- sess.WriteLine(fmt.Sprintf("\nQuick tech: %s", qDef.Name))
+ sess.WriteLine(fmt.Sprintf("Quick tech: %s", qDef.Name))
}
}
}
diff --git a/internal/game/cmd_trigger_transport.go b/internal/game/cmd_trigger_transport.go
index 4272a07..ba86ebe 100644
--- a/internal/game/cmd_trigger_transport.go
+++ b/internal/game/cmd_trigger_transport.go
@@ -22,7 +22,7 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess {
- other.WriteLine(fmt.Sprintf("\n%s teleports away.", p.Name))
+ other.WriteLine(fmt.Sprintf("%s teleports away.", p.Name))
}
}
}
diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go
index 64f35c0..d399e8f 100644
--- a/internal/game/cmd_use.go
+++ b/internal/game/cmd_use.go
@@ -103,7 +103,7 @@ func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string)
if ui.Action != nil {
g.applyNodeAction(sess, ui.Action)
}
- g.broadcastAction(sess, "\n%s uses the %s.", p.Name, def.Name)
+ g.broadcastAction(sess, "%s uses the %s.", p.Name, def.Name)
return true
}
if len(def.UseInteractions) > 0 {
diff --git a/internal/game/cmd_who.go b/internal/game/cmd_who.go
index 87c8a9c..f3c7092 100644
--- a/internal/game/cmd_who.go
+++ b/internal/game/cmd_who.go
@@ -23,7 +23,7 @@ func (g *Game) executeWho(sess *net.Session, args []string, rawInput string) {
return strings.ToLower(players[i].Name) < strings.ToLower(players[j].Name)
})
- sess.WriteLine(fmt.Sprintf("\nPlayers online (%d):", len(players)))
+ sess.WriteLine(fmt.Sprintf("Players online (%d):", len(players)))
for _, p := range players {
lvl := p.CombatLevel()
levelStr := g.levelColorize(sess, myP.CombatLevel(), lvl, fmt.Sprintf("(level %d)", lvl))
diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go
index 61eb9c1..36a496b 100644
--- a/internal/game/combat_attack.go
+++ b/internal/game/combat_attack.go
@@ -182,9 +182,9 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
}
}
if isTask {
- g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true))
+ g.broadcastAction(sess, "%s begins working on %s.", p.Name, mobDisplayName(mob, true))
} else {
- g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true))
+ g.broadcastAction(sess, "%s attacks %s!", p.Name, mobDisplayName(mob, true))
}
g.Ticks.Subscribe(1, func() bool {
@@ -208,13 +208,13 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if mod != nil && g.hasJunkCost(p, mod) {
g.scienceAttack(sess, p, currentMob, mod)
} else if mod != nil {
- sess.WriteLine(g.colorize(sess, "error",
- fmt.Sprintf("\nYou're out of junk for %s!! You flail with your fists in desperation!", mod.Name)))
+ sess.WriteLine(g.colorize(sess, "error",
+ fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name)))
g.playerAttackUnarmed(sess, p, currentMob)
} else {
p.AutotriggerMod = ""
- sess.WriteLine(g.colorize(sess, "error",
- "\nNo autotrigger set! Set a module with the 'autotrigger' command."))
+ sess.WriteLine(g.colorize(sess, "error",
+ "No autotrigger set! Set a module with the 'autotrigger' command."))
g.playerAttackUnarmed(sess, p, currentMob)
}
} else {
diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go
index 5558578..2abaf81 100644
--- a/internal/game/combat_mob.go
+++ b/internal/game/combat_mob.go
@@ -142,9 +142,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if complete == "" {
complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true))
}
- sess.WriteLine(g.colorize(sess, "victory", "\n"+complete))
- } else {
- sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
+ sess.WriteLine(g.colorize(sess, "victory", complete))
+ } else {
+ sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("You have defeated %s!", mobDisplayName(mob, true))))
g.onAssassinKill(sess, p, mob)
}
@@ -152,12 +152,12 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess && other.Player != nil {
if isTask {
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false))))
} else {
op := other.Player
mobLvl := mobCombatLevel(mob)
levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
- other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr)))
}
}
}
@@ -242,14 +242,14 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns
if mob.HP < 0 {
mob.HP = 0
}
- sess.WriteLine(fmt.Sprintf("\nDead Man's Switch activates! %s takes %d damage!",
- mobDisplayName(mob, true), retDmg))
+ sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!",
+ mobDisplayName(mob, true), retDmg))
}
}
}
p.DeactivateAllTechs()
p.Stats.RecordDeath()
- sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!"))
+ sess.WriteLine(g.colorize(sess, "death", "Oh dear, you are dead!"))
p.ClearMoveState()
p.HazardTimer = 0
if ss, ok := g.safespot.Get(p.Name); ok {
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index 5724c7b..d705684 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -122,7 +122,7 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
}
}
- sess.Write("\r\n> ")
+ sess.WritePrompt("> ")
}
func (g *Game) handleRenameChar(sess *net.Session, input string) {
diff --git a/internal/game/game.go b/internal/game/game.go
index acd2924..3cfd811 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -153,7 +153,7 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleGameCommand(sess, input)
case net.StateChangeDesc:
g.handleDescChange(sess, input)
- case net.StateTalk:
+ case net.StateTalk, net.StateTalkSequence:
g.handleTalkInput(sess, input)
case net.StateShop:
g.handleShopInput(sess, input)
@@ -177,11 +177,11 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
}
func (g *Game) writePrompt(sess *net.Session) {
- sess.Write("\r\n" + g.promptStr(sess))
+ sess.WritePrompt(g.promptStr(sess))
}
func (g *Game) reprompt(sess *net.Session) {
- sess.Write(g.promptStr(sess))
+ sess.Reprompt(g.promptStr(sess))
}
func (g *Game) handleGameCommand(sess *net.Session, input string) {
diff --git a/internal/game/look_entities.go b/internal/game/look_entities.go
index 915317f..36cff07 100644
--- a/internal/game/look_entities.go
+++ b/internal/game/look_entities.go
@@ -334,7 +334,7 @@ func (g *Game) showRoomPlayers(sess *net.Session, p *player.Player, room *world.
for _, other := range others {
if other != sess && other.Player != nil {
op := other.Player
- line := fmt.Sprintf("\n%s is here", g.colorize(sess, "player_name", op.Name))
+ line := fmt.Sprintf("%s is here", g.colorize(sess, "player_name", op.Name))
if cs := g.Combat.Get(op.Name); cs != nil {
if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 {
name := mobDisplayName(mob, false)
diff --git a/internal/game/render_help.go b/internal/game/render_help.go
index 1727153..869d306 100644
--- a/internal/game/render_help.go
+++ b/internal/game/render_help.go
@@ -133,7 +133,7 @@ func (g *Game) doHelp(sess *net.Session, topic string) {
topic = strings.ToLower(topic)
for _, h := range helps {
if strings.ToLower(h.Name) == topic {
- sess.WriteLine(fmt.Sprintf("\n %s", h.Content))
+ sess.WriteLine(fmt.Sprintf(" %s", h.Content))
return
}
}
diff --git a/internal/game/sys_assassin.go b/internal/game/sys_assassin.go
index 720529c..65ddb7c 100644
--- a/internal/game/sys_assassin.go
+++ b/internal/game/sys_assassin.go
@@ -93,7 +93,7 @@ func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.Mo
rep += bonus
currentRep := getPlayerFlagInt(p, "assassin_reputation")
g.setPlayerFlag(p, "assassin_reputation", currentRep+rep)
- sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("\n*** Assassin task complete! ***")))
+ sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf("*** Assassin task complete! ***")))
if bonus > 0 {
sess.WriteLine(g.colorize(sess, "assassin_task", fmt.Sprintf(" Streak bonus! %d tasks in a row. +%d bonus reputation.", streak, bonus)))
}
diff --git a/internal/game/sys_buff.go b/internal/game/sys_buff.go
index 9449731..3b8555c 100644
--- a/internal/game/sys_buff.go
+++ b/internal/game/sys_buff.go
@@ -28,7 +28,7 @@ func (g *Game) BuffTick() {
}
if dirty {
p.ActiveBuffs = remaining
- sess.WriteLine(g.colorize(sess, "broadcast", "\nYour potion buff has worn off."))
+ sess.WriteLine(g.colorize(sess, "broadcast", "Your potion buff has worn off."))
}
}
}
diff --git a/internal/game/sys_combat.go b/internal/game/sys_combat.go
index e020ccf..19dbb98 100644
--- a/internal/game/sys_combat.go
+++ b/internal/game/sys_combat.go
@@ -107,7 +107,7 @@ func (g *Game) checkAggro(sess *net.Session) {
if !aggMob.Unique {
attacker = "The " + aggMob.Name
}
- sess.WriteLine(fmt.Sprintf("\n%s attacks you!", g.colorize(sess, "mob", attacker)))
+ sess.WriteLine(fmt.Sprintf("%s attacks you!", g.colorize(sess, "mob", attacker)))
g.startCombat(sess, p, aggMob)
return false
})
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
index 29bf65e..c3b52b7 100644
--- a/internal/game/sys_hazard.go
+++ b/internal/game/sys_hazard.go
@@ -92,7 +92,7 @@ func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardD
if miss == "" {
miss = fmt.Sprintf("You weather the %s.", hz.Name)
}
- sess.WriteLine("\n" + g.colorize(sess, "miss", miss))
+ sess.WriteLine(g.colorize(sess, "miss", miss))
g.writePrompt(sess)
}
}
@@ -123,7 +123,7 @@ func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.Haz
msg = fmt.Sprintf(msg, dmg)
}
hpSuffix := fmt.Sprintf(" %s [%d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP())
- sess.WriteLine("\n" + g.colorize(sess, "damage_taken", msg) + hpSuffix)
+ sess.WriteLine(g.colorize(sess, "damage_taken", msg) + hpSuffix)
// NB: a hazard never clears MoveState, so it cannot interrupt a walk the way
// a mob hit ("Can't escape!") does.
diff --git a/internal/game/tick.go b/internal/game/tick.go
index ee9ecf2..f8d6f04 100644
--- a/internal/game/tick.go
+++ b/internal/game/tick.go
@@ -142,17 +142,17 @@ func (g *Game) WanderTick() {
if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") {
if m.level > 0 {
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", m.name), levelStr))
+ sess.WriteLine(fmt.Sprintf("%s %s leaves.", g.colorize(sess, "mob", m.name), levelStr))
} else {
- sess.WriteLine(fmt.Sprintf("\n%s moves away.", g.colorize(sess, "mob", m.name)))
+ sess.WriteLine(fmt.Sprintf("%s moves away.", g.colorize(sess, "mob", m.name)))
}
}
if p.RoomID == m.toRoom && p.OptionBool("mob_enter") {
if m.level > 0 {
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", m.name), levelStr))
+ sess.WriteLine(fmt.Sprintf("%s %s enters.", g.colorize(sess, "mob", m.name), levelStr))
} else {
- sess.WriteLine(fmt.Sprintf("\n%s drifts in.", g.colorize(sess, "mob", m.name)))
+ sess.WriteLine(fmt.Sprintf("%s drifts in.", g.colorize(sess, "mob", m.name)))
}
}
}
@@ -269,8 +269,8 @@ func (g *Game) TechTick() {
if p.Battery <= 0 {
p.Battery = 0
p.DeactivateAllTechs()
- sess.WriteLine(g.colorize(sess, "tech_depleted",
- "\nYour battery is depleted! All tech has been disabled."))
+ sess.WriteLine(g.colorize(sess, "tech_depleted",
+ "Your battery is depleted! All tech has been disabled."))
g.writePrompt(sess)
}
}
diff --git a/internal/game/triggers.go b/internal/game/triggers.go
index 3b6f609..cd1bbd3 100644
--- a/internal/game/triggers.go
+++ b/internal/game/triggers.go
@@ -56,7 +56,7 @@ func (g *Game) TransientMobTick() {
g.MobStore.RemoveInstance(inst.InstanceID)
for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) {
sess.WriteLine(g.colorize(sess, "broadcast",
- fmt.Sprintf("\n%s fades away.", mobDisplayName(inst, true))))
+ fmt.Sprintf("%s fades away.", mobDisplayName(inst, true))))
}
}
}
@@ -92,13 +92,8 @@ func (g *Game) processPlayerTriggerSequences() {
}
}
- firstMsg := true
for _, step := range jobs {
s := step
- if firstMsg && s.Message != "" {
- s.Message = "\n" + s.Message
- firstMsg = false
- }
g.executeTriggerStep(sess, p, &s, seq.RoomID, seq.FlagValue)
}
@@ -153,7 +148,7 @@ func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *wor
for _, other := range g.Hub.PlayersInRoom(roomID) {
otherMode := g.colorMode(other)
rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered))
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
if step.BroadcastGlobal != "" && g.Hub != nil {
@@ -162,7 +157,7 @@ func (g *Game) executeTriggerStep(sess *net.Session, p *player.Player, step *wor
if other.Player != nil {
otherMode := g.colorMode(other)
rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered))
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
}
@@ -216,7 +211,7 @@ func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flag
for _, other := range g.Hub.PlayersInRoom(roomID) {
otherMode := g.colorMode(other)
rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered))
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
if step.BroadcastGlobal != "" && g.Hub != nil {
@@ -226,7 +221,7 @@ func (g *Game) executeWorldTriggerStep(step *world.TriggerStep, roomID int, flag
if other.Player != nil {
otherMode := g.colorMode(other)
rendered := color.ExpandTagsDefault(otherMode, broadcastSpec, msg)
- other.WriteLine(g.colorize(other, "broadcast", "\n"+rendered))
+ other.WriteLine(g.colorize(other, "broadcast", rendered))
}
}
}
@@ -254,7 +249,7 @@ func (g *Game) spawnTriggerMob(sess *net.Session, p *player.Player, cfg *world.S
return
}
inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks))
- sess.WriteLine(g.colorize(sess, "broadcast", "\n"+mobDisplayName(inst, true)+" appears!"))
+ sess.WriteLine(g.colorize(sess, "broadcast", mobDisplayName(inst, true)+" appears!"))
}
func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) {
@@ -272,8 +267,8 @@ func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) {
inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks))
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(roomID) {
- sess.WriteLine(g.colorize(sess, "broadcast",
- "\n"+mobDisplayName(inst, true)+" appears!"))
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ mobDisplayName(inst, true)+" appears!"))
}
}
}
@@ -292,8 +287,8 @@ func (g *Game) despawnTriggerMobs(mobID string, owner string) {
}
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) {
- sess.WriteLine(g.colorize(sess, "broadcast",
- fmt.Sprintf("\n%s vanishes.", mobDisplayName(inst, true))))
+ sess.WriteLine(g.colorize(sess, "broadcast",
+ fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true))))
}
}
g.MobStore.RemoveInstance(inst.InstanceID)
diff --git a/internal/net/server.go b/internal/net/server.go
index 9ae8532..731beef 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -32,6 +32,7 @@ const (
StateGame
StateChangeDesc
StateTalk
+ StateTalkSequence
StateDropAllConfirm
StateRecipeChoice
StateHowMany
@@ -61,6 +62,7 @@ type Session struct {
Disconnecting bool
DisconnectTicks int
Shop *behavior.ShopConfig
+ promptVisible bool
writeMu sync.Mutex
}
@@ -346,6 +348,10 @@ func (sess *Session) wrap(msg string) string {
func (sess *Session) Write(msg string) {
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
+ if sess.promptVisible {
+ sess.Conn.Write([]byte("\r\n"))
+ sess.promptVisible = false
+ }
sess.Conn.Write([]byte(msg))
}
@@ -353,13 +359,21 @@ func (sess *Session) WriteLine(msg string) {
msg = sess.wrap(msg)
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
+ if sess.promptVisible {
+ sess.Conn.Write([]byte("\r\n"))
+ sess.promptVisible = false
+ }
sess.Conn.Write([]byte(msg + "\r\n"))
}
func (sess *Session) WriteLines(lines ...string) {
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
- for _, l := range lines {
+ for i, l := range lines {
+ if i == 0 && sess.promptVisible {
+ sess.Conn.Write([]byte("\r\n"))
+ sess.promptVisible = false
+ }
sess.Conn.Write([]byte(sess.wrap(l) + "\r\n"))
}
}
@@ -368,9 +382,30 @@ func (sess *Session) Writef(format string, args ...interface{}) {
msg := sess.wrap(fmt.Sprintf(format, args...))
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
+ if sess.promptVisible {
+ sess.Conn.Write([]byte("\r\n"))
+ sess.promptVisible = false
+ }
sess.Conn.Write([]byte(msg))
}
+func (sess *Session) WritePrompt(prompt string) {
+ sess.writeMu.Lock()
+ defer sess.writeMu.Unlock()
+ if sess.promptVisible {
+ return
+ }
+ sess.Conn.Write([]byte(prompt))
+ sess.promptVisible = true
+}
+
+func (sess *Session) Reprompt(prompt string) {
+ sess.writeMu.Lock()
+ defer sess.writeMu.Unlock()
+ sess.Conn.Write([]byte(prompt))
+ sess.promptVisible = true
+}
+
func (sess *Session) Close() error {
sess.writeMu.Lock()
defer sess.writeMu.Unlock()
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 9cff717..f1d6b5e 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -806,12 +806,12 @@ func validateTalkConfig(prefix string, cfg *behavior.TalkConfig, itemIDs map[str
if node.Action != nil {
issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...)
}
- if node.Next != "" {
- if _, ok := cfg.Nodes[node.Next]; !ok {
+ if node.Goto != "" {
+ if _, ok := cfg.Nodes[node.Goto]; !ok {
issues = append(issues, Issue{
Level: "ERROR",
Type: "reference",
- Message: fmt.Sprintf("%s: next points to nonexistent node %q", nodePrefix, node.Next),
+ Message: fmt.Sprintf("%s: goto points to nonexistent node %q", nodePrefix, node.Goto),
})
}
}