diff options
61 files changed, 551 insertions, 744 deletions
@@ -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), }) } } |
