aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-24 20:58:25 -0400
committerhistoria <[not public]>2026-06-24 20:58:25 -0400
commit9d4b799db4868bcab291b83599ff088763cd4ed6 (patch)
tree252f0fcc779acf35243983d643d6399ee2e0cd0b
parentd25638d98fe63efdeab570ef77e6a6a97f2d7a60 (diff)
downloadthehouseoficarus-9d4b799db4868bcab291b83599ff088763cd4ed6.tar.gz
feat: new type of non-violent 'combat': work
-rw-r--r--AGENTS.md37
-rw-r--r--IDEAS.md10
-rw-r--r--README.md24
-rw-r--r--building_guide/bank.md (renamed from worldbuilding_guide/bank.md)0
-rw-r--r--building_guide/behaviors.md (renamed from worldbuilding_guide/behaviors.md)0
-rw-r--r--building_guide/conditions.md (renamed from worldbuilding_guide/conditions.md)24
-rw-r--r--building_guide/construction.md (renamed from worldbuilding_guide/construction.md)0
-rw-r--r--building_guide/courses.md (renamed from worldbuilding_guide/courses.md)0
-rw-r--r--building_guide/doors.md (renamed from worldbuilding_guide/doors.md)0
-rw-r--r--building_guide/drops.md (renamed from worldbuilding_guide/drops.md)0
-rw-r--r--building_guide/hacking.md (renamed from worldbuilding_guide/hacking.md)0
-rw-r--r--building_guide/hazards.md75
-rw-r--r--building_guide/hidden_objects.md92
-rw-r--r--building_guide/items.md (renamed from worldbuilding_guide/items.md)4
-rw-r--r--building_guide/mobs.md (renamed from worldbuilding_guide/mobs.md)42
-rw-r--r--building_guide/objects.md (renamed from worldbuilding_guide/objects.md)0
-rw-r--r--building_guide/quests.md (renamed from worldbuilding_guide/quests.md)0
-rw-r--r--building_guide/recipes.md (renamed from worldbuilding_guide/recipes.md)0
-rw-r--r--building_guide/rooms.md209
-rw-r--r--building_guide/search.md (renamed from worldbuilding_guide/search.md)0
-rw-r--r--building_guide/state.md (renamed from worldbuilding_guide/state.md)0
-rw-r--r--building_guide/tips.md (renamed from worldbuilding_guide/tips.md)0
-rw-r--r--building_guide/wandering_mobs.md (renamed from worldbuilding_guide/wandering_mobs.md)0
-rw-r--r--building_guide/wandering_objects.md (renamed from worldbuilding_guide/wandering_objects.md)0
-rw-r--r--cmd/mud/main.go2
-rw-r--r--config.yaml3
-rw-r--r--data/courses/reactor.yaml16
-rw-r--r--data/courses/rooftop.yaml14
-rw-r--r--data/courses/vent_shaft.yaml12
-rw-r--r--data/hazards/solar_radiation.yaml12
-rw-r--r--data/help/combat.yaml2
-rw-r--r--data/help/drink.yaml2
-rw-r--r--data/help/prompt.yaml2
-rw-r--r--data/help/style.yaml2
-rw-r--r--data/items/consumables/stim_potion.yaml2
-rw-r--r--data/items/consumables/super_stim.yaml2
-rw-r--r--data/items/equipment/adamant_dagger.yaml2
-rw-r--r--data/items/equipment/adamant_sword.yaml2
-rw-r--r--data/items/equipment/black_axe.yaml2
-rw-r--r--data/items/equipment/dragon_axe.yaml2
-rw-r--r--data/items/equipment/mithril_dagger.yaml2
-rw-r--r--data/items/equipment/mithril_sword.yaml2
-rw-r--r--data/items/equipment/rune_dagger.yaml2
-rw-r--r--data/items/equipment/rune_sword.yaml2
-rw-r--r--data/items/equipment/steel_dagger.yaml2
-rw-r--r--data/items/equipment/steel_sword.yaml2
-rw-r--r--data/items/tools/adamant_axe.yaml2
-rw-r--r--data/items/tools/adamant_pickaxe.yaml2
-rw-r--r--data/items/tools/black_pickaxe.yaml2
-rw-r--r--data/items/tools/dragon_pickaxe.yaml2
-rw-r--r--data/items/tools/mithril_axe.yaml2
-rw-r--r--data/items/tools/mithril_pickaxe.yaml2
-rw-r--r--data/items/tools/rune_axe.yaml2
-rw-r--r--data/items/tools/rune_pickaxe.yaml2
-rw-r--r--data/items/tools/steel_axe.yaml2
-rw-r--r--data/items/tools/steel_pickaxe.yaml2
-rw-r--r--data/mobs/solar_panel_frame.yaml34
-rw-r--r--data/objects/1002_crowd.yaml18
-rw-r--r--data/objects/1002_pilot.yaml10
-rw-r--r--data/objects/1002_shuttle.yaml9
-rw-r--r--data/rooms/example/agility/99999200.yaml (renamed from data/rooms/agility/200.yaml)10
-rw-r--r--data/rooms/example/agility/99999201.yaml (renamed from data/rooms/agility/201.yaml)4
-rw-r--r--data/rooms/example/agility/99999202.yaml (renamed from data/rooms/agility/202.yaml)4
-rw-r--r--data/rooms/example/agility/99999203.yaml (renamed from data/rooms/agility/203.yaml)4
-rw-r--r--data/rooms/example/agility/99999204.yaml (renamed from data/rooms/agility/204.yaml)4
-rw-r--r--data/rooms/example/agility/99999205.yaml (renamed from data/rooms/agility/205.yaml)4
-rw-r--r--data/rooms/example/agility/99999210.yaml (renamed from data/rooms/agility/210.yaml)4
-rw-r--r--data/rooms/example/agility/99999211.yaml (renamed from data/rooms/agility/211.yaml)4
-rw-r--r--data/rooms/example/agility/99999212.yaml (renamed from data/rooms/agility/212.yaml)4
-rw-r--r--data/rooms/example/agility/99999213.yaml (renamed from data/rooms/agility/213.yaml)4
-rw-r--r--data/rooms/example/agility/99999214.yaml (renamed from data/rooms/agility/214.yaml)4
-rw-r--r--data/rooms/example/agility/99999215.yaml (renamed from data/rooms/agility/215.yaml)4
-rw-r--r--data/rooms/example/agility/99999220.yaml (renamed from data/rooms/agility/220.yaml)4
-rw-r--r--data/rooms/example/agility/99999221.yaml (renamed from data/rooms/agility/221.yaml)4
-rw-r--r--data/rooms/example/agility/99999222.yaml (renamed from data/rooms/agility/222.yaml)4
-rw-r--r--data/rooms/example/agility/99999223.yaml (renamed from data/rooms/agility/223.yaml)4
-rw-r--r--data/rooms/example/agility/99999224.yaml (renamed from data/rooms/agility/224.yaml)4
-rw-r--r--data/rooms/example/agility/99999225.yaml (renamed from data/rooms/agility/225.yaml)4
-rw-r--r--data/rooms/example/agility/99999226.yaml (renamed from data/rooms/agility/226.yaml)4
-rw-r--r--data/rooms/example/player_housing/99999300.yaml (renamed from data/rooms/player_housing/300.yaml)6
-rw-r--r--data/rooms/example/player_housing/99999301.yaml (renamed from data/rooms/player_housing/301.yaml)4
-rw-r--r--data/rooms/example/town/999991.yaml (renamed from data/rooms/town/1.yaml)10
-rw-r--r--data/rooms/example/town/9999910.yaml (renamed from data/rooms/town/10.yaml)6
-rw-r--r--data/rooms/example/town/9999911.yaml (renamed from data/rooms/town/11.yaml)6
-rw-r--r--data/rooms/example/town/9999912.yaml (renamed from data/rooms/town/12.yaml)6
-rw-r--r--data/rooms/example/town/9999913.yaml (renamed from data/rooms/town/13.yaml)6
-rw-r--r--data/rooms/example/town/9999914.yaml (renamed from data/rooms/town/14.yaml)6
-rw-r--r--data/rooms/example/town/9999915.yaml (renamed from data/rooms/town/15.yaml)8
-rw-r--r--data/rooms/example/town/9999916.yaml (renamed from data/rooms/town/16.yaml)8
-rw-r--r--data/rooms/example/town/9999917.yaml (renamed from data/rooms/town/17.yaml)8
-rw-r--r--data/rooms/example/town/9999918.yaml (renamed from data/rooms/town/18.yaml)6
-rw-r--r--data/rooms/example/town/9999919.yaml (renamed from data/rooms/town/19.yaml)6
-rw-r--r--data/rooms/example/town/999992.yaml (renamed from data/rooms/town/2.yaml)8
-rw-r--r--data/rooms/example/town/9999920.yaml (renamed from data/rooms/town/20.yaml)6
-rw-r--r--data/rooms/example/town/9999921.yaml (renamed from data/rooms/town/21.yaml)4
-rw-r--r--data/rooms/example/town/9999922.yaml (renamed from data/rooms/town/22.yaml)6
-rw-r--r--data/rooms/example/town/9999923.yaml (renamed from data/rooms/town/23.yaml)6
-rw-r--r--data/rooms/example/town/9999924.yaml (renamed from data/rooms/town/24.yaml)6
-rw-r--r--data/rooms/example/town/9999925.yaml (renamed from data/rooms/town/25.yaml)6
-rw-r--r--data/rooms/example/town/9999926.yaml (renamed from data/rooms/town/26.yaml)6
-rw-r--r--data/rooms/example/town/9999927.yaml (renamed from data/rooms/town/27.yaml)6
-rw-r--r--data/rooms/example/town/9999928.yaml (renamed from data/rooms/town/28.yaml)4
-rw-r--r--data/rooms/example/town/999993.yaml (renamed from data/rooms/town/3.yaml)4
-rw-r--r--data/rooms/example/town/9999930.yaml (renamed from data/rooms/town/30.yaml)4
-rw-r--r--data/rooms/example/town/9999931.yaml (renamed from data/rooms/town/31.yaml)6
-rw-r--r--data/rooms/example/town/999994.yaml (renamed from data/rooms/town/4.yaml)8
-rw-r--r--data/rooms/example/town/999995.yaml (renamed from data/rooms/town/5.yaml)4
-rw-r--r--data/rooms/example/town/999996.yaml (renamed from data/rooms/town/6.yaml)8
-rw-r--r--data/rooms/example/town/999997.yaml (renamed from data/rooms/town/7.yaml)4
-rw-r--r--data/rooms/example/town/999998.yaml (renamed from data/rooms/town/8.yaml)6
-rw-r--r--data/rooms/example/town/999999.yaml (renamed from data/rooms/town/9.yaml)10
-rw-r--r--data/rooms/example/underground/9999950.yaml (renamed from data/rooms/underground/50.yaml)4
-rw-r--r--data/rooms/example/underground/9999951.yaml (renamed from data/rooms/underground/51.yaml)6
-rw-r--r--data/rooms/example/underground/9999952.yaml (renamed from data/rooms/underground/52.yaml)6
-rw-r--r--data/rooms/example/underground/9999953.yaml (renamed from data/rooms/underground/53.yaml)6
-rw-r--r--data/rooms/example/underground/9999954.yaml (renamed from data/rooms/underground/54.yaml)6
-rw-r--r--data/rooms/example/underground/9999955.yaml (renamed from data/rooms/underground/55.yaml)14
-rw-r--r--data/rooms/example/underground/9999956.yaml (renamed from data/rooms/underground/56.yaml)4
-rw-r--r--data/rooms/example/underground/9999957.yaml (renamed from data/rooms/underground/57.yaml)4
-rw-r--r--data/rooms/example/underground/9999958.yaml (renamed from data/rooms/underground/58.yaml)6
-rw-r--r--data/rooms/example/underground/9999959.yaml (renamed from data/rooms/underground/59.yaml)4
-rw-r--r--data/rooms/example/underground/9999960.yaml (renamed from data/rooms/underground/60.yaml)6
-rw-r--r--data/rooms/example/underground/9999961.yaml (renamed from data/rooms/underground/61.yaml)4
-rw-r--r--data/rooms/example/underground/9999962.yaml (renamed from data/rooms/underground/62.yaml)6
-rw-r--r--data/rooms/example/underground/9999963.yaml (renamed from data/rooms/underground/63.yaml)6
-rw-r--r--data/rooms/example/underground/9999964.yaml (renamed from data/rooms/underground/64.yaml)6
-rw-r--r--data/rooms/example/underground/9999965.yaml (renamed from data/rooms/underground/65.yaml)0
-rw-r--r--data/rooms/example/underground/9999966.yaml (renamed from data/rooms/underground/66.yaml)0
-rw-r--r--data/rooms/example/underground/9999967.yaml (renamed from data/rooms/underground/67.yaml)0
-rw-r--r--data/rooms/example/underground/9999968.yaml (renamed from data/rooms/underground/68.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999150.yaml (renamed from data/rooms/wilderness/150.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999151.yaml (renamed from data/rooms/wilderness/151.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999152.yaml (renamed from data/rooms/wilderness/152.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999160.yaml (renamed from data/rooms/wilderness/160.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999161.yaml (renamed from data/rooms/wilderness/161.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999162.yaml (renamed from data/rooms/wilderness/162.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999170.yaml (renamed from data/rooms/wilderness/170.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999171.yaml (renamed from data/rooms/wilderness/171.yaml)0
-rw-r--r--data/rooms/example/wilderness/99999180.yaml5
-rw-r--r--data/rooms/example/wilderness/99999181.yaml11
-rw-r--r--data/rooms/intro/1001.yaml5
-rw-r--r--data/rooms/intro/1002.yaml43
-rw-r--r--data/rooms/intro/1003.yaml5
-rw-r--r--internal/game/action.go57
-rw-r--r--internal/game/action_room.go179
-rw-r--r--internal/game/action_state.go3
-rw-r--r--internal/game/cmd_attack.go275
-rw-r--r--internal/game/cmd_consume.go2
-rw-r--r--internal/game/cmd_look.go92
-rw-r--r--internal/game/cmd_move.go67
-rw-r--r--internal/game/cmd_registry.go1
-rw-r--r--internal/game/cmd_score.go4
-rw-r--r--internal/game/cmd_stats.go2
-rw-r--r--internal/game/condition_test.go155
-rw-r--r--internal/game/core_login_char.go21
-rw-r--r--internal/game/core_startup.go53
-rw-r--r--internal/game/game.go6
-rw-r--r--internal/game/map_test.go36
-rw-r--r--internal/game/sys_hazard.go136
-rw-r--r--internal/game/sys_labor.go81
-rw-r--r--internal/game/sys_safespot.go7
-rw-r--r--internal/game/sys_technology.go30
-rw-r--r--internal/net/server.go1
-rw-r--r--internal/object/object.go11
-rw-r--r--internal/player/player.go16
-rw-r--r--internal/world/hazard.go82
-rw-r--r--internal/world/mob.go23
-rw-r--r--internal/world/objects.go21
-rw-r--r--internal/world/room.go24
-rw-r--r--internal/world/world.go5
-rw-r--r--worldbuilding_guide/hidden_objects.md40
-rw-r--r--worldbuilding_guide/index.md17
-rw-r--r--worldbuilding_guide/rooms.md134
173 files changed, 2080 insertions, 637 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 8b65314..c2d420f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,7 +43,7 @@ internal/
| `core_*.go` | Infrastructure — types, utils, login, production engine, skill XP, flags, courses, equipment, stations, messages, startup validation | 13 |
| `action_*.go` | Tick-based action lifecycles — gather, firemaking, talk, use, search, thieving, farming, agility, scavenging, assassin, fletching, cleaning, room scripts, targeting | 16 |
| `cmd_*.go` | Command handlers — one file per command or command group | 49 |
-| `sys_*.go` | Game subsystems — technology, science, combat (death + aggro), assassin tasks, buffs, construction | 6 |
+| `sys_*.go` | Game subsystems — technology, science, combat (death + aggro), assassin tasks, buffs, construction, hazards, labor (task mobs) | 8 |
| `ui_*.go` | Display/output — color, prompt, table, map, help | 5 |
| `hacking/` | Hacking sub-package — Minigame interface, TerminalDef, CalcXP, WumpusGame, MastermindGame, LiarsDiceGame | 4 |
@@ -81,15 +81,22 @@ The pattern is: lock, snapshot/unmarshal, unlock, then process. The engine creat
- **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.
+## 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 **cutscene** (driven by `EnterSeqTick`); otherwise messages print synchronously. Step conditions are snapshotted once at entry. A cutscene persists `Player.CutsceneRoom` and **resumes on reconnect** if interrupted; plain on_enter never runs on login.
+- **Conditional descriptions** (`descriptions: [{text, condition}]`) on rooms and objects pick the first variant whose condition passes (per-looker), else the base `description`. For an **object**, if it has `descriptions` and none match, 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.
+
## Data Files
```
-data/rooms/<id>.yaml Room definitions (exits, objects, mobs, item_spawns, on_enter)
+data/rooms/<id>.yaml Room definitions (exits, objects, mobs, item_spawns, on_enter, descriptions)
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)
-data/objects/<id>.yaml Object definitions (name, behavior, hidden, inroom_description, removal_item)
+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, descriptions, removal_item)
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 (id, level, max_hit, base_xp, junk_cost, category)
data/courses/<id>.yaml Agility course definitions (name, required_level, start_room, obstacles)
@@ -99,7 +106,7 @@ data/players/characters/ Character YAML (gitignored)
Wander config (`wander_rooms`, `wander_interval`) is set per-instance in room YAML — mob defs are generic. Mobs wander via legal (unconditioned) room exits; objects teleport between rooms in their assigned list.
-See `worldbuilding_guide/` for full YAML format reference with examples.
+See `building_guide/` for full YAML format reference with examples.
## Startup Validation
@@ -217,6 +224,7 @@ The `verbSkill` map maps verb → skill name for default target resolution:
| `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)
@@ -286,12 +294,13 @@ clamped to [0, cfg.Cap]
### Condition System
-Used by exits, talk options, on-enter scripts, and use_interactions checks:
+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 |
@@ -341,6 +350,14 @@ Used by exits, talk options, on-enter scripts, and use_interactions checks:
**Mob DropTable:** `Remains` (guaranteed drop item), `Loot` (weighted DropEntry table).
+## 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)
25 hardcoded techs in 4 categories, unlocked by Technology level:
@@ -410,6 +427,7 @@ Account-wide options set via `option <name> <value>`. Stored in account YAML, sh
| `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 |
| `unicode` | bool | true | Unicode box-drawing characters |
| `run_countdown` | bool | false | Flee countdown messages |
@@ -454,6 +472,7 @@ Prompt variables: `%h` (HP), `%H` (max HP), `%b` (battery), `%B` (max battery),
| `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 (object/item.go)
@@ -500,6 +519,10 @@ Prompt variables: `%h` (HP), `%H` (max HP), `%b` (battery), `%B` (max battery),
- 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.
## Adding a New Skill
@@ -514,7 +537,7 @@ Prompt variables: `%h` (HP), `%H` (max HP), `%b` (battery), `%B` (max battery),
Production skills share a unified system in `core_production.go`:
-1. Create the output item YAML in `data/items/` with a `craft:` block. See `worldbuilding_guide/recipes.md`.
+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
diff --git a/IDEAS.md b/IDEAS.md
deleted file mode 100644
index 15031c5..0000000
--- a/IDEAS.md
+++ /dev/null
@@ -1,10 +0,0 @@
-- Quick swap outfits at bank (melee, range, tech, mining, etc.)
-- Graceful allows 20% longer walks
-- Rare card pack drops (mobs and skills). Lore, items, mob, character, mob cars. Super scope creep: Card game?
-- Make sure this doesn't ruin the casino but: A rare drop that improves your casino odds? Blocks low cards in BJ or something for X rounds? Something that tilts EV to like 102%.
-+ Better idea: A temporary pass to a room with gimmicked odds or payouts to like 105%.
-+ Training gambling room? Very low max bets but 101% payouts? Would have to fit into lore. Maybe it's a way to create problem gamblers.
-
-Story:
-Spaceport at Casino
-Corp owns Casino, only they're rich enough to keep the NL at bay enough to operate spaceships
diff --git a/README.md b/README.md
index fabf6f7..5b2b89a 100644
--- a/README.md
+++ b/README.md
@@ -93,32 +93,14 @@ Pockets of radicalized settlers on the terraformed asteroid Gaia 04 took the ten
Wounded, Gaia 04 struggles on. Everyone from astrobotanists to business executives must remake their lives as farmers, fishers, and miners as they survive in the primitive ashes of the fallen colony.
-## Major Factions
-**Neoluddites**
-Anti-tech mostly-extremists who brought about the Second Collapse by destroying electronic networks and thinking machines. Once pawns of Earth's ruling class, they've evolved into a self-sufficient traditionalist culture with inconsistent views about what technology should be permitted to exist.
-
-**Utopians**
-Those who embraced AI control of society and tried to stop the Neoluddites through both policy and violence. Their views range from religious exaltation of thinking machines to mere agreement that life was easier when the AI handled the governance and marginalia.
-
-**Craggers**
-Colonists who don't take a side in the conflict one way or the other. Everyone from criminals escaping their past to once well-paid Earth engineers overseeing terraforming operations.
-
-# Todo
-
-- Fix and test all skills
-- Pharmacy is broken
-- Make sure "use" works with everything as expected (e.g. id stations)
-- Play around with Hacking. Needs interesting but semi-afk minigames.
-- Prettify and make menus consistent
-- Start worldbuilding with a tutorial island.
# AI Disclosure
-Huge technical parts of this codebase were ~~completely vibe coded~~ ~~agentically engineered~~ written with LLM assistance.
+Huge technical parts of the **engine** were ~~completely vibe coded~~ ~~agentically engineered~~ written with LLM assistance.
-LLMs will **not** generate any creative part of the game world (e.g. story, characters, rooms, quests). LLMs do generate *example* content for dev testing, so you might see that somewhere, but not in the final game.
+**LLMs will not generate any creative content** (e.g. story, characters, rooms, quests). LLMs do generate *example* content for dev testing, so you might see that somewhere, but not in the final game.
# License
-0BSD. Do whatever you want.
+0BSD. Do whatever you want. Have fun.
diff --git a/worldbuilding_guide/bank.md b/building_guide/bank.md
index 0a5ec88..0a5ec88 100644
--- a/worldbuilding_guide/bank.md
+++ b/building_guide/bank.md
diff --git a/worldbuilding_guide/behaviors.md b/building_guide/behaviors.md
index 22e97fe..22e97fe 100644
--- a/worldbuilding_guide/behaviors.md
+++ b/building_guide/behaviors.md
diff --git a/worldbuilding_guide/conditions.md b/building_guide/conditions.md
index 972dadc..ea17ed6 100644
--- a/worldbuilding_guide/conditions.md
+++ b/building_guide/conditions.md
@@ -4,21 +4,34 @@ Conditions are used in talk options, exit gates, on-enter scripts, and use_inter
### Simple conditions
+A bare `flag:` / `player_flag:` check passes when the flag is **set to a truthy
+value** (`true`, a non-zero number, a non-empty string). Add `value:` only when
+you need to match a specific value (e.g. a numeric quest stage). `not: true`
+inverts any check.
+
```yaml
-# Check a world flag
+# Check a world flag is set
condition:
flag: gate_open
- value: true
# Check a world flag is NOT set
condition:
flag: gate_open
not: true
-# Check a player flag
+# Check a player flag is set
+condition:
+ player_flag: finished_tutorial
+
+# Check a player flag is NOT set (e.g. only on the first visit)
condition:
player_flag: finished_tutorial
- value: true
+ not: true
+
+# Match a specific (non-boolean) value
+condition:
+ player_flag: quest_stage
+ value: 3
# Check if player has an item
condition:
@@ -41,7 +54,6 @@ All must pass:
condition:
all_of:
- flag: gate_open
- value: true
- has_item: pass_stub
```
@@ -52,7 +64,6 @@ condition:
- has_item: bronze_key
- has_item: iron_key
- player_flag: master_of_unlocking
- value: true
```
Nested compounds:
@@ -60,7 +71,6 @@ Nested compounds:
condition:
all_of:
- player_flag: quest_started
- value: true
- any_of:
- has_item: wolf_pelt
- has_item: bear_pelt
diff --git a/worldbuilding_guide/construction.md b/building_guide/construction.md
index 2c4b7ce..2c4b7ce 100644
--- a/worldbuilding_guide/construction.md
+++ b/building_guide/construction.md
diff --git a/worldbuilding_guide/courses.md b/building_guide/courses.md
index 491e2bd..491e2bd 100644
--- a/worldbuilding_guide/courses.md
+++ b/building_guide/courses.md
diff --git a/worldbuilding_guide/doors.md b/building_guide/doors.md
index 1e9fb7a..1e9fb7a 100644
--- a/worldbuilding_guide/doors.md
+++ b/building_guide/doors.md
diff --git a/worldbuilding_guide/drops.md b/building_guide/drops.md
index e5a17ff..e5a17ff 100644
--- a/worldbuilding_guide/drops.md
+++ b/building_guide/drops.md
diff --git a/worldbuilding_guide/hacking.md b/building_guide/hacking.md
index 8927b3e..8927b3e 100644
--- a/worldbuilding_guide/hacking.md
+++ b/building_guide/hacking.md
diff --git a/building_guide/hazards.md b/building_guide/hazards.md
new file mode 100644
index 0000000..453ca18
--- /dev/null
+++ b/building_guide/hazards.md
@@ -0,0 +1,75 @@
+## Hazards
+
+A **hazard** is an environmental threat attached to a room — a sandstorm, solar
+radiation, falling rocks, toxic spores. It rolls an "attack" against everyone in
+the room every few ticks using the **same combat formulas as a mob**: the hazard
+supplies the accuracy and max hit, and the player defends with their Defense
+level plus the equipment defense bonus selected by the hazard's attack type. No
+new combat math, no special gear — your existing armour and Defense skill are
+what keep you alive.
+
+Hazards are **shared, ID-referenced definitions** (like drop tables and modules):
+define a hazard once in `data/hazards/<id>.yaml`, then reference it from any number
+of rooms with `hazard: <id>` (see `rooms.md`). This makes one hazard reusable
+across a whole area and lets a future `examine` command resolve it by name.
+
+```yaml
+id: solar_radiation
+name: scorching solar radiation
+description: "Unfiltered sunlight pours through a gap in the dome, burning exposed skin."
+attack_type: science # stab/slash/crush/ranged/science — picks player defense + protect tech
+attack: 40 # accuracy roll level
+attack_bonus: 0 # equipment-equivalent accuracy bonus
+max_hit: 6 # maximum damage per hit
+speed: 5 # ticks between hazard rolls
+warn_message: "The dome shielding fails here — raw solar radiation pours in."
+hit_message: "Solar radiation sears you for %d damage." # optional single %d for the damage
+miss_message: "You shield your eyes and weather a wave of solar glare."
+required_item: "" # optional: an equipped item that NEGATES the hazard entirely
+```
+
+### Fields
+
+| Field | Description |
+|---|---|
+| `id` | Unique identifier (matches the filename) |
+| `name` | Display name used in warnings and default messages |
+| `description` | Flavour text (for a future `examine`) |
+| `attack_type` | `stab`/`slash`/`crush`/`ranged`/`science` — selects which player defense bonus applies and which protection tech reduces the damage (empty = `crush`) |
+| `attack` | Hazard accuracy level |
+| `attack_bonus` | Equipment-equivalent accuracy bonus |
+| `max_hit` | Maximum damage per hit |
+| `speed` | Ticks between hazard rolls (default 5 if ≤ 0) |
+| `warn_message` | Shown when warning the player about the area |
+| `hit_message` | Message on a hit; include a single `%d` to show the damage |
+| `miss_message` | Message on a miss |
+| `required_item` | If set and equipped, the hazard is fully negated for that player |
+
+### How it behaves
+
+- **Applies to everyone in the room, always** — idle, passing through, working a task,
+ or fighting a mob. Strong incentive to work fast, take cover, or leave.
+- **Tech protection applies automatically.** Because the hazard has an attack type, the
+ matching protection tech reduces its damage: Kinetic Barrier (melee types),
+ Projectile Screen (`ranged`), Neural Firewall (`science`).
+- **Safespots block all hazard damage.** A hidden player behind a safespot object takes
+ no hazard damage, regardless of hazard type. Note that doing a *melee* attack/work step
+ forces you out of cover — so ranged/science work lets you stay sheltered, melee does not.
+- **Right gear negates it.** If `required_item` is set and equipped, the hazard is skipped
+ for that player entirely. The item must exist (validated at startup).
+- **Hazards never interrupt movement.** Unlike a mob hit (which aborts a flee with
+ "Can't escape!"), a hazard hit does not touch your walk — you can keep moving out of
+ the danger zone while taking hits.
+
+### Entry warning
+
+Players walking from a **safe** room into a **hazardous** one get a `[Y/n]` confirmation
+(pressing return defaults to yes; anything other than empty/`y`/`yes` cancels). Moving
+between two hazardous rooms does not re-prompt. Players can disable the prompt with the
+account option `danger_warning` (`option danger_warning false`).
+
+### Examine (forward-compatible)
+
+Because the hazard is a shared def referenced by ID, a future `examine sandstorm` can
+resolve the current room's `hazard` ID, load the def, and render its attack type, accuracy,
+max hit and description — no hidden objects required.
diff --git a/building_guide/hidden_objects.md b/building_guide/hidden_objects.md
new file mode 100644
index 0000000..e79c026
--- /dev/null
+++ b/building_guide/hidden_objects.md
@@ -0,0 +1,92 @@
+## Hidden Objects
+
+Objects with `hidden: true` don't appear in the room's object listing. Players discover them by reading room descriptions or trying commands. The object is still fully interactable — `push gate`, `look gate`, etc.
+
+```yaml
+# data/objects/secret_lever.yaml
+id: secret_lever
+name: stone lever
+hidden: true
+description: "A cleverly concealed lever behind a loose stone."
+use_interactions:
+ - condition:
+ flag: secret_passage_open
+ value: true
+ not: true
+ message: "You pull the lever. A grinding sound echoes from the east."
+ action:
+ set_flags:
+ secret_passage_open: true
+```
+
+Room description hints at it:
+```yaml
+description: "A dusty corridor. One of the wall stones looks slightly out of place."
+```
+
+### Multiple names (`aliases`)
+
+By default an object matches its `name` (word-prefix matching) and the words in
+its `id`. Add `aliases` to accept extra names:
+
+```yaml
+id: 1002_shuttle
+name: landing craft # matches "landing", "craft", "landing craft"
+aliases: [shuttle, ship] # also matches "shuttle" and "ship"
+hidden: true
+```
+
+If a player's input matches more than one distinct object, `look` lists the
+candidates ("That's ambiguous, which one?") instead of guessing — so keep aliases
+specific enough to avoid overlap between objects in the same room.
+
+### Descriptions that react to flags
+
+An object can show different `look` text depending on the looking player's flags,
+using a `descriptions` list (first matching condition wins). This is the simplest
+way to fake per-player scenery — a crowd that becomes a queue, an NPC that
+"appears" partway through a scene — without spawning real objects or mobs.
+
+**If none of the variants match, the object is treated as absent for that
+player** — `look <it>` reports nothing is there. Combined with `hidden: true`, the
+same shared object can appear only while a player's flags warrant it (e.g. during
+an intro) and vanish on return visits.
+
+```yaml
+id: 1002_crowd
+name: crowd of people
+aliases: [people, crowd, passengers]
+hidden: true
+descriptions:
+ - condition: # after the pilot arrives
+ all_of:
+ - player_flag: boarded
+ not: true
+ - player_flag: lined_up
+ text: "The passengers have formed a single-file line."
+ - condition: # before the pilot arrives
+ all_of:
+ - player_flag: boarded
+ not: true
+ - player_flag: lined_up
+ not: true
+ text: "A couple dozen anxious passengers mill about the pad."
+ # once `boarded` is set, no variant matches -> the crowd is gone
+```
+
+A plain `description:` (with no `descriptions:` list) is always present, as before.
+
+### Safespot Objects
+
+Safespot objects should always be `hidden: true`. They only become visible in `look` when the player's effective safespot tier meets the object's `safespot.tier` requirement. This creates a natural discovery mechanic: completing quests and achievements reveals new coverage opportunities.
+
+Players can discover safespots by:
+- Reading room descriptions for hints
+- Trying `hide <object_name>` (name matching works even for hidden objects)
+- Using `look <object>` once they know the name
+- Gaining tier through progression, which reveals safespots in `look`
+
+See [Safespots](objects.md#safespots) for the full YAML format.
+
+---
+
diff --git a/worldbuilding_guide/items.md b/building_guide/items.md
index 2e36349..52bfa7d 100644
--- a/worldbuilding_guide/items.md
+++ b/building_guide/items.md
@@ -20,7 +20,7 @@ speed: 5 # ticks between attacks
tool_type: pickaxe # used by gather behaviors that require "tool: pickaxe"
tool_speed: 2 # reduces gather wait time
requirements: # optional — skill levels needed to equip
- attack: 1
+ accuracy: 1
```
### Equipment Stats (ItemStats)
@@ -73,7 +73,7 @@ Higher-tier equipment requires skill levels to equip:
```yaml
requirements:
- attack: 20 # melee weapons
+ accuracy: 20 # melee weapons
defense: 20 # armor
ranged: 30 # ranged weapons/armor
```
diff --git a/worldbuilding_guide/mobs.md b/building_guide/mobs.md
index c19efff..c1f9332 100644
--- a/worldbuilding_guide/mobs.md
+++ b/building_guide/mobs.md
@@ -175,5 +175,47 @@ aggressive: true
Mobs without these fields work normally (assassin_level defaults to 0, finishing_blow and damage_without default to empty).
+### Task Mobs (Worksites)
+
+A task mob is a non-violent "worksite" — build a solar panel, survey flora, prospect a rock — that reuses the entire combat engine. Set `kind: task`. Mechanically its `hp` is a hidden work pool that drains to 0 to **complete** the work; the player sees a progress bar filling toward 100%. A task mob **never attacks back** — the danger (if any) comes from a room `hazard:` (see `hazards.md`). Use the `work` command (or `attack`) on it.
+
+```yaml
+id: solar_panel_frame
+name: solar panel frame
+kind: task # "" / "combat" (default) or "task"
+verb: assemble # flavor verb shown in messages (default: "work on")
+progress_noun: assembly # flavor noun in the progress line (default: "work")
+complete_message: "You bolt the final panel into place and the array hums to life!"
+hp: 40 # units of work to complete (NOT a health bar)
+defense: 10 # base difficulty
+speed: 5
+aggressive: false # task mobs do not fight; this is ignored
+respawn_ticks: 40 # the worksite replenishes after this many ticks
+attack_type: crush
+# Per-type defenses become METHOD EFFICIENCY: low = that approach works well.
+crush_defense: 0 # hammering panels in: efficient
+stab_defense: 40 # stabbing a frame: useless
+slash_defense: 40
+ranged_defense: 10 # a rivet gun / scanner (ranged weapon): works
+science_defense: 10 # an analysis deck (science weapon): works
+drops: # the "payment" for the labour — same drop system as combat
+ loot:
+ - item_id: credits
+ weight: 95
+ quantity: 40
+ - item_id: coal
+ weight: 5
+ quantity: 1
+```
+
+Key points:
+
+- **All combat styles work.** Melee, ranged (consumes ammo as "supplies") and science decks (consume junk) all apply progress. The per-type `*_defense` values steer which method is *efficient* without forbidding any — a rock might have low `crush_defense` but high `stab_defense`.
+- **XP and buffs are identical to combat** — driven by your attack style (accurate/aggressive/defensive/balanced) and split into the combat skills. This is a non-violent way to train Attack/Strength/Defense/Ranged/Science/Hitpoints.
+- **No special equipment.** Task mobs are designed around the weapons and bonuses you already have. There is no separate "tool" equipment set.
+- **Progress decays exactly like inverted mob regen.** A task you stop working on slowly loses progress over time (the same regen that heals combat mobs, displayed inverted).
+- **One worker at a time**, the same 1-v-1 lock as combat.
+
---
+
diff --git a/worldbuilding_guide/objects.md b/building_guide/objects.md
index 427968a..427968a 100644
--- a/worldbuilding_guide/objects.md
+++ b/building_guide/objects.md
diff --git a/worldbuilding_guide/quests.md b/building_guide/quests.md
index b5f18fa..b5f18fa 100644
--- a/worldbuilding_guide/quests.md
+++ b/building_guide/quests.md
diff --git a/worldbuilding_guide/recipes.md b/building_guide/recipes.md
index 450ded9..450ded9 100644
--- a/worldbuilding_guide/recipes.md
+++ b/building_guide/recipes.md
diff --git a/building_guide/rooms.md b/building_guide/rooms.md
new file mode 100644
index 0000000..735a6b4
--- /dev/null
+++ b/building_guide/rooms.md
@@ -0,0 +1,209 @@
+## Rooms
+
+Minimal room:
+```yaml
+id: 1
+name: "Town Square"
+description: "Cobblestone paths lead in all directions. A fountain gurgles peacefully."
+exits:
+ north: 2
+ west: 7
+ east: 3
+```
+
+### Inline Color Tags
+
+Room descriptions support inline color tags using `{spec}text{/}` syntax. Untagged text uses the `room_desc` color.
+
+```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{/}`.
+
+### Exits — simple vs conditional
+
+Simple exit — always passable:
+```yaml
+exits:
+ north: 2
+```
+
+Conditional exit — blocked until a world flag is set:
+```yaml
+exits:
+ north:
+ room: 11
+ condition:
+ flag: gate_open
+ blocked_message: "A heavy iron gate blocks the way north."
+```
+
+Conditional exit — blocked unless the PLAYER has a flag (key, permission, quest state):
+```yaml
+exits:
+ east:
+ room: 12
+ condition:
+ player_flag: has_vault_key
+ blocked_message: "The vault door is locked. You need a key."
+```
+
+Conditional exit with compound condition — requires both a world flag AND a player flag:
+```yaml
+exits:
+ north:
+ room: 20
+ condition:
+ all_of:
+ - flag: bridge_repaired
+ - player_flag: paid_toll
+ blocked_message: "The bridge is out, and the toll collector blocks the path."
+```
+
+Exit that sets flags when used — `set_flags` / `set_player_flags` are applied
+only when the player actually moves through the exit (not when it's blocked):
+```yaml
+exits:
+ north:
+ room: 21
+ condition:
+ player_flag: lined_up
+ set_player_flags:
+ boarded_shuttle: true # marks "left this area" on the way out
+```
+
+### Item Spawns — ground items that respawn
+
+```yaml
+item_spawns:
+ - id: bronze_pickaxe
+ quantity: 1
+ respawn_ticks: 30 # reappears 30 ticks (18 seconds) after being picked up
+ - id: copper_ore
+ quantity: 3
+ respawn_ticks: 50
+```
+
+### Mobs — NPCs placed in the room
+
+Simple string (no wandering):
+```yaml
+mobs:
+ - "newbie_trainer"
+ - "man"
+```
+
+With wander config per-instance:
+```yaml
+mobs:
+ - id: man
+ wander_interval: 10 # attempts to wander every 10 ticks
+ - id: man
+ wander_interval: 15
+ wander_rooms: [1, 4, 5] # optional — only exit to these rooms
+```
+
+Mob wander config lives in the room YAML, not in the mob definition. This keeps mobs generic
+so the same `man` can wander differently depending on where it's placed. Mobs wander through
+legal (unconditioned) room exits. If no legal exits exist, the mob stays still. Mobs with
+no `wander_interval` never wander.
+
+### Objects — interactive fixtures
+
+```yaml
+objects:
+ - id: copper_rock # simple placement
+ - id: copper_rock # second instance
+ - id: fishing_spot
+ wander_rooms: [7, 8, 9] # teleports between these rooms
+ wander_interval: 12 # every 12 ticks
+ - id: iron_gate # hidden object (see below)
+```
+
+### On-enter scripts — messages and timed cutscenes when a player arrives
+
+Each `on_enter` step shows a `message`, optionally gated by a `condition`. Steps
+whose condition fails are skipped.
+
+```yaml
+on_enter:
+ - message: "The guard barks: \"State your business!\""
+ condition:
+ player_flag: talked_to_guard
+ not: true # only the first visit
+
+ - message: "The guard nods. \"Back again?\""
+ condition:
+ player_flag: talked_to_guard # subsequent visits
+```
+
+**Timed cutscenes.** A step may also carry a `delay` (ticks to wait before it
+fires) and/or set flags (`set_flags` / `set_player_flags`). If any surviving step
+has a delay or sets a flag, the whole sequence runs as a scheduled cutscene;
+plain message-only scripts still print instantly.
+
+```yaml
+on_enter:
+ - condition: { player_flag: boarded, not: true }
+ delay: 5
+ message: "Some of the crowd look you up and down."
+ - condition: { player_flag: boarded, not: true }
+ delay: 5
+ message: "The pilot calls out: \"Tickets, please! Nice and orderly!\""
+ set_player_flags:
+ lined_up: true # opens an exit, flips a description, etc.
+ - condition: { player_flag: boarded, not: true }
+ message: "The crowd forms a single-file line."
+```
+
+Notes:
+- `delay` counts ticks before the step fires; `delay: 0` (or omitted) fires on the next tick.
+- Conditions are evaluated **once** on entry, so a flag a step sets won't cancel a later step in the same sequence.
+- Gate a cutscene on a flag the sequence itself sets (above, `lined_up`) so it doesn't replay on a return visit.
+- If a player disconnects mid-cutscene it resumes on reconnect, so they can't get stuck behind an exit the cutscene was meant to open. Plain `on_enter` messages never replay on login.
+
+### Conditional room descriptions
+
+A room can show a different description depending on the looking player's flags.
+`descriptions` are checked top-to-bottom; the first whose condition passes wins,
+otherwise the base `description` is used.
+
+```yaml
+description: "A large, empty concrete pad in the middle of the ocean."
+descriptions:
+ - condition:
+ player_flag: boarded
+ not: true
+ text: "A concrete pad swarming with a couple dozen anxious passengers."
+```
+
+### Hazardous rooms — environmental danger
+
+A room can reference a shared hazard by ID. The hazard rolls an attack against
+everyone in the room every few ticks using the combat formulas (see `hazards.md`).
+
+```yaml
+id: 99999181
+name: "Exposed Solar Array"
+hazard: solar_radiation # ID of a data/hazards/<id>.yaml definition
+exits:
+ south: 99999180
+objects:
+ - id: rock_outcrop # a safespot object shields players from the hazard
+mobs:
+ - solar_panel_frame # a task worksite (see mobs.md > Task Mobs)
+```
+
+- Players are warned with a `[Y/n]` prompt before walking from a **safe** room into a
+ **hazardous** one (unless they disable the `danger_warning` option). Moving between
+ two hazardous rooms does not re-prompt.
+- A safespot object (an overhang, alcove, rock outcrop, …) shields a hidden player from
+ **all** hazard damage — but performing a melee attack/work step forces you out of cover.
+- Hazards stack with mobs: an aggressive mob in a hazardous room hits you while the room
+ hazard also rolls against you.
+
+---
+
diff --git a/worldbuilding_guide/search.md b/building_guide/search.md
index e6e2a14..e6e2a14 100644
--- a/worldbuilding_guide/search.md
+++ b/building_guide/search.md
diff --git a/worldbuilding_guide/state.md b/building_guide/state.md
index 2eefcc4..2eefcc4 100644
--- a/worldbuilding_guide/state.md
+++ b/building_guide/state.md
diff --git a/worldbuilding_guide/tips.md b/building_guide/tips.md
index 307ea1e..307ea1e 100644
--- a/worldbuilding_guide/tips.md
+++ b/building_guide/tips.md
diff --git a/worldbuilding_guide/wandering_mobs.md b/building_guide/wandering_mobs.md
index 8d49ed9..8d49ed9 100644
--- a/worldbuilding_guide/wandering_mobs.md
+++ b/building_guide/wandering_mobs.md
diff --git a/worldbuilding_guide/wandering_objects.md b/building_guide/wandering_objects.md
index f1f91c5..f1f91c5 100644
--- a/worldbuilding_guide/wandering_objects.md
+++ b/building_guide/wandering_objects.md
diff --git a/cmd/mud/main.go b/cmd/mud/main.go
index e9d0aed..773d3c9 100644
--- a/cmd/mud/main.go
+++ b/cmd/mud/main.go
@@ -42,6 +42,7 @@ func main() {
g.Ticks.Subscribe(1, func() bool {
g.MoveTick()
g.ProcessQueuedCommands()
+ g.EnterSeqTick()
g.World.Tick()
g.MobStore.Tick()
g.RegenTick()
@@ -58,6 +59,7 @@ func main() {
g.FarmTick()
g.BuffTick()
g.SafespotTick()
+ g.HazardTick()
return true
})
diff --git a/config.yaml b/config.yaml
index ffe9d3b..5c3e3bc 100644
--- a/config.yaml
+++ b/config.yaml
@@ -1,5 +1,8 @@
game:
tick_length: 600
+ startup_validation:
+ check_sources: [1001]
+ ignore_unreachable: []
colors:
room_name: "81 bold"
diff --git a/data/courses/reactor.yaml b/data/courses/reactor.yaml
index fcf2824..22d1a2b 100644
--- a/data/courses/reactor.yaml
+++ b/data/courses/reactor.yaml
@@ -1,10 +1,10 @@
id: "reactor"
name: "Reactor Course"
required_level: 50
-start_room: 200
+start_room: 99999200
completion_xp: 350
obstacles:
- - room_id: 220
+ - room_id: 99999220
verb: climb
ticks_per_phase: 3
xp: 40
@@ -13,7 +13,7 @@ obstacles:
- "You grip the reactor scaffolding and begin your ascent..."
- "The metal groans under your weight as you climb higher, heat radiating from below..."
- "You pull yourself onto the upper platform, the reactor humming beneath you!"
- - room_id: 221
+ - room_id: 99999221
verb: swing
ticks_per_phase: 3
xp: 45
@@ -22,7 +22,7 @@ obstacles:
- "You seize the heavy coolant chain dangling above the reactor pit..."
- "You swing out over the glowing core, heat blasting your face..."
- "You release and land hard on the maintenance gantry, chain clanging behind you!"
- - room_id: 222
+ - room_id: 99999222
verb: balance
ticks_per_phase: 3
xp: 50
@@ -31,7 +31,7 @@ obstacles:
- "You step onto the massive steam pipe spanning the reactor chamber..."
- "Steam jets hiss from valves on either side as you shuffle along the pipe..."
- "You reach the junction platform and hop off the pipe with relief!"
- - room_id: 223
+ - room_id: 99999223
verb: jump
ticks_per_phase: 2
xp: 45
@@ -40,7 +40,7 @@ obstacles:
- "A section of the reactor platform is missing, leaving a gaping void..."
- "You back up, sprint, and leap with everything you've got..."
- "You slam into the far platform and roll to safety!"
- - room_id: 224
+ - room_id: 99999224
verb: crawl
ticks_per_phase: 3
xp: 40
@@ -49,7 +49,7 @@ obstacles:
- "You squeeze into the narrow service conduit, radiation warnings plastered on every surface..."
- "You drag yourself through on your elbows, sparks showering from damaged wiring above..."
- "You tumble out the far end and gulp down clean air!"
- - room_id: 225
+ - room_id: 99999225
verb: vault
ticks_per_phase: 2
xp: 50
@@ -58,7 +58,7 @@ obstacles:
- "A radiation containment barrier blocks the path, humming with energy..."
- "You time the pulse cycle, sprint at the barrier, and throw yourself over it..."
- "You clear the barrier and land on the other side, heart pounding!"
- - room_id: 226
+ - room_id: 99999226
verb: leap
ticks_per_phase: 3
xp: 55
diff --git a/data/courses/rooftop.yaml b/data/courses/rooftop.yaml
index 4cc6748..3a24f89 100644
--- a/data/courses/rooftop.yaml
+++ b/data/courses/rooftop.yaml
@@ -1,10 +1,10 @@
id: "rooftop"
name: "Rooftop Course"
required_level: 20
-start_room: 200
+start_room: 99999200
completion_xp: 120
obstacles:
- - room_id: 210
+ - room_id: 99999210
verb: climb
ticks_per_phase: 2
xp: 15
@@ -13,7 +13,7 @@ obstacles:
- "You grab the rough permacrete wall and begin to climb..."
- "Your fingers find cracks and ledges as you pull yourself higher..."
- "You heave yourself over the edge and onto the rooftop!"
- - room_id: 211
+ - room_id: 99999211
verb: jump
ticks_per_phase: 2
xp: 22
@@ -22,7 +22,7 @@ obstacles:
- "You eye the gap between this building and the next..."
- "You sprint toward the edge, boots pounding on the rooftop..."
- "You launch yourself across and roll to a stop on the other roof!"
- - room_id: 212
+ - room_id: 99999212
verb: swing
ticks_per_phase: 3
xp: 20
@@ -31,7 +31,7 @@ obstacles:
- "You grab the dangling power cable with both hands..."
- "You kick off the ledge and swing out over the street far below..."
- "You release at the peak of the arc and land on the opposite platform!"
- - room_id: 213
+ - room_id: 99999213
verb: balance
ticks_per_phase: 3
xp: 18
@@ -40,7 +40,7 @@ obstacles:
- "You step onto the narrow structural beam spanning the alley..."
- "The beam sways slightly as you inch forward, arms out for balance..."
- "You reach the far side and step gratefully onto solid rooftop!"
- - room_id: 214
+ - room_id: 99999214
verb: vault
ticks_per_phase: 2
xp: 15
@@ -49,7 +49,7 @@ obstacles:
- "You run toward the maintenance railing at full speed..."
- "You plant one hand on the rail and swing your legs over..."
- "You clear the railing and land in a crouch on the other side!"
- - room_id: 215
+ - room_id: 99999215
verb: leap
ticks_per_phase: 2
xp: 20
diff --git a/data/courses/vent_shaft.yaml b/data/courses/vent_shaft.yaml
index a59a16c..643b39f 100644
--- a/data/courses/vent_shaft.yaml
+++ b/data/courses/vent_shaft.yaml
@@ -1,10 +1,10 @@
id: "vent_shaft"
name: "Ventilation Shaft Course"
required_level: 1
-start_room: 200
+start_room: 99999200
completion_xp: 40
obstacles:
- - room_id: 201
+ - room_id: 99999201
verb: scramble
ticks_per_phase: 2
xp: 8
@@ -13,7 +13,7 @@ obstacles:
- "You approach the corroded ventilation wall..."
- "You find footholds in the rusted panels and begin to climb..."
- "You scramble up the wall and haul yourself onto the ledge!"
- - room_id: 202
+ - room_id: 99999202
verb: balance
ticks_per_phase: 2
xp: 8
@@ -22,7 +22,7 @@ obstacles:
- "You step onto the narrow coolant pipe..."
- "Arms outstretched, you carefully place one foot in front of the other..."
- "You reach the other side of the pipe and step onto solid ground!"
- - room_id: 203
+ - room_id: 99999203
verb: jump
ticks_per_phase: 2
xp: 7
@@ -31,7 +31,7 @@ obstacles:
- "You peer across the dark gap in the shaft floor..."
- "You take a few steps back, then sprint toward the edge..."
- "You leap across the gap and land safely on the other side!"
- - room_id: 204
+ - room_id: 99999204
verb: crawl
ticks_per_phase: 3
xp: 7
@@ -40,7 +40,7 @@ obstacles:
- "You drop to your hands and knees at the narrow vent opening..."
- "You squeeze through the tight passage, metal scraping against your back..."
- "You emerge from the vent and stand up, brushing dust from your clothes!"
- - room_id: 205
+ - room_id: 99999205
verb: slide
ticks_per_phase: 2
xp: 10
diff --git a/data/hazards/solar_radiation.yaml b/data/hazards/solar_radiation.yaml
new file mode 100644
index 0000000..40afee6
--- /dev/null
+++ b/data/hazards/solar_radiation.yaml
@@ -0,0 +1,12 @@
+id: solar_radiation
+name: scorching solar radiation
+description: "Unfiltered sunlight hammers down through a gap in the atmospheric dome. Without shade or shielding, the radiation burns exposed skin and overheats equipment."
+attack_type: science
+attack: 40
+attack_bonus: 0
+max_hit: 6
+speed: 5
+warn_message: "The dome shielding fails here — raw solar radiation pours in."
+hit_message: "Solar radiation sears you for %d damage."
+miss_message: "You shield your eyes and weather a wave of solar glare."
+required_item: ""
diff --git a/data/help/combat.yaml b/data/help/combat.yaml
index 210a6da..f588d81 100644
--- a/data/help/combat.yaml
+++ b/data/help/combat.yaml
@@ -11,7 +11,7 @@ description: |
but weak crush defense takes more hits from a mace than a sword.
Use the 'style' command to change your combat style:
- accurate - +3 attack level, XP to attack
+ accurate - +3 accuracy level, XP to accuracy
aggressive - +3 strength level, XP to strength
defensive - +3 defense level, XP to defense
balanced - +1 to all, XP split evenly
diff --git a/data/help/drink.yaml b/data/help/drink.yaml
index 98843b3..b9506d7 100644
--- a/data/help/drink.yaml
+++ b/data/help/drink.yaml
@@ -6,7 +6,7 @@ description: |
Usage: drink <potion>
Potions provide various effects:
- - Stim potions: boost Attack
+ - Stim potions: boost Accuracy
- Amp potions: boost Strength
- Shield potions: boost Defense
- Tech serum: boosts Technology
diff --git a/data/help/prompt.yaml b/data/help/prompt.yaml
index 2b35f41..37a98f9 100644
--- a/data/help/prompt.yaml
+++ b/data/help/prompt.yaml
@@ -23,7 +23,7 @@ description: |
%S Attack style longform (accurate, aggressive, defensive, balanced)
%i Free inventory slots
%x_<skill> XP to next level for a skill (e.g. %x_mining)
- %X_<skill> Total XP for a skill (e.g. %X_attack)
+ %X_<skill> Total XP for a skill (e.g. %X_accuracy)
Use %% for a literal % sign.
diff --git a/data/help/style.yaml b/data/help/style.yaml
index c4f4e67..663dc07 100644
--- a/data/help/style.yaml
+++ b/data/help/style.yaml
@@ -6,7 +6,7 @@ description: |
Changes your combat style. Prefix matching is supported.
Styles:
- accurate - +3 to attack level, XP goes to Attack
+ accurate - +3 to accuracy level, XP goes to Accuracy
aggressive - +3 to strength level, XP goes to Strength
defensive - +3 to defense level, XP goes to Defense
balanced - +1 to all three, XP split evenly
diff --git a/data/items/consumables/stim_potion.yaml b/data/items/consumables/stim_potion.yaml
index 34e7282..4ae10ff 100644
--- a/data/items/consumables/stim_potion.yaml
+++ b/data/items/consumables/stim_potion.yaml
@@ -17,6 +17,6 @@ id: stim_potion
name: stim potion
potion_bonus: 10
potion_duration: 200
-potion_effect: attack
+potion_effect: accuracy
stackable: false
value: 30
diff --git a/data/items/consumables/super_stim.yaml b/data/items/consumables/super_stim.yaml
index 00f3aa1..f8e4756 100644
--- a/data/items/consumables/super_stim.yaml
+++ b/data/items/consumables/super_stim.yaml
@@ -17,6 +17,6 @@ id: super_stim
name: super stim
potion_bonus: 20
potion_duration: 200
-potion_effect: attack
+potion_effect: accuracy
stackable: false
value: 120
diff --git a/data/items/equipment/adamant_dagger.yaml b/data/items/equipment/adamant_dagger.yaml
index a483667..7875c3a 100644
--- a/data/items/equipment/adamant_dagger.yaml
+++ b/data/items/equipment/adamant_dagger.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: adamant_dagger
name: adamant dagger
requirements:
- attack: 30
+ accuracy: 30
speed: 3
stackable: false
stats:
diff --git a/data/items/equipment/adamant_sword.yaml b/data/items/equipment/adamant_sword.yaml
index cce058c..b1aaccd 100644
--- a/data/items/equipment/adamant_sword.yaml
+++ b/data/items/equipment/adamant_sword.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: adamant_sword
name: adamant sword
requirements:
- attack: 30
+ accuracy: 30
speed: 4
stackable: false
stats:
diff --git a/data/items/equipment/black_axe.yaml b/data/items/equipment/black_axe.yaml
index e708645..49fc8f5 100644
--- a/data/items/equipment/black_axe.yaml
+++ b/data/items/equipment/black_axe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: axe
tool_speed: 2.5
requirements:
- attack: 10
+ accuracy: 10
diff --git a/data/items/equipment/dragon_axe.yaml b/data/items/equipment/dragon_axe.yaml
index ddc9758..caef5bb 100644
--- a/data/items/equipment/dragon_axe.yaml
+++ b/data/items/equipment/dragon_axe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: axe
tool_speed: 1
requirements:
- attack: 60
+ accuracy: 60
diff --git a/data/items/equipment/mithril_dagger.yaml b/data/items/equipment/mithril_dagger.yaml
index 0d531e1..8543198 100644
--- a/data/items/equipment/mithril_dagger.yaml
+++ b/data/items/equipment/mithril_dagger.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: mithril_dagger
name: mithril dagger
requirements:
- attack: 20
+ accuracy: 20
speed: 3
stackable: false
stats:
diff --git a/data/items/equipment/mithril_sword.yaml b/data/items/equipment/mithril_sword.yaml
index 53dcd25..397d044 100644
--- a/data/items/equipment/mithril_sword.yaml
+++ b/data/items/equipment/mithril_sword.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: mithril_sword
name: mithril sword
requirements:
- attack: 20
+ accuracy: 20
speed: 4
stackable: false
stats:
diff --git a/data/items/equipment/rune_dagger.yaml b/data/items/equipment/rune_dagger.yaml
index f1f53f6..2068380 100644
--- a/data/items/equipment/rune_dagger.yaml
+++ b/data/items/equipment/rune_dagger.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: rune_dagger
name: rune dagger
requirements:
- attack: 40
+ accuracy: 40
speed: 3
stackable: false
stats:
diff --git a/data/items/equipment/rune_sword.yaml b/data/items/equipment/rune_sword.yaml
index 3451450..12daf4d 100644
--- a/data/items/equipment/rune_sword.yaml
+++ b/data/items/equipment/rune_sword.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: rune_sword
name: rune sword
requirements:
- attack: 40
+ accuracy: 40
speed: 4
stackable: false
stats:
diff --git a/data/items/equipment/steel_dagger.yaml b/data/items/equipment/steel_dagger.yaml
index 9df7d14..fc9f81f 100644
--- a/data/items/equipment/steel_dagger.yaml
+++ b/data/items/equipment/steel_dagger.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: steel_dagger
name: steel dagger
requirements:
- attack: 5
+ accuracy: 5
speed: 3
stackable: false
stats:
diff --git a/data/items/equipment/steel_sword.yaml b/data/items/equipment/steel_sword.yaml
index b0ca858..66023f4 100644
--- a/data/items/equipment/steel_sword.yaml
+++ b/data/items/equipment/steel_sword.yaml
@@ -17,7 +17,7 @@ equip_slot: main_hand
id: steel_sword
name: steel sword
requirements:
- attack: 5
+ accuracy: 5
speed: 4
stackable: false
stats:
diff --git a/data/items/tools/adamant_axe.yaml b/data/items/tools/adamant_axe.yaml
index 81232fe..0172019 100644
--- a/data/items/tools/adamant_axe.yaml
+++ b/data/items/tools/adamant_axe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: axe
tool_speed: 2
requirements:
- attack: 30
+ accuracy: 30
diff --git a/data/items/tools/adamant_pickaxe.yaml b/data/items/tools/adamant_pickaxe.yaml
index 4296b0d..862f6e6 100644
--- a/data/items/tools/adamant_pickaxe.yaml
+++ b/data/items/tools/adamant_pickaxe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: pickaxe
tool_speed: 0.75
requirements:
- attack: 30
+ accuracy: 30
diff --git a/data/items/tools/black_pickaxe.yaml b/data/items/tools/black_pickaxe.yaml
index c3df955..083ad3a 100644
--- a/data/items/tools/black_pickaxe.yaml
+++ b/data/items/tools/black_pickaxe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: pickaxe
tool_speed: 1
requirements:
- attack: 10
+ accuracy: 10
diff --git a/data/items/tools/dragon_pickaxe.yaml b/data/items/tools/dragon_pickaxe.yaml
index 6f187b8..20b43b1 100644
--- a/data/items/tools/dragon_pickaxe.yaml
+++ b/data/items/tools/dragon_pickaxe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: pickaxe
tool_speed: 0.25
requirements:
- attack: 60
+ accuracy: 60
diff --git a/data/items/tools/mithril_axe.yaml b/data/items/tools/mithril_axe.yaml
index ca18195..f153f12 100644
--- a/data/items/tools/mithril_axe.yaml
+++ b/data/items/tools/mithril_axe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: axe
tool_speed: 2.5
requirements:
- attack: 20
+ accuracy: 20
diff --git a/data/items/tools/mithril_pickaxe.yaml b/data/items/tools/mithril_pickaxe.yaml
index 5da6937..81da209 100644
--- a/data/items/tools/mithril_pickaxe.yaml
+++ b/data/items/tools/mithril_pickaxe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: pickaxe
tool_speed: 1
requirements:
- attack: 20
+ accuracy: 20
diff --git a/data/items/tools/rune_axe.yaml b/data/items/tools/rune_axe.yaml
index a694bdf..bc99732 100644
--- a/data/items/tools/rune_axe.yaml
+++ b/data/items/tools/rune_axe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: axe
tool_speed: 1.5
requirements:
- attack: 40
+ accuracy: 40
diff --git a/data/items/tools/rune_pickaxe.yaml b/data/items/tools/rune_pickaxe.yaml
index 265231d..7691513 100644
--- a/data/items/tools/rune_pickaxe.yaml
+++ b/data/items/tools/rune_pickaxe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: pickaxe
tool_speed: 0.5
requirements:
- attack: 40
+ accuracy: 40
diff --git a/data/items/tools/steel_axe.yaml b/data/items/tools/steel_axe.yaml
index 6f2ac99..1f62e33 100644
--- a/data/items/tools/steel_axe.yaml
+++ b/data/items/tools/steel_axe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: axe
tool_speed: 3
requirements:
- attack: 5
+ accuracy: 5
diff --git a/data/items/tools/steel_pickaxe.yaml b/data/items/tools/steel_pickaxe.yaml
index cc048a1..560da07 100644
--- a/data/items/tools/steel_pickaxe.yaml
+++ b/data/items/tools/steel_pickaxe.yaml
@@ -16,4 +16,4 @@ speed: 5
tool_type: pickaxe
tool_speed: 1.25
requirements:
- attack: 5
+ accuracy: 5
diff --git a/data/mobs/solar_panel_frame.yaml b/data/mobs/solar_panel_frame.yaml
new file mode 100644
index 0000000..4270bda
--- /dev/null
+++ b/data/mobs/solar_panel_frame.yaml
@@ -0,0 +1,34 @@
+id: solar_panel_frame
+name: solar panel frame
+description: "A half-assembled photovoltaic array on a steel frame. Panels, brackets and cabling are laid out, waiting to be fitted. This is work, not a fight — fix the panels into place to complete the array."
+kind: task
+verb: assemble
+progress_noun: assembly
+complete_message: "You bolt the final panel into place and the solar array hums to life!"
+hp: 40
+defense: 10
+speed: 5
+aggressive: false
+respawn_ticks: 40
+attack_type: crush
+crush_defense: 0
+stab_defense: 40
+slash_defense: 40
+ranged_defense: 10
+science_defense: 10
+idle_descriptions:
+ - "stands half-assembled, panels waiting to be fitted"
+ - "has loose cabling dangling from an open junction box"
+ - "catches the light on its few mounted panels"
+size: medium
+drops:
+ loot:
+ - item_id: credits
+ weight: 70
+ quantity: 40
+ - item_id: credits
+ weight: 25
+ quantity: 90
+ - item_id: coal
+ weight: 5
+ quantity: 1
diff --git a/data/objects/1002_crowd.yaml b/data/objects/1002_crowd.yaml
new file mode 100644
index 0000000..8f8f099
--- /dev/null
+++ b/data/objects/1002_crowd.yaml
@@ -0,0 +1,18 @@
+id: 1002_crowd
+name: crowd of people
+aliases: [people, crowd, group, passengers]
+hidden: true
+descriptions:
+ - condition:
+ all_of:
+ - player_flag: denali_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."
+ - condition:
+ all_of:
+ - player_flag: denali_intro_done
+ not: true
+ - player_flag: denali_lined_up
+ not: true
+ text: "A couple dozen anxious passengers mill about the pad, glancing between you and the small building to the north."
diff --git a/data/objects/1002_pilot.yaml b/data/objects/1002_pilot.yaml
new file mode 100644
index 0000000..61264b2
--- /dev/null
+++ b/data/objects/1002_pilot.yaml
@@ -0,0 +1,10 @@
+id: 1002_pilot
+name: pilot
+hidden: true
+descriptions:
+ - condition:
+ all_of:
+ - player_flag: denali_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."
diff --git a/data/objects/1002_shuttle.yaml b/data/objects/1002_shuttle.yaml
new file mode 100644
index 0000000..6ac3a90
--- /dev/null
+++ b/data/objects/1002_shuttle.yaml
@@ -0,0 +1,9 @@
+id: 1002_shuttle
+name: landing craft
+aliases: [shuttle, ship]
+hidden: true
+descriptions:
+ - condition:
+ player_flag: denali_intro_done
+ not: true
+ text: "The battered landing craft that carried you down still ticks and steams as it cools on the pad."
diff --git a/data/rooms/agility/200.yaml b/data/rooms/example/agility/99999200.yaml
index 469e214..c44cb6a 100644
--- a/data/rooms/agility/200.yaml
+++ b/data/rooms/example/agility/99999200.yaml
@@ -1,8 +1,8 @@
-id: 200
+id: 99999200
name: "Agility Training Grounds"
description: "A cavernous space beneath the asteroid's surface, repurposed as a training facility. Scaffolding, pipes, and platforms fill the chamber. Signs point to three courses of increasing difficulty: {33}Ventilation Shaft{/} (beginner), {214}Rooftop{/} (intermediate), and {196}Reactor{/} (advanced)."
exits:
- south: 17
- north: 201
- east: 210
- west: 220
+ south: 9999917
+ north: 99999201
+ east: 99999210
+ west: 99999220
diff --git a/data/rooms/agility/201.yaml b/data/rooms/example/agility/99999201.yaml
index 2beb2ec..f551fe1 100644
--- a/data/rooms/agility/201.yaml
+++ b/data/rooms/example/agility/99999201.yaml
@@ -1,9 +1,9 @@
-id: 201
+id: 99999201
name: "Ventilation Shaft - Corroded Wall"
description: "A towering wall of corroded ventilation panels rises before you. Rust-eaten handholds and buckled seams offer a treacherous path upward. The air smells of old metal and recycled atmosphere."
on_enter:
- message: "Type 'scramble' to climb the wall."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/202.yaml b/data/rooms/example/agility/99999202.yaml
index b9c0292..1d33043 100644
--- a/data/rooms/agility/202.yaml
+++ b/data/rooms/example/agility/99999202.yaml
@@ -1,9 +1,9 @@
-id: 202
+id: 99999202
name: "Ventilation Shaft - Coolant Pipe Walkway"
description: "A narrow coolant pipe stretches across a dark chasm. Condensation drips from its surface, making it slick. Far below, you can hear the distant hum of machinery."
on_enter:
- message: "Type 'balance' to cross the pipe."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/203.yaml b/data/rooms/example/agility/99999203.yaml
index 4f5d883..529bad6 100644
--- a/data/rooms/agility/203.yaml
+++ b/data/rooms/example/agility/99999203.yaml
@@ -1,9 +1,9 @@
-id: 203
+id: 99999203
name: "Ventilation Shaft - Shaft Gap"
description: "The ventilation shaft floor is missing here -- a jagged gap drops into darkness. The far side is just barely within jumping distance. Exposed wiring sparks intermittently below."
on_enter:
- message: "Type 'jump' to leap across the gap."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/204.yaml b/data/rooms/example/agility/99999204.yaml
index 2d82040..7ddf6a1 100644
--- a/data/rooms/agility/204.yaml
+++ b/data/rooms/example/agility/99999204.yaml
@@ -1,9 +1,9 @@
-id: 204
+id: 99999204
name: "Ventilation Shaft - Narrow Vent"
description: "The passage narrows dramatically here, becoming a tight rectangular vent barely wide enough to fit through. Scratches on the metal walls suggest others have squeezed through before you."
on_enter:
- message: "Type 'crawl' to squeeze through the vent."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/205.yaml b/data/rooms/example/agility/99999205.yaml
index d814f7d..3810925 100644
--- a/data/rooms/agility/205.yaml
+++ b/data/rooms/example/agility/99999205.yaml
@@ -1,9 +1,9 @@
-id: 205
+id: 99999205
name: "Ventilation Shaft - Emergency Chute"
description: "A smooth metal chute angles steeply downward, polished by countless slides. An old emergency evacuation sign hangs crookedly on the wall. This is the final obstacle -- the chute leads back to the training grounds."
on_enter:
- message: "Type 'slide' to descend the chute."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/210.yaml b/data/rooms/example/agility/99999210.yaml
index 2b86500..0ecfb1a 100644
--- a/data/rooms/agility/210.yaml
+++ b/data/rooms/example/agility/99999210.yaml
@@ -1,9 +1,9 @@
-id: 210
+id: 99999210
name: "Rooftop Course - Hab Block Wall"
description: "The exterior wall of Hab Block 7 rises three stories above the street. Rough permacrete and maintenance handholds provide a challenging climb. The city spreads out below, neon signs flickering in the perpetual twilight."
on_enter:
- message: "Type 'climb' to scale the wall."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/211.yaml b/data/rooms/example/agility/99999211.yaml
index 8b1f069..99497e5 100644
--- a/data/rooms/agility/211.yaml
+++ b/data/rooms/example/agility/99999211.yaml
@@ -1,9 +1,9 @@
-id: 211
+id: 99999211
name: "Rooftop Course - Building Gap"
description: "You stand on the edge of Hab Block 7's roof. Across a three-meter gap, the roof of Hab Block 8 awaits. The street below is a dizzying drop. A few old bootprints mark the takeoff point."
on_enter:
- message: "Type 'jump' to leap to the next building."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/212.yaml b/data/rooms/example/agility/99999212.yaml
index 0393a7e..c4bc14d 100644
--- a/data/rooms/agility/212.yaml
+++ b/data/rooms/example/agility/99999212.yaml
@@ -1,9 +1,9 @@
-id: 212
+id: 99999212
name: "Rooftop Course - Cable Array"
description: "A tangle of power cables and data lines stretches between two antenna towers. One thick cable hangs low enough to grab. The gap below drops to a dark alleyway between hab blocks."
on_enter:
- message: "Type 'swing' to cross on the cable."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/213.yaml b/data/rooms/example/agility/99999213.yaml
index be3b736..79da0d2 100644
--- a/data/rooms/agility/213.yaml
+++ b/data/rooms/example/agility/99999213.yaml
@@ -1,9 +1,9 @@
-id: 213
+id: 99999213
name: "Rooftop Course - Narrow Beam"
description: "A structural I-beam extends across the gap between two buildings, no wider than your foot. It sways slightly in the recycled air currents. Someone has scratched tally marks into the near end."
on_enter:
- message: "Type 'balance' to cross the beam."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/214.yaml b/data/rooms/example/agility/99999214.yaml
index 9f9e081..8a040cd 100644
--- a/data/rooms/agility/214.yaml
+++ b/data/rooms/example/agility/99999214.yaml
@@ -1,9 +1,9 @@
-id: 214
+id: 99999214
name: "Rooftop Course - Maintenance Railing"
description: "A high maintenance railing blocks the path forward, topped with sensor equipment and warning labels. It's too high to step over but the right technique could clear it. Beyond the railing, the course continues."
on_enter:
- message: "Type 'vault' to clear the railing."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/215.yaml b/data/rooms/example/agility/99999215.yaml
index 7ea39f9..4a740f7 100644
--- a/data/rooms/agility/215.yaml
+++ b/data/rooms/example/agility/99999215.yaml
@@ -1,9 +1,9 @@
-id: 215
+id: 99999215
name: "Rooftop Course - Rooftop Edge"
description: "The final jump. The gap here is wider than any before -- a full four meters of empty air between you and the landing platform. Far below, the streets of the asteroid colony pulse with dim light. This is the last obstacle."
on_enter:
- message: "Type 'leap' to make the final jump."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/220.yaml b/data/rooms/example/agility/99999220.yaml
index d6826a3..f063231 100644
--- a/data/rooms/agility/220.yaml
+++ b/data/rooms/example/agility/99999220.yaml
@@ -1,9 +1,9 @@
-id: 220
+id: 99999220
name: "Reactor Course - Scaffolding"
description: "Massive metal scaffolding surrounds the outer reactor housing. The structure vibrates with the reactor's pulse. Heat radiates from every surface, and warning klaxons sound periodically in the distance."
on_enter:
- message: "Type 'climb' to ascend the scaffolding."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/221.yaml b/data/rooms/example/agility/99999221.yaml
index 936ee65..bebfcf1 100644
--- a/data/rooms/agility/221.yaml
+++ b/data/rooms/example/agility/99999221.yaml
@@ -1,9 +1,9 @@
-id: 221
+id: 99999221
name: "Reactor Course - Coolant Chain"
description: "A heavy chain hangs from an overhead crane, suspended above the reactor cooling pit. The pit glows with an eerie blue-green light. The chain is your only way across -- the gantry ahead is the landing zone."
on_enter:
- message: "Type 'swing' to cross on the chain."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/222.yaml b/data/rooms/example/agility/99999222.yaml
index 119db01..17d6395 100644
--- a/data/rooms/agility/222.yaml
+++ b/data/rooms/example/agility/99999222.yaml
@@ -1,9 +1,9 @@
-id: 222
+id: 99999222
name: "Reactor Course - Steam Pipe"
description: "An enormous steam pipe, two meters in diameter, stretches across the reactor chamber. Steam vents periodically blast from pressure valves along its length. The pipe's surface is warm but not scalding -- yet."
on_enter:
- message: "Type 'balance' to traverse the pipe."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/223.yaml b/data/rooms/example/agility/99999223.yaml
index 0e557f6..b945e7e 100644
--- a/data/rooms/agility/223.yaml
+++ b/data/rooms/example/agility/99999223.yaml
@@ -1,9 +1,9 @@
-id: 223
+id: 99999223
name: "Reactor Course - Platform Gap"
description: "A section of the reactor maintenance platform has collapsed into the void below. Emergency barriers block the edges, but someone has moved them aside here. The gap is intimidating but clearable."
on_enter:
- message: "Type 'jump' to clear the gap."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/224.yaml b/data/rooms/example/agility/99999224.yaml
index 4e70f7a..0c9f2e0 100644
--- a/data/rooms/agility/224.yaml
+++ b/data/rooms/example/agility/99999224.yaml
@@ -1,9 +1,9 @@
-id: 224
+id: 99999224
name: "Reactor Course - Service Conduit"
description: "A narrow service conduit leads through the reactor shielding. Radiation warning symbols are painted on every surface. Damaged wiring hangs from the ceiling, sparking occasionally. It's the only way forward."
on_enter:
- message: "Type 'crawl' to enter the conduit."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/225.yaml b/data/rooms/example/agility/99999225.yaml
index 3413792..feb32e9 100644
--- a/data/rooms/agility/225.yaml
+++ b/data/rooms/example/agility/99999225.yaml
@@ -1,9 +1,9 @@
-id: 225
+id: 99999225
name: "Reactor Course - Radiation Barrier"
description: "A containment barrier hums with energy, its surface shimmering with a faint purple glow. It pulses on and off in a regular cycle. Beyond it, the final stretch of the course is visible."
on_enter:
- message: "Type 'vault' to clear the barrier."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/agility/226.yaml b/data/rooms/example/agility/99999226.yaml
index 6faa2c1..cd1c979 100644
--- a/data/rooms/agility/226.yaml
+++ b/data/rooms/example/agility/99999226.yaml
@@ -1,9 +1,9 @@
-id: 226
+id: 99999226
name: "Reactor Course - Reactor Chasm"
description: "The final obstacle. A massive chasm separates you from the exit platform, the reactor's coolant pool churning far below in shades of luminous green. A narrow runway of grating leads to the edge. This is the longest leap on the course."
on_enter:
- message: "Type 'leap' to make the final jump."
exits:
down:
- room: 200
+ room: 99999200
blocked_message: ""
diff --git a/data/rooms/player_housing/300.yaml b/data/rooms/example/player_housing/99999300.yaml
index 06f1a07..124b278 100644
--- a/data/rooms/player_housing/300.yaml
+++ b/data/rooms/example/player_housing/99999300.yaml
@@ -1,8 +1,8 @@
-id: 300
+id: 99999300
name: "Player House: Entrance Hall"
description: "A cozy entrance hall with polished floors and warm lighting. A welcome mat greets visitors at the door."
exits:
- south: 170
- north: 301
+ south: 99999170
+ north: 99999301
objects:
- id: charging_station
diff --git a/data/rooms/player_housing/301.yaml b/data/rooms/example/player_housing/99999301.yaml
index c2f3e65..ebaba3d 100644
--- a/data/rooms/player_housing/301.yaml
+++ b/data/rooms/example/player_housing/99999301.yaml
@@ -1,8 +1,8 @@
-id: 301
+id: 99999301
name: "Player House: Workshop"
description: "A well-lit workshop with a sturdy workbench covered in tools and blueprints. Wood shavings litter the floor around the bench."
exits:
- south: 300
+ south: 99999300
objects:
- id: workbench
- id: bank_booth
diff --git a/data/rooms/town/1.yaml b/data/rooms/example/town/999991.yaml
index 81e9351..0231940 100644
--- a/data/rooms/town/1.yaml
+++ b/data/rooms/example/town/999991.yaml
@@ -1,11 +1,11 @@
-id: 1
+id: 999991
name: "Town Square"
description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district."
exits:
- east: 2
- west: 22
- up: 30
- down: 31
+ east: 999992
+ west: 9999922
+ up: 9999930
+ down: 9999931
objects:
- id: lumby_fountain
- id: charging_station
diff --git a/data/rooms/town/10.yaml b/data/rooms/example/town/9999910.yaml
index edeac4d..7dcbc2e 100644
--- a/data/rooms/town/10.yaml
+++ b/data/rooms/example/town/9999910.yaml
@@ -1,9 +1,9 @@
-id: 10
+id: 9999910
name: "Farming Plot"
description: "A small patch of tilled soil behind a weathered fence. This area is not yet accessible."
exits:
- west: 9
- east: 11
+ west: 999999
+ east: 9999911
mobs:
- id: skeleton
- id: skeleton
diff --git a/data/rooms/town/11.yaml b/data/rooms/example/town/9999911.yaml
index 9bc45a7..72b5337 100644
--- a/data/rooms/town/11.yaml
+++ b/data/rooms/example/town/9999911.yaml
@@ -1,9 +1,9 @@
-id: 11
+id: 9999911
name: "Kitchen"
description: "A warm kitchen with a stone hearth and wooden counters. A large iron cooking range dominates one wall, and shelves are stocked with ingredients."
exits:
- east: 12
- west: 10
+ east: 9999912
+ west: 9999910
objects:
- id: cooking_range
mobs:
diff --git a/data/rooms/town/12.yaml b/data/rooms/example/town/9999912.yaml
index e44ae3a..eee3aeb 100644
--- a/data/rooms/town/12.yaml
+++ b/data/rooms/example/town/9999912.yaml
@@ -1,9 +1,9 @@
-id: 12
+id: 9999912
name: "Forge"
description: "A blazing forge and anvil dominate this smoky workshop. The heat from the furnace is intense."
exits:
- east: 13
- west: 11
+ east: 9999913
+ west: 9999911
mobs:
- "weapons_smith"
objects:
diff --git a/data/rooms/town/13.yaml b/data/rooms/example/town/9999913.yaml
index dc07dc1..369fb66 100644
--- a/data/rooms/town/13.yaml
+++ b/data/rooms/example/town/9999913.yaml
@@ -1,9 +1,9 @@
-id: 13
+id: 9999913
name: "Crafting Workshop"
description: "A bustling workshop filled with crafting stations. A {130}spinning wheel{/} sits in one corner, a {173}pottery wheel{/} and {196}pottery oven{/} in another. Racks of tools and raw materials line the walls. The forge is just to the west."
exits:
- east: 14
- west: 12
+ east: 9999914
+ west: 9999912
objects:
- id: spinning_wheel
- id: pottery_wheel
diff --git a/data/rooms/town/14.yaml b/data/rooms/example/town/9999914.yaml
index ad515de..c003cf7 100644
--- a/data/rooms/town/14.yaml
+++ b/data/rooms/example/town/9999914.yaml
@@ -1,9 +1,9 @@
-id: 14
+id: 9999914
name: "Fletching Bench"
description: "A workbench covered in wood shavings, feathers, and arrowheads. Racks of unfinished bows line the walls, and a pile of bowstrings sits in the corner."
exits:
- east: 15
- west: 13
+ east: 9999915
+ west: 9999913
mobs:
- id: moss_giant
item_spawns:
diff --git a/data/rooms/town/15.yaml b/data/rooms/example/town/9999915.yaml
index a67624d..f632f3e 100644
--- a/data/rooms/town/15.yaml
+++ b/data/rooms/example/town/9999915.yaml
@@ -1,10 +1,10 @@
-id: 15
+id: 9999915
name: "Pharmacy Lab"
description: "Shelves lined with glass vials, dried herbs, and bubbling cauldrons. This area is not yet accessible."
exits:
- east: 16
- west: 14
- south: 150
+ east: 9999916
+ west: 9999914
+ south: 99999150
mobs:
- id: lesser_drone
- id: lesser_drone
diff --git a/data/rooms/town/16.yaml b/data/rooms/example/town/9999916.yaml
index 3776f4f..0a8fb95 100644
--- a/data/rooms/town/16.yaml
+++ b/data/rooms/example/town/9999916.yaml
@@ -1,10 +1,10 @@
-id: 16
+id: 9999916
name: "Construction Site"
description: "A half-built structure surrounded by planks, nails, and blueprints. A workbench stands ready for crafting. Signs point north toward the Local Neighborhood and east toward the lumberyard."
exits:
- east: 171
- west: 15
- north: 170
+ east: 99999171
+ west: 9999915
+ north: 99999170
objects:
- id: workbench
mobs:
diff --git a/data/rooms/town/17.yaml b/data/rooms/example/town/9999917.yaml
index 512ec9e..82ff07d 100644
--- a/data/rooms/town/17.yaml
+++ b/data/rooms/example/town/9999917.yaml
@@ -1,10 +1,10 @@
-id: 17
+id: 9999917
name: "Bonfire"
description: "A ring of stones surrounds a bed of ash. A few unlit logs wait nearby. A narrow passage leads north toward what sounds like echoing footsteps and clanging metal."
exits:
- east: 18
- west: 16
- north: 200
+ east: 9999918
+ west: 9999916
+ north: 99999200
mobs:
- id: scrap_bot
- id: scrap_bot
diff --git a/data/rooms/town/18.yaml b/data/rooms/example/town/9999918.yaml
index 9dfc446..baa21f4 100644
--- a/data/rooms/town/18.yaml
+++ b/data/rooms/example/town/9999918.yaml
@@ -1,6 +1,6 @@
-id: 18
+id: 9999918
name: "Thieves' Den"
description: "A shadowy hideout with lockpicks, dark cloaks, and wanted posters on the walls. This area is not yet accessible."
exits:
- east: 19
- west: 17
+ east: 9999919
+ west: 9999917
diff --git a/data/rooms/town/19.yaml b/data/rooms/example/town/9999919.yaml
index 35800a0..2fd5c5d 100644
--- a/data/rooms/town/19.yaml
+++ b/data/rooms/example/town/9999919.yaml
@@ -1,6 +1,6 @@
-id: 19
+id: 9999919
name: "Agility Course"
description: "A training ground with balance beams, monkey bars, and climbing nets. This area is not yet accessible."
exits:
- east: 20
- west: 18
+ east: 9999920
+ west: 9999918
diff --git a/data/rooms/town/2.yaml b/data/rooms/example/town/999992.yaml
index 8d3fe2d..90bacac 100644
--- a/data/rooms/town/2.yaml
+++ b/data/rooms/example/town/999992.yaml
@@ -1,10 +1,10 @@
-id: 2
+id: 999992
name: "Fishing Spot"
description: "A quiet spot along the riverbank. The water is clear and fish can be seen swimming beneath the surface. You can cast a line here with a fishing rod and bait."
exits:
- east: 4
- north: 3
- west: 1
+ east: 999994
+ north: 999993
+ west: 999991
objects:
- id: bait_fishing_spot
item_spawns:
diff --git a/data/rooms/town/20.yaml b/data/rooms/example/town/9999920.yaml
index 379322b..88a2e3c 100644
--- a/data/rooms/town/20.yaml
+++ b/data/rooms/example/town/9999920.yaml
@@ -1,6 +1,6 @@
-id: 20
+id: 9999920
name: "Assassin's Bureau"
description: "A discreet office with job postings pinned to a corkboard. This area is not yet accessible."
exits:
- east: 21
- west: 19
+ east: 9999921
+ west: 9999919
diff --git a/data/rooms/town/21.yaml b/data/rooms/example/town/9999921.yaml
index ac6dfad..8c399fe 100644
--- a/data/rooms/town/21.yaml
+++ b/data/rooms/example/town/9999921.yaml
@@ -1,8 +1,8 @@
-id: 21
+id: 9999921
name: "Combat Arena"
description: "A sandy training ground with practice dummies and sparring rings. Several men wander here looking for a fight."
exits:
- west: 20
+ west: 9999920
mobs:
- id: man
wander_interval: 10
diff --git a/data/rooms/town/22.yaml b/data/rooms/example/town/9999922.yaml
index 2199783..fb2fe9f 100644
--- a/data/rooms/town/22.yaml
+++ b/data/rooms/example/town/9999922.yaml
@@ -1,6 +1,6 @@
-id: 22
+id: 9999922
name: "West of Town Square"
description: ""
exits:
- east: 1
- west: 23
+ east: 999991
+ west: 9999923
diff --git a/data/rooms/town/23.yaml b/data/rooms/example/town/9999923.yaml
index 45a9d18..6160f00 100644
--- a/data/rooms/town/23.yaml
+++ b/data/rooms/example/town/9999923.yaml
@@ -1,6 +1,6 @@
-id: 23
+id: 9999923
name: "West of West of Town Square"
description: "Here is a description of where we are!"
exits:
- east: 22
- south: 24
+ east: 9999922
+ south: 9999924
diff --git a/data/rooms/town/24.yaml b/data/rooms/example/town/9999924.yaml
index e1c9068..4c4592f 100644
--- a/data/rooms/town/24.yaml
+++ b/data/rooms/example/town/9999924.yaml
@@ -1,6 +1,6 @@
-id: 24
+id: 9999924
name: "South of West of West of Town Square"
description: "Here is a description of where we are!"
exits:
- north: 23
- east: 25
+ north: 9999923
+ east: 9999925
diff --git a/data/rooms/town/25.yaml b/data/rooms/example/town/9999925.yaml
index 78bc181..b9ad750 100644
--- a/data/rooms/town/25.yaml
+++ b/data/rooms/example/town/9999925.yaml
@@ -1,6 +1,6 @@
-id: 25
+id: 9999925
name: "East of South of West of West of Town Square"
description: "Here is a description of where we are!"
exits:
- west: 24
- east: 26
+ west: 9999924
+ east: 9999926
diff --git a/data/rooms/town/26.yaml b/data/rooms/example/town/9999926.yaml
index 0db976e..f776337 100644
--- a/data/rooms/town/26.yaml
+++ b/data/rooms/example/town/9999926.yaml
@@ -1,6 +1,6 @@
-id: 26
+id: 9999926
name: "East of South of West of West of Town Square"
description: "Here is a description of where we are!"
exits:
- west: 25
- south: 27
+ west: 9999925
+ south: 9999927
diff --git a/data/rooms/town/27.yaml b/data/rooms/example/town/9999927.yaml
index d820374..1c108e6 100644
--- a/data/rooms/town/27.yaml
+++ b/data/rooms/example/town/9999927.yaml
@@ -1,6 +1,6 @@
-id: 27
+id: 9999927
name: "East of South of West of West of Town Square"
description: "Here is a description of where we are!"
exits:
- west: 28
- north: 26
+ west: 9999928
+ north: 9999926
diff --git a/data/rooms/town/28.yaml b/data/rooms/example/town/9999928.yaml
index b34ada0..79917f1 100644
--- a/data/rooms/town/28.yaml
+++ b/data/rooms/example/town/9999928.yaml
@@ -1,5 +1,5 @@
-id: 28
+id: 9999928
name: "East of South of West of West of Town Square"
description: "Here is a description of where we are!"
exits:
- east: 27
+ east: 9999927
diff --git a/data/rooms/town/3.yaml b/data/rooms/example/town/999993.yaml
index 8a1cade..078da89 100644
--- a/data/rooms/town/3.yaml
+++ b/data/rooms/example/town/999993.yaml
@@ -1,8 +1,8 @@
-id: 3
+id: 999993
name: "Fly Fishing Pool"
description: "A calm pool where insects hover over the water. Perfect for fly fishing."
exits:
- south: 2
+ south: 999992
objects:
- id: fly_fishing_spot
item_spawns:
diff --git a/data/rooms/town/30.yaml b/data/rooms/example/town/9999930.yaml
index ddce834..6e8a4c2 100644
--- a/data/rooms/town/30.yaml
+++ b/data/rooms/example/town/9999930.yaml
@@ -1,5 +1,5 @@
-id: 30
+id: 9999930
name: "Above Town Square"
description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district."
exits:
- down: 1
+ down: 999991
diff --git a/data/rooms/town/31.yaml b/data/rooms/example/town/9999931.yaml
index 5b5f4d5..9a93023 100644
--- a/data/rooms/town/31.yaml
+++ b/data/rooms/example/town/9999931.yaml
@@ -1,6 +1,6 @@
-id: 31
+id: 9999931
name: "Below Town Square"
description: "The central square of Lumbridge. A fountain gurgles peacefully in the center. Paths lead west toward the gathering grounds and east toward the production district."
exits:
- up: 1
- down: 50
+ up: 999991
+ down: 9999950
diff --git a/data/rooms/town/4.yaml b/data/rooms/example/town/999994.yaml
index 9453651..e8c3453 100644
--- a/data/rooms/town/4.yaml
+++ b/data/rooms/example/town/999994.yaml
@@ -1,10 +1,10 @@
-id: 4
+id: 999994
name: "Woodcutting Glade"
description: "A sunny glade at the edge of the forest. A sturdy tree stands here, perfect for practicing woodcutting. A solid rock formation near the edge of the glade looks like it could provide cover."
exits:
- east: 6
- north: 5
- west: 2
+ east: 999996
+ north: 999995
+ west: 999992
objects:
- id: tree
- id: tree
diff --git a/data/rooms/town/5.yaml b/data/rooms/example/town/999995.yaml
index cdf84d8..767fe73 100644
--- a/data/rooms/town/5.yaml
+++ b/data/rooms/example/town/999995.yaml
@@ -1,8 +1,8 @@
-id: 5
+id: 999995
name: "Oak Grove"
description: "A stand of young oak trees. The wood here is tougher but more valuable."
exits:
- south: 4
+ south: 999994
objects:
- id: oak_tree
item_spawns:
diff --git a/data/rooms/town/6.yaml b/data/rooms/example/town/999996.yaml
index 34b5bd0..86b1b26 100644
--- a/data/rooms/town/6.yaml
+++ b/data/rooms/example/town/999996.yaml
@@ -1,10 +1,10 @@
-id: 6
+id: 999996
name: "Mining Pit"
description: "A small mining pit. The rocky walls glint with veins of copper ore."
exits:
- east: 8
- north: 7
- west: 4
+ east: 999998
+ north: 999997
+ west: 999994
objects:
- id: copper_rock
- id: copper_rock
diff --git a/data/rooms/town/7.yaml b/data/rooms/example/town/999997.yaml
index 37dfed9..847da5c 100644
--- a/data/rooms/town/7.yaml
+++ b/data/rooms/example/town/999997.yaml
@@ -1,8 +1,8 @@
-id: 7
+id: 999997
name: "Deep Mine"
description: "A deeper mine shaft. Higher quality ores can be found here. This area is not yet accessible."
exits:
- south: 6
+ south: 999996
mobs:
- id: goblin
- id: goblin
diff --git a/data/rooms/town/8.yaml b/data/rooms/example/town/999998.yaml
index b82c1bb..6700e9a 100644
--- a/data/rooms/town/8.yaml
+++ b/data/rooms/example/town/999998.yaml
@@ -1,9 +1,9 @@
-id: 8
+id: 999998
name: "Hacking Lab"
description: "A dimly lit room filled with old terminals and flickering screens. Cables dangle from the ceiling, some still sparking. The air smells of ozone and burnt circuitry. A {40}basic terminal{/} against the far wall still has power."
exits:
- east: 9
- west: 6
+ east: 999999
+ west: 999996
objects:
- id: terminal_basic
- id: terminal_advanced
diff --git a/data/rooms/town/9.yaml b/data/rooms/example/town/999999.yaml
index 2835128..71b7f5f 100644
--- a/data/rooms/town/9.yaml
+++ b/data/rooms/example/town/999999.yaml
@@ -1,11 +1,11 @@
-id: 9
+id: 999999
name: "Scavenging Post"
description: "A cluttered yard full of {243}scrap{/} and discarded technology. Piles of salvageable debris are scattered across the ground. The air smells of ozone and rust."
exits:
- east: 10
- west: 8
- north: 150
- south: 151
+ east: 9999910
+ west: 999998
+ north: 99999150
+ south: 99999151
objects:
- id: scrap_pile
- id: scrap_pile
diff --git a/data/rooms/underground/50.yaml b/data/rooms/example/underground/9999950.yaml
index 0c9ebf2..adbad79 100644
--- a/data/rooms/underground/50.yaml
+++ b/data/rooms/example/underground/9999950.yaml
@@ -1,7 +1,7 @@
-id: 50
+id: 9999950
name: "The Assassin's Den"
description: "A dimly lit basement accessible through a trapdoor. Tactical maps and bounty posters line the walls. The air smells of gun oil and burnt ozone. {141}The Client{/} sits behind a reinforced desk."
exits:
- up: 31
+ up: 9999931
mobs:
- "client"
diff --git a/data/rooms/underground/51.yaml b/data/rooms/example/underground/9999951.yaml
index 74a97ef..4dbd966 100644
--- a/data/rooms/underground/51.yaml
+++ b/data/rooms/example/underground/9999951.yaml
@@ -1,9 +1,9 @@
-id: 51
+id: 9999951
name: "Sewer Tunnels"
description: "Dark, damp tunnels beneath the settlement. The floor is slick with moisture and something {82}slimy{/}. The smell is indescribable."
exits:
- north: 50
- south: 52
+ north: 9999950
+ south: 9999952
mobs:
- "slug"
- "slug"
diff --git a/data/rooms/underground/52.yaml b/data/rooms/example/underground/9999952.yaml
index 2f90abd..7c8d871 100644
--- a/data/rooms/underground/52.yaml
+++ b/data/rooms/example/underground/9999952.yaml
@@ -1,9 +1,9 @@
-id: 52
+id: 9999952
name: "Abandoned Sector"
description: "A decommissioned sector of the asteroid's infrastructure. Broken monitors flicker and exposed wiring {214}sparks{/} dangerously."
exits:
- north: 51
- south: 53
+ north: 9999951
+ south: 9999953
mobs:
- "drone"
- "drone"
diff --git a/data/rooms/underground/53.yaml b/data/rooms/example/underground/9999953.yaml
index 126719b..bad2d27 100644
--- a/data/rooms/underground/53.yaml
+++ b/data/rooms/example/underground/9999953.yaml
@@ -1,9 +1,9 @@
-id: 53
+id: 9999953
name: "Deep Tunnels"
description: "The tunnels descend deeper into the asteroid's core. Strange chitinous scraping echoes from the darkness. The walls are scarred with {130}claw marks{/}."
exits:
- north: 52
- south: 54
+ north: 9999952
+ south: 9999954
mobs:
- "crawler"
- "crawler"
diff --git a/data/rooms/underground/54.yaml b/data/rooms/example/underground/9999954.yaml
index e86e5b7..315a831 100644
--- a/data/rooms/underground/54.yaml
+++ b/data/rooms/example/underground/9999954.yaml
@@ -1,9 +1,9 @@
-id: 54
+id: 9999954
name: "The Void Chamber"
description: "A vast cavern where reality seems to thin. The air shimmers with {141}spectral energy{/} and strange whispers fill your mind."
exits:
- north: 53
- down: 55
+ north: 9999953
+ down: 9999955
mobs:
- "phantom"
- "phantom"
diff --git a/data/rooms/underground/55.yaml b/data/rooms/example/underground/9999955.yaml
index 8000170..63f9dbd 100644
--- a/data/rooms/underground/55.yaml
+++ b/data/rooms/example/underground/9999955.yaml
@@ -1,12 +1,12 @@
-id: 55
+id: 9999955
name: "The Deeper Tunnels"
description: "A junction deep beneath the void chamber. The walls here are reinforced with pre-regression alloys. Several passages branch off into the darkness, and you can hear the sounds of machinery echoing from all directions. A steep shaft descends further into the depths."
exits:
- up: 54
- north: 56
- east: 57
- south: 58
- west: 60
- down: 62
+ up: 9999954
+ north: 9999956
+ east: 9999957
+ south: 9999958
+ west: 9999960
+ down: 9999962
mobs:
- id: security_turret
diff --git a/data/rooms/underground/56.yaml b/data/rooms/example/underground/9999956.yaml
index 375696d..74b75a9 100644
--- a/data/rooms/underground/56.yaml
+++ b/data/rooms/example/underground/9999956.yaml
@@ -1,8 +1,8 @@
-id: 56
+id: 9999956
name: "Irradiated Sector"
description: "Warning symbols cover every surface, their paint long since faded. A faint green glow emanates from cracks in the walls. The air is thick with the taste of old radiation."
exits:
- south: 55
+ south: 9999955
mobs:
- id: ghoul
- id: ghoul
diff --git a/data/rooms/underground/57.yaml b/data/rooms/example/underground/9999957.yaml
index 714197d..b32c7aa 100644
--- a/data/rooms/underground/57.yaml
+++ b/data/rooms/example/underground/9999957.yaml
@@ -1,8 +1,8 @@
-id: 57
+id: 9999957
name: "Drone Assembly"
description: "A long-abandoned drone manufacturing bay. Conveyor belts lie still, and half-assembled drone chassis hang from the ceiling. A few of them still twitch with residual power."
exits:
- west: 55
+ west: 9999955
mobs:
- id: war_drone
- id: war_drone
diff --git a/data/rooms/underground/58.yaml b/data/rooms/example/underground/9999958.yaml
index f4d1829..1834260 100644
--- a/data/rooms/underground/58.yaml
+++ b/data/rooms/example/underground/9999958.yaml
@@ -1,8 +1,8 @@
-id: 58
+id: 9999958
name: "Security Checkpoint"
description: "A fortified security station with blast shields and a decommissioned weapons locker. Scorch marks on the walls tell the story of a last stand."
exits:
- north: 55
- south: 59
+ north: 9999955
+ south: 9999959
mobs:
- id: enforcer_bot
diff --git a/data/rooms/underground/59.yaml b/data/rooms/example/underground/9999959.yaml
index a1cc594..21f526b 100644
--- a/data/rooms/underground/59.yaml
+++ b/data/rooms/example/underground/9999959.yaml
@@ -1,8 +1,8 @@
-id: 59
+id: 9999959
name: "Plasma Core"
description: "A massive plasma containment chamber with cracked magnetic coils lining the walls. Streams of superheated gas leak from the containment vessel, forming shimmering wraith-like shapes in the air."
exits:
- north: 58
+ north: 9999958
mobs:
- id: plasma_wraith
- id: plasma_wraith
diff --git a/data/rooms/underground/60.yaml b/data/rooms/example/underground/9999960.yaml
index e9b7114..9a45e32 100644
--- a/data/rooms/underground/60.yaml
+++ b/data/rooms/example/underground/9999960.yaml
@@ -1,8 +1,8 @@
-id: 60
+id: 9999960
name: "Heavy Mech Bay"
description: "An enormous maintenance bay built to house assault mechs. A gantry crane hangs overhead, and hydraulic lifts dot the floor. The bay's occupant is still active — and it has noticed you."
exits:
- east: 55
- west: 61
+ east: 9999955
+ west: 9999961
mobs:
- id: heavy_mech
diff --git a/data/rooms/underground/61.yaml b/data/rooms/example/underground/9999961.yaml
index a85c7ba..caf4eab 100644
--- a/data/rooms/underground/61.yaml
+++ b/data/rooms/example/underground/9999961.yaml
@@ -1,7 +1,7 @@
-id: 61
+id: 9999961
name: "Synth Lab"
description: "A sterile laboratory where combat androids were designed and assembled. White panels line the walls, some splattered with old coolant stains. Diagnostic screens flicker with corrupted data."
exits:
- east: 60
+ east: 9999960
mobs:
- id: elite_synth
diff --git a/data/rooms/underground/62.yaml b/data/rooms/example/underground/9999962.yaml
index 9ed1fad..531ead1 100644
--- a/data/rooms/underground/62.yaml
+++ b/data/rooms/example/underground/9999962.yaml
@@ -1,8 +1,8 @@
-id: 62
+id: 9999962
name: "Abandoned Bunker"
description: "A fortified bunker from some forgotten conflict. Sandbags are piled in the corners and spent shell casings litter the floor. A solitary figure stands watch over the remains."
exits:
- up: 55
- down: 63
+ up: 9999955
+ down: 9999963
mobs:
- id: veteran_merc
diff --git a/data/rooms/underground/63.yaml b/data/rooms/example/underground/9999963.yaml
index b6bf53e..99b8f90 100644
--- a/data/rooms/underground/63.yaml
+++ b/data/rooms/example/underground/9999963.yaml
@@ -1,8 +1,8 @@
-id: 63
+id: 9999963
name: "Magma Conduit"
description: "A geothermal vent has been tapped and routed through industrial conduits. The heat is oppressive, and molten rivulets course through channels in the floor. Something moves within the glowing flow."
exits:
- up: 62
- down: 64
+ up: 9999962
+ down: 9999964
mobs:
- id: molten_elemental
diff --git a/data/rooms/underground/64.yaml b/data/rooms/example/underground/9999964.yaml
index b412992..6bac466 100644
--- a/data/rooms/underground/64.yaml
+++ b/data/rooms/example/underground/9999964.yaml
@@ -1,8 +1,8 @@
-id: 64
+id: 9999964
name: "The Foundry"
description: "A vast smelting facility where sentinels were once forged. Massive crucibles hang from chains, and rivers of cooled slag form a maze-like floor. A single iron sentinel stands guard over the dormant machinery."
exits:
- up: 63
- down: 65
+ up: 9999963
+ down: 9999965
mobs:
- id: iron_sentinel
diff --git a/data/rooms/underground/65.yaml b/data/rooms/example/underground/9999965.yaml
index 47aed6b..47aed6b 100644
--- a/data/rooms/underground/65.yaml
+++ b/data/rooms/example/underground/9999965.yaml
diff --git a/data/rooms/underground/66.yaml b/data/rooms/example/underground/9999966.yaml
index 755d5e2..755d5e2 100644
--- a/data/rooms/underground/66.yaml
+++ b/data/rooms/example/underground/9999966.yaml
diff --git a/data/rooms/underground/67.yaml b/data/rooms/example/underground/9999967.yaml
index 6b8d663..6b8d663 100644
--- a/data/rooms/underground/67.yaml
+++ b/data/rooms/example/underground/9999967.yaml
diff --git a/data/rooms/underground/68.yaml b/data/rooms/example/underground/9999968.yaml
index f631981..f631981 100644
--- a/data/rooms/underground/68.yaml
+++ b/data/rooms/example/underground/9999968.yaml
diff --git a/data/rooms/wilderness/150.yaml b/data/rooms/example/wilderness/99999150.yaml
index 3e74da2..3e74da2 100644
--- a/data/rooms/wilderness/150.yaml
+++ b/data/rooms/example/wilderness/99999150.yaml
diff --git a/data/rooms/wilderness/151.yaml b/data/rooms/example/wilderness/99999151.yaml
index 4242270..4242270 100644
--- a/data/rooms/wilderness/151.yaml
+++ b/data/rooms/example/wilderness/99999151.yaml
diff --git a/data/rooms/wilderness/152.yaml b/data/rooms/example/wilderness/99999152.yaml
index 787bbbd..787bbbd 100644
--- a/data/rooms/wilderness/152.yaml
+++ b/data/rooms/example/wilderness/99999152.yaml
diff --git a/data/rooms/wilderness/160.yaml b/data/rooms/example/wilderness/99999160.yaml
index 7f30c34..7f30c34 100644
--- a/data/rooms/wilderness/160.yaml
+++ b/data/rooms/example/wilderness/99999160.yaml
diff --git a/data/rooms/wilderness/161.yaml b/data/rooms/example/wilderness/99999161.yaml
index 7c2c140..7c2c140 100644
--- a/data/rooms/wilderness/161.yaml
+++ b/data/rooms/example/wilderness/99999161.yaml
diff --git a/data/rooms/wilderness/162.yaml b/data/rooms/example/wilderness/99999162.yaml
index 6aab585..6aab585 100644
--- a/data/rooms/wilderness/162.yaml
+++ b/data/rooms/example/wilderness/99999162.yaml
diff --git a/data/rooms/wilderness/170.yaml b/data/rooms/example/wilderness/99999170.yaml
index 35d539f..35d539f 100644
--- a/data/rooms/wilderness/170.yaml
+++ b/data/rooms/example/wilderness/99999170.yaml
diff --git a/data/rooms/wilderness/171.yaml b/data/rooms/example/wilderness/99999171.yaml
index 552ccd4..552ccd4 100644
--- a/data/rooms/wilderness/171.yaml
+++ b/data/rooms/example/wilderness/99999171.yaml
diff --git a/data/rooms/example/wilderness/99999180.yaml b/data/rooms/example/wilderness/99999180.yaml
new file mode 100644
index 0000000..866d658
--- /dev/null
+++ b/data/rooms/example/wilderness/99999180.yaml
@@ -0,0 +1,5 @@
+id: 99999180
+name: "Construction Yard Entrance"
+description: "A fenced staging area at the edge of the terraforming works. Crates of brackets and coils of cable are stacked under a sturdy section of atmospheric dome. To the {220}north{/} the dome shielding gives out and the open solar array field begins."
+exits:
+ north: 99999181
diff --git a/data/rooms/example/wilderness/99999181.yaml b/data/rooms/example/wilderness/99999181.yaml
new file mode 100644
index 0000000..aae861b
--- /dev/null
+++ b/data/rooms/example/wilderness/99999181.yaml
@@ -0,0 +1,11 @@
+id: 99999181
+name: "Exposed Solar Array"
+description: "A wide field of steel frames stretches under a torn section of the atmospheric dome. Raw sunlight blazes down with nothing to filter it. Half-built {220}solar panel frames{/} wait to be finished. A jagged {248}rock outcrop{/} on the south edge throws a patch of shade. The yard entrance lies back to the {34}south{/}."
+hazard: solar_radiation
+exits:
+ south: 99999180
+objects:
+ - id: rock_outcrop
+mobs:
+ - solar_panel_frame
+ - solar_panel_frame
diff --git a/data/rooms/intro/1001.yaml b/data/rooms/intro/1001.yaml
new file mode 100644
index 0000000..7ad95fa
--- /dev/null
+++ b/data/rooms/intro/1001.yaml
@@ -0,0 +1,5 @@
+id: 1001
+name: "Inside the Landing Craft"
+description: "Rows of sturdy metal seats fill the cramped cabin of the landing craft, harnesses still swinging from the descent. A thin haze of recycled air hangs in the light spilling through the open hatch. The only way out is north, onto the landing pad."
+exits:
+ north: 1002
diff --git a/data/rooms/intro/1002.yaml b/data/rooms/intro/1002.yaml
new file mode 100644
index 0000000..2c9bb4f
--- /dev/null
+++ b/data/rooms/intro/1002.yaml
@@ -0,0 +1,43 @@
+id: 1002
+name: "Landing Pad"
+description: "A large, empty concrete pad in the middle of the ocean. The horizon stretches out in all directions."
+descriptions:
+ - condition:
+ player_flag: denali_intro_done
+ not: true
+ text: "A large concrete pad sits in the middle of an endless ocean. A mass of a couple dozen people spill from a small building to the north, their faces a churn of anxiety, impatience, and relief."
+objects:
+ - id: 1002_crowd
+ - id: 1002_shuttle
+ - id: 1002_pilot
+exits:
+ south:
+ room: 1001
+ condition:
+ player_flag: has_ticket
+ blocked_message: "You don't have a ticket!"
+ north:
+ room: 1003
+ condition:
+ player_flag: denali_lined_up
+ blocked_message: "A mass of people blocks the way."
+ set_player_flags:
+ denali_intro_done: true
+on_enter:
+ - condition:
+ player_flag: denali_lined_up
+ not: true
+ delay: 5
+ message: "Some of the group look you up and down."
+ - condition:
+ player_flag: denali_lined_up
+ not: true
+ delay: 5
+ message: "The pilot pops out of the hatch of the craft and calls out, \"Shuttle to Passenger Liner Denali. Tickets out, please! Nice and orderly!\""
+ set_player_flags:
+ denali_lined_up: true
+ - condition:
+ player_flag: denali_lined_up
+ not: true
+ delay: 0
+ message: "The people line up single file."
diff --git a/data/rooms/intro/1003.yaml b/data/rooms/intro/1003.yaml
new file mode 100644
index 0000000..a3fd89f
--- /dev/null
+++ b/data/rooms/intro/1003.yaml
@@ -0,0 +1,5 @@
+id: 1003
+name: "Customs Building"
+description: "A cramped customs checkpoint lit by flickering panels. Officials in grey uniforms wave the new arrivals through, one at a time. (This area is still under construction.)"
+exits:
+ south: 1002
diff --git a/internal/game/action.go b/internal/game/action.go
index 9a07419..8d50eea 100644
--- a/internal/game/action.go
+++ b/internal/game/action.go
@@ -284,22 +284,17 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
}
if c.PlayerFlag != "" {
- if p == nil || p.Flags == nil {
- return c.Not
+ var val any
+ present := false
+ if p != nil && p.Flags != nil {
+ val, present = p.Flags[c.PlayerFlag]
}
- val, ok := p.Flags[c.PlayerFlag]
- if c.Not {
- return !ok || val != c.Value
- }
- return ok && val == c.Value
+ return flagMatches(present, val, c.Value, c.Not)
}
if c.Flag != "" {
- val, ok := g.WorldFlags[c.Flag]
- if c.Not {
- return !ok || val != c.Value
- }
- return ok && val == c.Value
+ val, present := g.WorldFlags[c.Flag]
+ return flagMatches(present, val, c.Value, c.Not)
}
if c.HasItem != "" {
has := p != nil && p.HasItem(c.HasItem)
@@ -317,3 +312,41 @@ func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool {
}
return true
}
+
+// flagMatches evaluates a flag/player_flag condition.
+//
+// If the condition specifies no `value`, the flag matches when it is present
+// and truthy (so `{player_flag: x}` means "x is set" and
+// `{player_flag: x, not: true}` means "x is not set"). If a `value` is given,
+// the flag must be present and equal to it. `not` inverts the result.
+func flagMatches(present bool, val, want any, not bool) bool {
+ var match bool
+ if want == nil {
+ match = present && isTruthy(val)
+ } else {
+ match = present && val == want
+ }
+ if not {
+ return !match
+ }
+ return match
+}
+
+func isTruthy(v any) bool {
+ switch x := v.(type) {
+ case nil:
+ return false
+ case bool:
+ return x
+ case int:
+ return x != 0
+ case int64:
+ return x != 0
+ case float64:
+ return x != 0
+ case string:
+ return x != ""
+ default:
+ return true
+ }
+}
diff --git a/internal/game/action_room.go b/internal/game/action_room.go
index af5b99b..6c886c1 100644
--- a/internal/game/action_room.go
+++ b/internal/game/action_room.go
@@ -5,19 +5,194 @@ import (
"strings"
"thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
)
+// enterSeq is an in-flight on_enter cutscene for a single player. It is driven
+// one step at a time by EnterSeqTick.
+type enterSeq struct {
+ sess *net.Session
+ playerName string
+ roomID int
+ steps []world.EnterStep
+ wait int
+ started bool
+}
+
+// runEnterSteps evaluates a room's on_enter script for the player. Steps whose
+// condition fails are dropped. If none of the surviving steps are "timed"
+// (no delay, no flag mutation) the messages are printed synchronously, exactly
+// as before. Otherwise the surviving steps become a scheduled cutscene driven
+// by EnterSeqTick.
func (g *Game) runEnterSteps(sess *net.Session, roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil || len(room.OnEnter) == 0 {
return
}
+ p := sess.Player
+ if p == nil {
+ return
+ }
+
+ var steps []world.EnterStep
+ cutscene := false
for _, step := range room.OnEnter {
if step.Condition != nil && !g.checkCondition(sess, step.Condition) {
continue
}
- if step.Message != "" {
- sess.WriteLine(fmt.Sprintf("%s", step.Message))
+ steps = append(steps, step)
+ if step.IsTimed() {
+ cutscene = true
+ }
+ }
+ if len(steps) == 0 {
+ return
+ }
+
+ if !cutscene {
+ for _, step := range steps {
+ if step.Message != "" {
+ sess.WriteLine(step.Message)
+ }
+ }
+ return
+ }
+
+ g.startEnterCutscene(sess, p, roomID, steps)
+}
+
+// startEnterCutscene registers a scheduled on_enter sequence and persists a
+// marker on the player so the cutscene can be resumed on reconnect if it is
+// interrupted (disconnect, server restart).
+func (g *Game) startEnterCutscene(sess *net.Session, p *player.Player, roomID int, steps []world.EnterStep) {
+ g.cancelEnterSeq(p.Name)
+ if p.CutsceneRoom != roomID {
+ p.CutsceneRoom = roomID
+ g.AccountStore.SaveCharacter(p)
+ }
+ g.enterMu.Lock()
+ g.enterSeqs[p.Name] = &enterSeq{
+ sess: sess,
+ playerName: p.Name,
+ roomID: roomID,
+ steps: steps,
+ wait: steps[0].Delay,
+ }
+ g.enterMu.Unlock()
+}
+
+// EnterSeqTick advances every in-flight cutscene by one tick. Sequences for a
+// player who is no longer connected are paused (left for resume); sequences are
+// removed from the map on completion or cancellation.
+func (g *Game) EnterSeqTick() {
+ g.enterMu.Lock()
+ seqs := make([]*enterSeq, 0, len(g.enterSeqs))
+ for _, s := range g.enterSeqs {
+ seqs = append(seqs, s)
+ }
+ g.enterMu.Unlock()
+
+ for _, seq := range seqs {
+ if !g.isCharLive(seq.playerName, seq.sess) {
+ continue
+ }
+
+ var jobs []world.EnterStep
+ done := false
+
+ g.enterMu.Lock()
+ if g.enterSeqs[seq.playerName] != seq {
+ g.enterMu.Unlock()
+ continue
+ }
+ seq.wait--
+ for seq.wait <= 0 && len(seq.steps) > 0 {
+ step := seq.steps[0]
+ seq.steps = seq.steps[1:]
+ jobs = append(jobs, step)
+ if len(seq.steps) > 0 {
+ seq.wait = seq.steps[0].Delay
+ } else {
+ seq.wait = 0
+ }
+ }
+ if len(seq.steps) == 0 {
+ done = true
+ delete(g.enterSeqs, seq.playerName)
+ }
+ g.enterMu.Unlock()
+
+ for _, step := range jobs {
+ g.fireEnterStep(seq, step)
+ }
+ if done {
+ g.completeEnterSeq(seq)
+ }
+ }
+}
+
+func (g *Game) fireEnterStep(seq *enterSeq, step world.EnterStep) {
+ p := seq.sess.Player
+ if p == nil || p.RoomID != seq.roomID {
+ return
+ }
+ if step.Message != "" {
+ // Break from the prompt on the first line only, so a multi-line
+ // cutscene reads as tight consecutive lines.
+ if !seq.started {
+ seq.sess.WriteLine("\n" + step.Message)
+ seq.started = true
+ } else {
+ seq.sess.WriteLine(step.Message)
+ }
+ }
+ if len(step.SetFlags) > 0 || len(step.SetPlayerFlags) > 0 {
+ g.applyFlagMutations(p, step.SetFlags, step.SetPlayerFlags)
+ g.AccountStore.SaveCharacter(p)
+ }
+}
+
+func (g *Game) completeEnterSeq(seq *enterSeq) {
+ if !g.isCharLive(seq.playerName, seq.sess) {
+ return
+ }
+ p := seq.sess.Player
+ if p == nil {
+ return
+ }
+ if p.CutsceneRoom == seq.roomID {
+ p.CutsceneRoom = 0
+ g.AccountStore.SaveCharacter(p)
+ }
+ g.writePrompt(seq.sess)
+}
+
+// cancelEnterSeq drops any in-flight cutscene for the player. It does not touch
+// the persisted CutsceneRoom marker, so a cutscene cancelled by disconnect can
+// still be resumed on reconnect.
+func (g *Game) cancelEnterSeq(name string) {
+ g.enterMu.Lock()
+ delete(g.enterSeqs, name)
+ g.enterMu.Unlock()
+}
+
+func (g *Game) isCharLive(name string, sess *net.Session) bool {
+ g.charsMu.Lock()
+ defer g.charsMu.Unlock()
+ return g.loggedInChars[name] == sess
+}
+
+// applyFlagMutations sets world and player flags, shared by on_enter steps and
+// exit traversal.
+func (g *Game) applyFlagMutations(p *player.Player, setFlags, setPlayerFlags map[string]any) {
+ for k, v := range setFlags {
+ g.WorldFlags[k] = v
+ }
+ if len(setPlayerFlags) > 0 {
+ p.EnsureFlags()
+ for k, v := range setPlayerFlags {
+ p.Flags[k] = v
}
}
}
diff --git a/internal/game/action_state.go b/internal/game/action_state.go
index ffce545..bed936d 100644
--- a/internal/game/action_state.go
+++ b/internal/game/action_state.go
@@ -30,6 +30,7 @@ const (
ActionTraversing ActionType = "traversing"
ActionHacking ActionType = "hacking"
ActionHiding ActionType = "hiding"
+ ActionWorking ActionType = "working"
)
type ActionState struct {
@@ -101,6 +102,8 @@ func (a *ActionState) Description() string {
return "jacked into a " + a.TargetName
case ActionHiding:
return "positioning behind a " + a.TargetName
+ case ActionWorking:
+ return "working on a " + a.TargetName
}
return ""
}
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 1f18d00..f3b99c0 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -35,7 +35,11 @@ func (g *Game) doAttack(sess *net.Session, input string) {
}
if mob.HP <= 0 {
- sess.WriteLine("That is already dead.")
+ if mob.IsTask() {
+ sess.WriteLine(fmt.Sprintf("%s is already complete.", mobDisplayName(mob, true)))
+ } else {
+ sess.WriteLine("That is already dead.")
+ }
return
}
@@ -133,6 +137,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
combat.EnterCombat(p.Name, mob.InstanceID)
p.AttackTimer = 0
+ isTask := mob.IsTask()
autotriggerActive := p.AutotriggerMod != ""
if !autotriggerActive {
@@ -151,20 +156,35 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if len(styleParts) > 0 {
styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
}
- sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ if isTask {
+ sess.WriteLine(fmt.Sprintf("You begin to %s %s.%s", taskWorkVerb(mob), g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You attack %s!%s", g.colorize(sess, "mob", mobDisplayName(mob, true)), styleStr))
+ }
} else {
mod := GetMod(p.AutotriggerMod)
modName := p.AutotriggerMod
if mod != nil {
modName = mod.Name
}
- sess.WriteLine(fmt.Sprintf("You attack %s with %s!",
- g.colorize(sess, "mob", mobDisplayName(mob, true)),
- g.colorize(sess, "science_mod", modName)))
+ if isTask {
+ sess.WriteLine(fmt.Sprintf("You begin to %s %s using %s!",
+ taskWorkVerb(mob),
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", modName)))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You attack %s with %s!",
+ g.colorize(sess, "mob", mobDisplayName(mob, true)),
+ g.colorize(sess, "science_mod", modName)))
+ }
+ }
+ if isTask {
+ p.ActionState = &ActionState{Type: ActionWorking, TargetName: mob.Name}
+ g.broadcastAction(sess, "\n%s begins working on %s.", p.Name, mobDisplayName(mob, true))
+ } else {
+ p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
+ g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true))
}
- p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
-
- g.broadcastAction(sess, "\n%s attacks %s!", p.Name, mobDisplayName(mob, true))
g.Ticks.Subscribe(1, func() bool {
cs := combat.GetCombat(p.Name)
@@ -223,22 +243,24 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
return true
})
- g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool {
- cs := combat.GetCombat(p.Name)
- if cs == nil || !cs.Active {
- return false
- }
- currentMob := g.MobStore.GetInstance(cs.MobID)
- if currentMob == nil || currentMob.HP <= 0 {
- return false
- }
- g.mobAttack(sess, p, currentMob)
- if p.HP <= 0 {
- g.endCombat(sess, p, currentMob)
- return false
- }
- return true
- })
+ if !isTask {
+ g.Ticks.Subscribe(engine.ToTicks(mob.Speed), func() bool {
+ cs := combat.GetCombat(p.Name)
+ if cs == nil || !cs.Active {
+ return false
+ }
+ currentMob := g.MobStore.GetInstance(cs.MobID)
+ if currentMob == nil || currentMob.HP <= 0 {
+ return false
+ }
+ g.mobAttack(sess, p, currentMob)
+ if p.HP <= 0 {
+ g.endCombat(sess, p, currentMob)
+ return false
+ }
+ return true
+ })
+ }
}
func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) {
@@ -299,8 +321,8 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc
equipAttack := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, equipAttack)
+ effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
+ attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
@@ -331,8 +353,8 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan
weaponType = object.WeaponMelee
attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle))
- effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack")
- attRoll = combat.EffectiveRoll(effectiveAttack, attBonus, 0)
+ effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy")
+ attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0)
mobDefBonus := combat.SelectBonus(attackType,
mob.StabDefense, mob.SlashDefense, mob.CrushDefense,
@@ -374,30 +396,34 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo
g.consumeAmmo(sess, p)
}
- mobName := mobDisplayName(mob, true)
- attacker := mob.Name
- if !mob.Unique {
- attacker = "The " + mob.Name
- }
- w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
- if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w {
- w = w2
- }
- prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
- visLen := color.VisibleLen(prefix)
- if visLen < w {
- prefix += strings.Repeat(" ", w-visLen+1)
- }
- hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP)
- line := prefix + hpSuffix
- if p.OptionBool("xp_drops") && len(gains) > 0 {
- var parts []string
- for _, gain := range gains {
- parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
+ if mob.IsTask() {
+ g.writeTaskProgress(sess, p, mob, dmg, gains)
+ } else {
+ mobName := mobDisplayName(mob, true)
+ attacker := mob.Name
+ if !mob.Unique {
+ attacker = "The " + mob.Name
+ }
+ w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999))
+ if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w {
+ w = w2
+ }
+ prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg)))
+ visLen := color.VisibleLen(prefix)
+ if visLen < w {
+ prefix += strings.Repeat(" ", w-visLen+1)
+ }
+ hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP)
+ line := prefix + hpSuffix
+ if p.OptionBool("xp_drops") && len(gains) > 0 {
+ var parts []string
+ for _, gain := range gains {
+ parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
+ }
+ line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
}
- line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
+ sess.WriteLine(line)
}
- sess.WriteLine(line)
if isRanged && p.AmmoQty <= 0 {
sess.WriteLine("You've run out of ammo!")
@@ -559,60 +585,51 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
p.ActionState = nil
if p.HP <= 0 {
- if p.HasActiveTech("retribution") {
- retDef := GetTechDef("retribution")
- if retDef != nil && mob != nil && mob.HP > 0 {
- retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct)
- if retDmg > 0 {
- mob.HP -= retDmg
- if mob.HP < 0 {
- mob.HP = 0
- }
- sess.WriteLine(fmt.Sprintf("\nDead 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!"))
- p.ClearMoveState()
- g.safespotMu.Lock()
- if ss, ok := g.safespotStates[p.Name]; ok {
- g.safespotMu.Unlock()
- g.forceLeaveSafespot(sess, p, ss, "")
- } else {
- g.safespotMu.Unlock()
- }
- g.dropItemsOnDeath(p)
- p.HP = p.MaxHP()
- p.RoomID = 1
- g.AccountStore.SaveCharacter(p)
- if g.Hub != nil {
- g.Hub.EnterRoom(sess, p.RoomID)
- }
- g.doLook(sess)
- g.writePrompt(sess)
+ g.killPlayer(sess, p, mob)
return
}
if mob != nil && mob.HP <= 0 {
+ isTask := mob.IsTask()
p.Stats.RecordMobKill(mob.DefID)
- sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
- g.onAssassinKill(sess, p, mob)
+ if isTask {
+ complete := mob.CompleteMessage
+ 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))))
+ g.onAssassinKill(sess, p, mob)
+ }
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess && other.Player != nil {
- 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)))
+ if isTask {
+ other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%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)))
+ }
}
}
}
+ dropLabel := func() string {
+ if isTask {
+ return "You receive:"
+ }
+ dropper := mob.Name
+ if !mob.Unique {
+ dropper = "The " + mob.Name
+ }
+ return dropper + " drops:"
+ }()
+
if mob.Drops.Remains != "" {
g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name)
def, _ := g.ItemStore.Load(mob.Drops.Remains)
@@ -620,12 +637,8 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if def != nil {
name = def.Name
}
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
coloredName := g.itemColorize(sess, def, name)
- sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName))
+ sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
}
if len(mob.Drops.Loot) > 0 {
@@ -641,15 +654,11 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
if def != nil {
name = def.Name
}
- dropper := mob.Name
- if !mob.Unique {
- dropper = "The " + mob.Name
- }
coloredName := g.itemColorize(sess, def, name)
if qty > 1 {
- sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropper+" drops:"), qty, coloredName))
+ sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropper+" drops:"), coloredName))
+ sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName))
}
}
}
@@ -667,6 +676,50 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
}
+// killPlayer handles a player death from any source (combat or a room hazard).
+// mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch
+// retribution.
+func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobInstance) {
+ combat.LeaveCombat(p.Name)
+ p.ActionState = nil
+
+ if p.HasActiveTech("retribution") {
+ retDef := GetTechDef("retribution")
+ if retDef != nil && mob != nil && mob.HP > 0 {
+ retDmg := int(float64(p.MaxHP()) * retDef.Effects.RetributionPct)
+ if retDmg > 0 {
+ mob.HP -= retDmg
+ if mob.HP < 0 {
+ mob.HP = 0
+ }
+ sess.WriteLine(fmt.Sprintf("\nDead 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!"))
+ p.ClearMoveState()
+ p.HazardTimer = 0
+ g.safespotMu.Lock()
+ if ss, ok := g.safespotStates[p.Name]; ok {
+ g.safespotMu.Unlock()
+ g.forceLeaveSafespot(sess, p, ss, "")
+ } else {
+ g.safespotMu.Unlock()
+ }
+ g.dropItemsOnDeath(p)
+ p.HP = p.MaxHP()
+ p.RoomID = 1001
+ g.AccountStore.SaveCharacter(p)
+ if g.Hub != nil {
+ g.Hub.EnterRoom(sess, p.RoomID)
+ }
+ g.doLook(sess)
+ g.writePrompt(sess)
+}
+
func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain {
baseXP := dmg * 4
var gains []xpGain
@@ -679,7 +732,7 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
} else {
switch p.AttackStyle {
case player.Accurate:
- gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
+ gains = []xpGain{{string(player.Accuracy), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
case player.Aggressive:
gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}}
case player.Defensive:
@@ -687,7 +740,7 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan
case player.Balanced:
quarter := baseXP / 4
gains = []xpGain{
- {string(player.Attack), quarter},
+ {string(player.Accuracy), quarter},
{string(player.Strength), quarter},
{string(player.Defense), quarter},
{string(player.Hitpoints), quarter},
@@ -722,6 +775,22 @@ func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string)
}
}
+// executeWork is the non-violent alias of attack. It routes to the same engine;
+// doAttack auto-detects whether the target is a task worksite or a combat mob.
+func (g *Game) executeWork(sess *net.Session, args []string, rawInput string) {
+ if len(args) == 0 {
+ p := sess.Player
+ target := g.resolveDefaultMob(p.RoomID)
+ if target == "" {
+ sess.WriteLine("Work on what?")
+ return
+ }
+ g.doAttack(sess, target)
+ } else {
+ g.doAttack(sess, strings.Join(args, " "))
+ }
+}
+
func (g *Game) respawnMob(instanceID string) {
inst := g.MobStore.GetInstance(instanceID)
if inst == nil {
diff --git a/internal/game/cmd_consume.go b/internal/game/cmd_consume.go
index fa55d3d..223d803 100644
--- a/internal/game/cmd_consume.go
+++ b/internal/game/cmd_consume.go
@@ -258,7 +258,7 @@ func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, d
case "all_combat":
duration := engine.ToTicks(def.PotionDuration)
- buffs := []string{"attack", "strength", "defense"}
+ buffs := []string{"accuracy", "strength", "defense"}
for _, stat := range buffs {
p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{
Stat: stat,
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 98d48fd..266cd15 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -62,7 +62,7 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo
if descWidth <= 0 {
descWidth = 70
}
- rawLines := wrapText(room.Description, descWidth)
+ rawLines := wrapText(g.roomDescription(sess, room), descWidth)
mode := g.colorMode(sess)
roomDescSpec := g.resolveColor(sess, "room_desc")
lines := make([]string, len(rawLines))
@@ -72,6 +72,35 @@ func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *wo
return lines
}
+// roomDescription resolves the room's effective description for this player,
+// picking the first conditional variant whose condition passes, else the base
+// Description.
+func (g *Game) roomDescription(sess *net.Session, room *world.Room) string {
+ for _, d := range room.Descriptions {
+ if d.Condition == nil || g.checkCondition(sess, d.Condition) {
+ return d.Text
+ }
+ }
+ return room.Description
+}
+
+// resolveObjDesc resolves the description an object shows to this player. For an
+// object with conditional Descriptions, the first variant whose condition
+// passes wins; if none pass the object is reported as not present (false), so
+// look falls through as if it were not there. Objects without Descriptions use
+// their plain Description and are always present.
+func (g *Game) resolveObjDesc(sess *net.Session, def *object.ObjectDef) (string, bool) {
+ if len(def.Descriptions) == 0 {
+ return def.Description, true
+ }
+ for _, d := range def.Descriptions {
+ if d.Condition == nil || g.checkCondition(sess, d.Condition) {
+ return d.Text, true
+ }
+ }
+ return "", false
+}
+
func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) []string {
mobs := g.MobStore.MobsInRoom(p.RoomID)
if len(mobs) == 0 {
@@ -91,7 +120,15 @@ func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Roo
for _, m := range mobs {
hp := ""
if m.HP < m.MaxHP {
- hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP)
+ if m.IsTask() {
+ pct := 0
+ if m.MaxHP > 0 {
+ pct = (m.MaxHP - m.HP) * 100 / m.MaxHP
+ }
+ hp = fmt.Sprintf(" [%d%% complete]", pct)
+ } else {
+ hp = fmt.Sprintf(" [%s/%dhp]", color.Render(g.colorMode(sess), color.Parse("167"), fmt.Sprint(m.HP)), m.MaxHP)
+ }
}
var desc string
if combat.IsMobInCombat(m.InstanceID) {
@@ -467,6 +504,14 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine("You can't see anything that way.")
return
}
+ if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
+ msg := exitDef.BlockedMessage
+ if msg == "" {
+ msg = fmt.Sprintf("The way %s is blocked.", exitDir)
+ }
+ sess.WriteLine(msg)
+ return
+ }
g.World.SeedGroundItems(exitDef.Room)
g.seedRoomMobs(exitDef.Room)
origRoom := p.RoomID
@@ -521,8 +566,43 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
return
}
- instances := g.World.FindObjInstances(p.RoomID, lower)
- if len(instances) > 0 {
+ rawInstances := g.World.FindObjInstances(p.RoomID, lower)
+
+ // Group matched object instances by definition, keeping only those visible
+ // to this player (an object whose conditional descriptions all fail is
+ // absent). If more than one distinct object matches, disambiguate.
+ var presentDefs []string
+ byDef := map[string][]world.ObjState{}
+ descByDef := map[string]string{}
+ for _, ist := range rawInstances {
+ def, err := g.ObjectStore.Load(ist.DefID)
+ if err != nil {
+ continue
+ }
+ text, present := g.resolveObjDesc(sess, def)
+ if !present {
+ continue
+ }
+ if _, seen := byDef[ist.DefID]; !seen {
+ presentDefs = append(presentDefs, ist.DefID)
+ descByDef[ist.DefID] = text
+ }
+ byDef[ist.DefID] = append(byDef[ist.DefID], ist)
+ }
+
+ if len(presentDefs) > 1 {
+ sess.WriteLine("That's ambiguous, which one?")
+ for _, defID := range presentDefs {
+ if def, err := g.ObjectStore.Load(defID); err == nil {
+ sess.WriteLine(fmt.Sprintf(" %s", def.Name))
+ }
+ }
+ return
+ }
+
+ if len(presentDefs) == 1 {
+ instances := byDef[presentDefs[0]]
+ objDescText := descByDef[presentDefs[0]]
st := &instances[0]
def, _ := g.ObjectStore.Load(st.DefID)
@@ -538,8 +618,8 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
sess.WriteLine(def.Name)
}
- if def.Description != "" && !strings.Contains(def.Description, "{quality}") {
- sess.WriteLine(fmt.Sprintf(" %s", def.Description))
+ if objDescText != "" && !strings.Contains(objDescText, "{quality}") {
+ sess.WriteLine(fmt.Sprintf(" %s", objDescText))
}
if def.Safespot != nil {
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 85abbdf..b7cab37 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -42,12 +42,34 @@ func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) {
targetID := exitDef.Room
- _, err = g.World.LoadRoom(targetID)
+ targetRoom, err := g.World.LoadRoom(targetID)
if err != nil {
sess.WriteLine("That path seems blocked.")
return
}
+ // Warn before walking from a safe room into a dangerous (hazardous) one.
+ // Dangerous->dangerous never prompts (the current room is already hazardous).
+ if targetRoom.Hazard != "" && room.Hazard == "" && p.OptionBool("danger_warning") {
+ if p.PendingDangerDir == dir {
+ p.PendingDangerDir = ""
+ } else {
+ p.PendingDangerDir = dir
+ p.WalkSequence = nil
+ sess.State = net.StateDangerConfirm
+ name := targetRoom.Hazard
+ if hz, herr := g.World.LoadHazard(targetRoom.Hazard); herr == nil && hz.Name != "" {
+ name = hz.Name
+ }
+ sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf("WARNING: that area is exposed to %s.", name)))
+ sess.WriteLine("Enter anyway? [Y/n]")
+ return
+ }
+ }
+
+ p.MovePendingFlags = exitDef.SetFlags
+ p.MovePendingPlayerFlags = exitDef.SetPlayerFlags
+
inCombat := combat.GetCombat(p.Name) != nil
if !inCombat && p.Action != nil {
@@ -108,6 +130,8 @@ func (g *Game) moveTicks(p *player.Player, multiplier float64) int {
func (g *Game) completeMove(sess *net.Session, p *player.Player) {
exitDir := p.MoveDirection
targetID := p.MoveTarget
+ pendingFlags := p.MovePendingFlags
+ pendingPlayerFlags := p.MovePendingPlayerFlags
p.ClearMoveState()
@@ -127,7 +151,16 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) {
oldRoom := p.RoomID
p.RoomID = targetID
+ p.HazardTimer = 0
p.Stats.RecordRoomVisit(targetID)
+
+ g.cancelEnterSeq(p.Name)
+ if p.CutsceneRoom != 0 && p.CutsceneRoom == oldRoom {
+ p.CutsceneRoom = 0
+ }
+ if len(pendingFlags) > 0 || len(pendingPlayerFlags) > 0 {
+ g.applyFlagMutations(p, pendingFlags, pendingPlayerFlags)
+ }
g.AccountStore.SaveCharacter(p)
g.World.SeedGroundItems(p.RoomID)
@@ -179,6 +212,35 @@ func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) {
g.doMove(sess, strings.TrimSpace(rawInput), 1.0)
}
+// handleDangerConfirm processes the [Y/n] reply to the dangerous-area warning.
+// Pressing return (empty), "y", or "yes" enters; anything else cancels.
+func (g *Game) handleDangerConfirm(sess *net.Session, input string) {
+ p := sess.Player
+ if p == nil {
+ sess.State = net.StateGame
+ g.writePrompt(sess)
+ return
+ }
+
+ dir := p.PendingDangerDir
+ choice := strings.TrimSpace(strings.ToLower(input))
+ sess.State = net.StateGame
+
+ if choice != "" && choice != "y" && choice != "yes" {
+ p.PendingDangerDir = ""
+ sess.WriteLine("You decide against it.")
+ g.writePrompt(sess)
+ return
+ }
+
+ if dir == "" {
+ g.writePrompt(sess)
+ return
+ }
+ g.doMove(sess, dir, 1.0)
+ g.writePrompt(sess)
+}
+
func (g *Game) seedRoomObjects(roomID int) {
room, err := g.World.LoadRoom(roomID)
if err != nil {
@@ -195,6 +257,9 @@ func (g *Game) seedRoomObjects(roomID int) {
continue
}
g.World.SetObjName(roomID, obj.ID, def.Name)
+ if len(def.Aliases) > 0 {
+ g.World.SetObjAliases(roomID, obj.ID, def.Aliases)
+ }
if len(obj.WanderRooms) > 0 {
g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval)
}
diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go
index fe3d90a..be1c276 100644
--- a/internal/game/cmd_registry.go
+++ b/internal/game/cmd_registry.go
@@ -22,6 +22,7 @@ var commandRegistry = map[string]commandDef{
"drop": {(*Game).executeDrop, ClassActive},
"attack": {(*Game).executeAttack, ClassActive},
"kill": {(*Game).executeAttack, ClassActive},
+ "work": {(*Game).executeWork, ClassActive},
"style": {(*Game).executeStyle, ClassInstant},
"look": {(*Game).executeLook, ClassInstant},
"l": {(*Game).executeLook, ClassInstant},
diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go
index 9f3c786..635c4b4 100644
--- a/internal/game/cmd_score.go
+++ b/internal/game/cmd_score.go
@@ -43,7 +43,7 @@ func (g *Game) doScore(sess *net.Session) {
}
skillDisplay := map[player.SkillName]string{
- player.Attack: "Attack", player.Strength: "Strength", player.Defense: "Defense",
+ player.Accuracy: "Accuracy", player.Strength: "Strength", player.Defense: "Defense",
player.Hitpoints: "Hitpoints", player.Ranged: "Ranged", player.Science: "Science",
player.Technology: "Technology", player.Assassin: "Assassin",
player.Fishing: "Fishing", player.Woodcutting: "Woodcutting", player.Mining: "Mining",
@@ -61,7 +61,7 @@ func (g *Game) doScore(sess *net.Session) {
}
categories := []skillCat{
{"Combat", []player.SkillName{
- player.Attack, player.Strength, player.Defense, player.Hitpoints,
+ player.Accuracy, player.Strength, player.Defense, player.Hitpoints,
player.Ranged, player.Science, player.Technology, player.Assassin,
}},
{"Gathering", []player.SkillName{
diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go
index 9987a1d..2466a5f 100644
--- a/internal/game/cmd_stats.go
+++ b/internal/game/cmd_stats.go
@@ -67,7 +67,7 @@ func (g *Game) doStats(sess *net.Session) {
equipAtt := combat.SelectBonus(attackType,
totals.StabAttack, totals.SlashAttack, totals.CrushAttack,
totals.ScienceAttack, totals.RangedAttack)
- attRoll = combat.EffectiveRoll(p.Level(player.Attack), attBonus, equipAtt)
+ attRoll = combat.EffectiveRoll(p.Level(player.Accuracy), attBonus, equipAtt)
maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus)
}
diff --git a/internal/game/condition_test.go b/internal/game/condition_test.go
new file mode 100644
index 0000000..5e5967d
--- /dev/null
+++ b/internal/game/condition_test.go
@@ -0,0 +1,155 @@
+package game
+
+import (
+ "testing"
+
+ "thehouseoficarus/internal/action"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+func sessWithFlags(flags map[string]any) *net.Session {
+ return &net.Session{Player: &player.Player{Flags: flags}}
+}
+
+func TestIsTruthy(t *testing.T) {
+ cases := []struct {
+ v any
+ want bool
+ }{
+ {nil, false},
+ {true, true},
+ {false, false},
+ {0, false},
+ {1, true},
+ {int64(0), false},
+ {int64(2), true},
+ {0.0, false},
+ {1.5, true},
+ {"", false},
+ {"x", true},
+ }
+ for _, c := range cases {
+ if got := isTruthy(c.v); got != c.want {
+ t.Errorf("isTruthy(%v) = %v, want %v", c.v, got, c.want)
+ }
+ }
+}
+
+func TestCheckConditionPlayerFlagTruthy(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+
+ set := sessWithFlags(map[string]any{"x": true})
+ unset := sessWithFlags(map[string]any{})
+
+ // {player_flag: x} -> set means present+truthy
+ posCond := &action.Condition{PlayerFlag: "x"}
+ if !g.checkCondition(set, posCond) {
+ t.Error("expected positive flag check to pass when flag is true")
+ }
+ if g.checkCondition(unset, posCond) {
+ t.Error("expected positive flag check to fail when flag is unset")
+ }
+
+ // {player_flag: x, not: true} -> negative
+ negCond := &action.Condition{PlayerFlag: "x", Not: true}
+ if g.checkCondition(set, negCond) {
+ t.Error("expected negative flag check to fail when flag is true")
+ }
+ if !g.checkCondition(unset, negCond) {
+ t.Error("expected negative flag check to pass when flag is unset")
+ }
+
+ // flag explicitly set to false counts as not-set for truthy checks
+ falseSet := sessWithFlags(map[string]any{"x": false})
+ if g.checkCondition(falseSet, posCond) {
+ t.Error("expected positive check to fail when flag is false")
+ }
+ if !g.checkCondition(falseSet, negCond) {
+ t.Error("expected negative check to pass when flag is false")
+ }
+}
+
+func TestCheckConditionValueComparisonPreserved(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+ sess := sessWithFlags(map[string]any{"n": 1})
+
+ if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 1}) {
+ t.Error("value match should pass for equal int")
+ }
+ if g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2}) {
+ t.Error("value match should fail for unequal int")
+ }
+ if !g.checkCondition(sess, &action.Condition{PlayerFlag: "n", Value: 2, Not: true}) {
+ t.Error("negated value match should pass for unequal int")
+ }
+}
+
+func TestCheckConditionWorldFlag(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{"gate_open": true}}
+ sess := sessWithFlags(map[string]any{})
+
+ if !g.checkCondition(sess, &action.Condition{Flag: "gate_open"}) {
+ t.Error("world flag truthy check should pass")
+ }
+ if g.checkCondition(sess, &action.Condition{Flag: "gate_open", Not: true}) {
+ t.Error("negated world flag should fail when set")
+ }
+ if g.checkCondition(sess, &action.Condition{Flag: "missing"}) {
+ t.Error("missing world flag should not pass")
+ }
+}
+
+func TestResolveObjDescPresence(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+
+ def := &object.ObjectDef{
+ Descriptions: []object.ConditionalDesc{
+ {Text: "lined", Condition: &action.Condition{PlayerFlag: "lined_up"}},
+ {Text: "milling", Condition: &action.Condition{PlayerFlag: "lined_up", Not: true}},
+ },
+ }
+
+ // not lined up -> milling, present
+ if text, present := g.resolveObjDesc(sessWithFlags(map[string]any{}), def); !present || text != "milling" {
+ t.Errorf("expected milling/present, got %q/%v", text, present)
+ }
+ // lined up -> lined, present
+ if text, present := g.resolveObjDesc(sessWithFlags(map[string]any{"lined_up": true}), def); !present || text != "lined" {
+ t.Errorf("expected lined/present, got %q/%v", text, present)
+ }
+
+ // gated entirely off -> absent
+ gated := &object.ObjectDef{
+ Descriptions: []object.ConditionalDesc{
+ {Text: "x", Condition: &action.Condition{PlayerFlag: "done", Not: true}},
+ },
+ }
+ if _, present := g.resolveObjDesc(sessWithFlags(map[string]any{"done": true}), gated); present {
+ t.Error("expected object to be absent when no variant matches")
+ }
+
+ // plain object (no Descriptions) always present
+ plain := &object.ObjectDef{Description: "plain"}
+ if text, present := g.resolveObjDesc(sessWithFlags(nil), plain); !present || text != "plain" {
+ t.Errorf("expected plain/present, got %q/%v", text, present)
+ }
+}
+
+func TestRoomDescriptionSelection(t *testing.T) {
+ g := &Game{WorldFlags: map[string]any{}}
+ room := &world.Room{
+ Description: "empty pad",
+ Descriptions: []world.RoomDesc{
+ {Text: "crowd", Condition: &action.Condition{PlayerFlag: "done", Not: true}},
+ },
+ }
+ if got := g.roomDescription(sessWithFlags(map[string]any{}), room); got != "crowd" {
+ t.Errorf("first visit: want crowd, got %q", got)
+ }
+ if got := g.roomDescription(sessWithFlags(map[string]any{"done": true}), room); got != "empty pad" {
+ t.Errorf("revisit: want empty pad, got %q", got)
+ }
+}
diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go
index f0d6863..dec6dae 100644
--- a/internal/game/core_login_char.go
+++ b/internal/game/core_login_char.go
@@ -41,8 +41,8 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) {
}
p := player.New(name)
- p.RoomID = 1
- p.Stats.RecordRoomVisit(1)
+ p.RoomID = 1001
+ p.Stats.RecordRoomVisit(1001)
if err := g.AccountStore.SaveCharacter(p); err != nil {
sess.WriteLine(fmt.Sprintf("Error creating character: %v", err))
@@ -101,6 +101,23 @@ func (g *Game) connectCharacter(sess *net.Session, name string) {
g.doLook(sess)
g.checkAggro(sess)
+
+ // Resume an interrupted on_enter cutscene (e.g. disconnected mid-sequence).
+ // Only fires when a persisted marker matches the room the player is in, so
+ // ordinary on_enter scripts never run on login.
+ if p.CutsceneRoom != 0 {
+ if p.CutsceneRoom == p.RoomID {
+ g.runEnterSteps(sess, p.RoomID)
+ }
+ g.enterMu.Lock()
+ _, active := g.enterSeqs[p.Name]
+ g.enterMu.Unlock()
+ if !active {
+ p.CutsceneRoom = 0
+ g.AccountStore.SaveCharacter(p)
+ }
+ }
+
sess.Write("\r\n> ")
}
diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go
index 4bdb50d..323e519 100644
--- a/internal/game/core_startup.go
+++ b/internal/game/core_startup.go
@@ -31,6 +31,7 @@ func (g *Game) ValidateStartup() []StartupIssue {
issues = append(issues, validateRooms(g)...)
issues = append(issues, validateMobs(g)...)
+ issues = append(issues, validateHazards(g)...)
issues = append(issues, validateObjects(g)...)
issues = append(issues, validateItems(g)...)
issues = append(issues, validateCraftBlocks(g)...)
@@ -66,6 +67,7 @@ func validateRooms(g *Game) []StartupIssue {
itemIDs := g.ItemStore.IDSet()
mobIDs := g.MobStore.AllDefIDs()
objIDs := g.ObjectStore.IDSet()
+ hazardIDs := g.World.HazardIndex()
for id := range roomIndex {
room, err := g.World.LoadRoom(id)
@@ -164,6 +166,15 @@ func validateRooms(g *Game) []StartupIssue {
}
}
}
+
+ if room.Hazard != "" && !hazardIDs[room.Hazard] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: references nonexistent hazard %q",
+ id, room.Hazard),
+ })
+ }
}
return issues
@@ -193,6 +204,14 @@ func validateMobs(g *Game) []StartupIssue {
})
}
+ if def.Kind != "" && def.Kind != "combat" && def.Kind != "task" {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: invalid kind %q (must be \"combat\" or \"task\")", id, def.Kind),
+ })
+ }
+
if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] {
issues = append(issues, StartupIssue{
Level: "ERROR",
@@ -243,6 +262,40 @@ func validateMobs(g *Game) []StartupIssue {
return issues
}
+func validateHazards(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ itemIDs := g.ItemStore.IDSet()
+ validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true}
+
+ for id := range g.World.HazardIndex() {
+ def, err := g.World.LoadHazard(id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.AttackType != "" && !validTypes[def.AttackType] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: invalid attack_type %q (must be stab/slash/crush/ranged/science)", id, def.AttackType),
+ })
+ }
+ if def.RequiredItem != "" && !itemIDs[def.RequiredItem] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Hazard %q: required_item %q does not exist", id, def.RequiredItem),
+ })
+ }
+ }
+
+ return issues
+}
+
func validateObjects(g *Game) []StartupIssue {
var issues []StartupIssue
objIDs := g.ObjectStore.IDSet()
diff --git a/internal/game/game.go b/internal/game/game.go
index a1b7bbe..b9b54ba 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -59,6 +59,8 @@ type Game struct {
hackingStates map[string]*hacking.Session
safespotMu sync.Mutex
safespotStates map[string]*SafespotState
+ enterMu sync.Mutex
+ enterSeqs map[string]*enterSeq
}
type SafespotState struct {
@@ -92,6 +94,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig, valConfig config.Vali
guardWatchTimers: make(map[string]int),
hackingStates: make(map[string]*hacking.Session),
safespotStates: make(map[string]*SafespotState),
+ enterSeqs: make(map[string]*enterSeq),
}
g.CourseStore.LoadAll()
g.LoadMods()
@@ -105,6 +108,7 @@ func (g *Game) SetHub(hub *net.Hub) {
hub.OnRemove(func(sess *net.Session) {
if p := sess.Player; p != nil {
p.DeactivateAllTechs()
+ g.cancelEnterSeq(p.Name)
g.charsMu.Lock()
delete(g.loggedInChars, p.Name)
g.charsMu.Unlock()
@@ -167,6 +171,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) {
g.handleColorChoice(sess, input)
case net.StateHacking:
g.handleHackingInput(sess, input)
+ case net.StateDangerConfirm:
+ g.handleDangerConfirm(sess, input)
}
}
diff --git a/internal/game/map_test.go b/internal/game/map_test.go
index b7d9f01..dded62e 100644
--- a/internal/game/map_test.go
+++ b/internal/game/map_test.go
@@ -21,32 +21,32 @@ func TestBuildTinyMap(t *testing.T) {
want []string // expected lines, or nil to just check count
}{
{
- name: "room 1 has east exit",
- roomID: 1,
+ name: "room 999991 has east exit",
+ roomID: 999991,
},
{
- name: "room 2 has west/north/east",
- roomID: 2,
+ name: "room 999992 has west/north/east",
+ roomID: 999992,
},
{
- name: "room 4 has west/north/east",
- roomID: 4,
+ name: "room 999994 has west/north/east",
+ roomID: 999994,
},
{
- name: "room 21 has west only",
- roomID: 21,
+ name: "room 9999921 has west only",
+ roomID: 9999921,
},
{
- name: "room 22 west of town square",
- roomID: 22,
+ name: "room 9999922 west of town square",
+ roomID: 9999922,
},
{
- name: "room 25 east of south of west",
- roomID: 25,
+ name: "room 9999925 east of south of west",
+ roomID: 9999925,
},
{
- name: "room 8 hacking lab",
- roomID: 8,
+ name: "room 999998 hacking lab",
+ roomID: 999998,
},
}
@@ -108,10 +108,10 @@ func TestBuildFullMap(t *testing.T) {
width int
height int
}{
- {"room 1 30x20", 1, 30, 20},
- {"room 1 20x10", 1, 20, 10},
- {"room 25 30x20", 25, 30, 20},
- {"min size 5x5", 1, 5, 5},
+ {"room 999991 30x20", 999991, 30, 20},
+ {"room 999991 20x10", 999991, 20, 10},
+ {"room 9999925 30x20", 9999925, 30, 20},
+ {"min size 5x5", 999991, 5, 5},
}
for _, tt := range tests {
diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go
new file mode 100644
index 0000000..29bf65e
--- /dev/null
+++ b/internal/game/sys_hazard.go
@@ -0,0 +1,136 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/combat"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// HazardTick rolls environmental attacks against every player standing in a
+// hazardous room. It reuses the combat formulas exactly: the hazard "attacks"
+// and the player defends with their Defense level + equipment bonus selected by
+// the hazard's attack type. It runs independently of combat, so a player can be
+// hit by BOTH a mob and the room hazard in the same area. It deliberately never
+// touches movement state — a hazard hit does NOT interrupt walking.
+func (g *Game) HazardTick() {
+ if g.Hub == nil {
+ return
+ }
+ for _, sess := range g.Hub.AllSessions() {
+ p := sess.Player
+ if p == nil {
+ continue
+ }
+
+ room, err := g.World.LoadRoom(p.RoomID)
+ if err != nil || room.Hazard == "" {
+ p.HazardTimer = 0
+ continue
+ }
+ hz, err := g.World.LoadHazard(room.Hazard)
+ if err != nil || hz == nil {
+ p.HazardTimer = 0
+ continue
+ }
+
+ // A safespot shields the player from all hazard damage while hidden.
+ if g.safespotBlocksHazard(p) {
+ continue
+ }
+
+ // The right protective gear negates the hazard entirely.
+ if hz.RequiredItem != "" && g.hasEquipped(p, hz.RequiredItem) {
+ continue
+ }
+
+ speed := hz.Speed
+ if speed <= 0 {
+ speed = 5
+ }
+ p.HazardTimer++
+ if float64(p.HazardTimer) >= speed {
+ p.HazardTimer = 0
+ g.rollHazard(sess, p, hz)
+ }
+ }
+}
+
+func (g *Game) hasEquipped(p *player.Player, itemID string) bool {
+ for _, id := range p.Equipment {
+ if id == itemID {
+ return true
+ }
+ }
+ return false
+}
+
+func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardDef) {
+ attackType := hz.AttackType
+ if attackType == "" {
+ attackType = "crush"
+ }
+
+ attRoll := combat.EffectiveRoll(hz.Attack, 0, hz.AttackBonus)
+
+ _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle))
+ totals := g.playerEquipBonuses(p)
+ equipDef := combat.SelectBonus(attackType,
+ totals.StabDefense, totals.SlashDefense, totals.CrushDefense,
+ totals.ScienceDefense, totals.RangedDefense)
+ defRoll := combat.EffectiveRoll(
+ p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"),
+ defStyleBonus, equipDef)
+
+ if combat.HitCheck(attRoll, defRoll) {
+ g.applyHazardHit(sess, p, hz)
+ } else {
+ miss := hz.MissMessage
+ if miss == "" {
+ miss = fmt.Sprintf("You weather the %s.", hz.Name)
+ }
+ sess.WriteLine("\n" + g.colorize(sess, "miss", miss))
+ g.writePrompt(sess)
+ }
+}
+
+func (g *Game) applyHazardHit(sess *net.Session, p *player.Player, hz *world.HazardDef) {
+ maxHit := hz.MaxHit
+ if maxHit < 1 {
+ maxHit = 1
+ }
+ dmg := combat.RollDamage(maxHit)
+ dmg = g.damageAfterTechProtection(p, hz.AttackType, dmg)
+ if dmg < 0 {
+ dmg = 0
+ }
+
+ p.HP -= dmg
+ if p.HP < 0 {
+ p.HP = 0
+ }
+ p.StartRegen()
+ g.AccountStore.SaveCharacter(p)
+
+ msg := hz.HitMessage
+ switch {
+ case msg == "":
+ msg = fmt.Sprintf("The %s hits you for %d damage.", hz.Name, dmg)
+ case strings.Contains(msg, "%d"):
+ 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)
+
+ // NB: a hazard never clears MoveState, so it cannot interrupt a walk the way
+ // a mob hit ("Can't escape!") does.
+
+ if p.HP <= 0 {
+ g.killPlayer(sess, p, nil)
+ return
+ }
+ g.writePrompt(sess)
+}
diff --git a/internal/game/sys_labor.go b/internal/game/sys_labor.go
new file mode 100644
index 0000000..102c54e
--- /dev/null
+++ b/internal/game/sys_labor.go
@@ -0,0 +1,81 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+ "thehouseoficarus/internal/world"
+)
+
+// taskWorkVerb returns the flavor verb for working on a task worksite. Designers
+// set `verb:` on the mob (e.g. "survey", "prospect", "build"); the default is
+// "work on". The player-facing command is always `work` (or `attack`).
+func taskWorkVerb(mob *world.MobInstance) string {
+ if mob != nil && mob.Verb != "" {
+ return mob.Verb
+ }
+ return "work on"
+}
+
+// progressBar renders a bar that fills as progress rises toward max — the
+// inverse of hpBar. It is used for task worksites whose internal HP drains to 0
+// to complete the work (progress = MaxHP - HP).
+func (g *Game) progressBar(sess *net.Session, progress, max int) string {
+ if max <= 0 {
+ max = 1
+ }
+ if progress < 0 {
+ progress = 0
+ }
+ if progress > max {
+ progress = max
+ }
+
+ pct := float64(progress) / float64(max) * 100.0
+ var fgColor int
+ switch {
+ case pct >= 70:
+ fgColor = 82
+ case pct >= 40:
+ fgColor = 220
+ default:
+ fgColor = 39
+ }
+
+ unicode := sess.Player != nil && sess.Player.OptionBool("unicode")
+ style := &BarStyle{FilledColor: fgColor, EmptyColor: 238, EmptyDim: true}
+ bar := RenderColoredBar(progress, max, 10, unicode, style, g.colorMode(sess))
+ return "[" + bar + "]"
+}
+
+// writeTaskProgress prints the per-tick progress line for a task worksite,
+// mirroring the combat hit line but framed as labor instead of damage.
+func (g *Game) writeTaskProgress(sess *net.Session, p *player.Player, mob *world.MobInstance, dmg int, gains []xpGain) {
+ progress := mob.MaxHP - mob.HP
+ if progress < 0 {
+ progress = 0
+ }
+ pct := 0
+ if mob.MaxHP > 0 {
+ pct = progress * 100 / mob.MaxHP
+ }
+
+ noun := mob.ProgressNoun
+ if noun == "" {
+ noun = "work"
+ }
+
+ prefix := fmt.Sprintf("You advance the %s on %s.", noun, g.colorize(sess, "mob", mobDisplayName(mob, true)))
+ line := fmt.Sprintf("%s %s %3d%%", prefix, g.progressBar(sess, progress, mob.MaxHP), pct)
+
+ if p.OptionBool("xp_drops") && len(gains) > 0 {
+ var parts []string
+ for _, gain := range gains {
+ parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
+ }
+ line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
+ }
+ sess.WriteLine(line)
+}
diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go
index da999c0..44a8f9f 100644
--- a/internal/game/sys_safespot.go
+++ b/internal/game/sys_safespot.go
@@ -46,6 +46,13 @@ func blocksMob(maxBlockSize, mobSize string) bool {
return mob <= max
}
+// safespotBlocksHazard reports whether the player's active safespot shields them
+// from room hazard damage. Unlike mobs, hazard type does not matter: an active
+// safespot blocks ALL hazard damage, no safespot blocks none.
+func (g *Game) safespotBlocksHazard(p *player.Player) bool {
+ return g.isSafespotted(p.Name)
+}
+
func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool {
g.safespotMu.Lock()
ss, ok := g.safespotStates[p.Name]
diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go
index bb48375..e673dcb 100644
--- a/internal/game/sys_technology.go
+++ b/internal/game/sys_technology.go
@@ -10,7 +10,7 @@ import (
)
type TechEffects struct {
- AttackPercent int
+ AccuracyPercent int
StrengthPercent int
DefensePercent int
RangedPercent int
@@ -39,9 +39,9 @@ var techByID map[string]*TechDef
func init() {
AllTechs = []TechDef{
- {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 5}},
- {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 10}},
- {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "attack", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15}},
+ {ID: "clarity_1", Name: "Clarity", Level: 4, DrainRate: 0.05, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 5}},
+ {ID: "clarity_2", Name: "Enhanced Clarity", Level: 16, DrainRate: 0.10, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 10}},
+ {ID: "clarity_3", Name: "Superior Clarity", Level: 44, DrainRate: 0.15, Category: "accuracy", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 15}},
{ID: "amplifier_1", Name: "Power Amplifier", Level: 7, DrainRate: 0.05, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 5}},
{ID: "amplifier_2", Name: "Enhanced Amplifier", Level: 23, DrainRate: 0.10, Category: "strength", Group: "strength_tier", Effects: TechEffects{StrengthPercent: 10}},
@@ -68,7 +68,7 @@ func init() {
{ID: "preserve", Name: "Power Saver", Level: 55, DrainRate: 0.05, Category: "utility", Group: "", Effects: TechEffects{PreserveDrain: 0.2}},
{ID: "retribution", Name: "Dead Man's Switch", Level: 46, DrainRate: 0.10, Category: "utility", Group: "", Effects: TechEffects{RetributionPct: 0.25}},
- {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "attack_tier", Effects: TechEffects{AttackPercent: 15, StrengthPercent: 15}},
+ {ID: "overclock", Name: "Overclock", Level: 60, DrainRate: 0.25, Category: "combo", Group: "accuracy_tier", Effects: TechEffects{AccuracyPercent: 15, StrengthPercent: 15}},
{ID: "fortify", Name: "Fortify", Level: 65, DrainRate: 0.25, Category: "combo", Group: "defense_tier", Effects: TechEffects{DefensePercent: 15, HPRegenMulti: 2.0}},
}
@@ -104,8 +104,8 @@ func TechByPrefixMatch(input string) []*TechDef {
func techEffectString(tech TechDef) string {
var parts []string
e := tech.Effects
- if e.AttackPercent > 0 {
- parts = append(parts, fmt.Sprintf("+%d%% Attack", e.AttackPercent))
+ if e.AccuracyPercent > 0 {
+ parts = append(parts, fmt.Sprintf("+%d%% Accuracy", e.AccuracyPercent))
}
if e.StrengthPercent > 0 {
parts = append(parts, fmt.Sprintf("+%d%% Strength", e.StrengthPercent))
@@ -163,8 +163,8 @@ func (g *Game) techLevelBonus(p *player.Player, stat string) int {
continue
}
switch stat {
- case "attack":
- totalPercent += def.Effects.AttackPercent
+ case "accuracy":
+ totalPercent += def.Effects.AccuracyPercent
case "strength":
totalPercent += def.Effects.StrengthPercent
case "defense":
@@ -182,8 +182,8 @@ func (g *Game) techLevelBonus(p *player.Player, stat string) int {
var baseLevel int
switch stat {
- case "attack":
- baseLevel = p.Level(player.Attack)
+ case "accuracy":
+ baseLevel = p.Level(player.Accuracy)
case "strength":
baseLevel = p.Level(player.Strength)
case "defense":
@@ -217,11 +217,17 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo
}
func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int {
+ return g.damageAfterTechProtection(p, mob.AttackType, dmg)
+}
+
+// damageAfterTechProtection reduces incoming damage if the player has the
+// protection tech matching the given attack type active. Shared by mob combat
+// and room hazards.
+func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dmg int) int {
if len(p.ActiveTechs) == 0 {
return dmg
}
- attackType := mob.AttackType
if attackType == "" {
attackType = "crush"
}
diff --git a/internal/net/server.go b/internal/net/server.go
index eaa8f17..b2cc3be 100644
--- a/internal/net/server.go
+++ b/internal/net/server.go
@@ -41,6 +41,7 @@ const (
StateShop
StateBank
StateHacking
+ StateDangerConfirm
)
type Session struct {
diff --git a/internal/object/object.go b/internal/object/object.go
index 12e5585..f7a0e7b 100644
--- a/internal/object/object.go
+++ b/internal/object/object.go
@@ -12,11 +12,13 @@ type UseInteraction struct {
type ObjectDef struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
+ Aliases []string `yaml:"aliases"`
Color string `yaml:"color"`
Hidden bool `yaml:"hidden"`
InRoomDescription string `yaml:"inroom_description"`
RemovalItem string `yaml:"removal_item"`
Description string `yaml:"description"`
+ Descriptions []ConditionalDesc `yaml:"descriptions,omitempty"`
UseInteractions []UseInteraction `yaml:"use_interactions"`
StealTable string `yaml:"steal_table"`
StealLevel int `yaml:"steal_level"`
@@ -30,6 +32,15 @@ type ObjectDef struct {
Safespot *SafespotConfig `yaml:"safespot,omitempty"`
}
+// ConditionalDesc is a description variant gated by a condition. They are
+// evaluated in order per-looker; the first whose condition passes (or has no
+// condition) wins. If a def has Descriptions but none match for a given player,
+// the object is treated as absent for that player.
+type ConditionalDesc struct {
+ Text string `yaml:"text"`
+ Condition *action.Condition `yaml:"condition"`
+}
+
type SafespotConfig struct {
Tier int `yaml:"tier"`
MaxBlockSize string `yaml:"max_block_size"`
diff --git a/internal/player/player.go b/internal/player/player.go
index ebba376..03ed7ac 100644
--- a/internal/player/player.go
+++ b/internal/player/player.go
@@ -10,7 +10,7 @@ import (
type SkillName string
const (
- Attack SkillName = "attack"
+ Accuracy SkillName = "accuracy"
Strength SkillName = "strength"
Defense SkillName = "defense"
Hitpoints SkillName = "hitpoints"
@@ -36,13 +36,13 @@ const (
)
var AllSkills = []SkillName{
- Attack, Strength, Defense, Hitpoints, Ranged, Science, Technology,
+ Accuracy, Strength, Defense, Hitpoints, Ranged, Science, Technology,
Fishing, Cooking, Woodcutting, Firemaking, Mining, Smithing, Crafting, Fletching,
Pharmacy, Thieving, Agility, Construction, Scavenging, Hacking, Assassin, Farming,
}
var SkillAbbr = map[SkillName]string{
- Attack: "atk",
+ Accuracy: "acc",
Strength: "str",
Defense: "def",
Hitpoints: "hp",
@@ -139,6 +139,7 @@ var OptionDefs = []OptionDef{
{"construct_all", OptBool, false, nil, "Auto-start constructing when only one product is possible"},
{"craft_all", OptBool, false, nil, "Auto-start crafting when only one product is possible"},
{"safespot_alert", OptString, "{196 bold}** Your safespot has been compromised! **{/}", nil, "Message shown when forced out of a safespot"},
+ {"danger_warning", OptBool, true, nil, "Confirm before entering a dangerous (hazardous) area"},
}
var optionByName map[string]*OptionDef
@@ -169,6 +170,7 @@ type Player struct {
Prompt string `yaml:"prompt"`
Options map[string]any `yaml:"-"`
Flags map[string]any `yaml:"flags"`
+ CutsceneRoom int `yaml:"cutscene_room,omitempty"`
RegenerateTick int
AmmoQty int `yaml:"ammo_qty,omitempty"`
Action *action.Action `yaml:"-"`
@@ -180,10 +182,14 @@ type Player struct {
MoveTicks int `yaml:"-"`
MoveDirection string `yaml:"-"`
MoveTarget int `yaml:"-"`
+ MovePendingFlags map[string]any `yaml:"-"`
+ MovePendingPlayerFlags map[string]any `yaml:"-"`
VisualTickCurrent int `yaml:"-"`
AutotriggerMod string `yaml:"-"`
QueuedTrigger string `yaml:"-"`
AttackTimer int `yaml:"-"`
+ HazardTimer int `yaml:"-"`
+ PendingDangerDir string `yaml:"-"`
Battery float64 `yaml:"battery"`
ActiveTechs map[string]bool `yaml:"-"`
QuickTech string `yaml:"quick_tech,omitempty"`
@@ -210,6 +216,8 @@ func (p *Player) ClearMoveState() {
p.MoveTicks = 0
p.MoveDirection = ""
p.MoveTarget = 0
+ p.MovePendingFlags = nil
+ p.MovePendingPlayerFlags = nil
}
func (p *Player) OptionBool(name string) bool {
@@ -358,7 +366,7 @@ func (p *Player) AddSkillXP(s SkillName, amount int) int {
func (p *Player) CombatLevel() int {
base := 0.25 * float64(p.Level(Defense)+p.Level(Hitpoints)+p.Level(Technology))
- att := float64(p.Level(Attack))
+ att := float64(p.Level(Accuracy))
str := float64(p.Level(Strength))
if float64(p.Level(Ranged))*1.5 > att+str {
diff --git a/internal/world/hazard.go b/internal/world/hazard.go
new file mode 100644
index 0000000..396343b
--- /dev/null
+++ b/internal/world/hazard.go
@@ -0,0 +1,82 @@
+package world
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "thehouseoficarus/internal/action"
+ "gopkg.in/yaml.v3"
+)
+
+// HazardDef describes an environmental threat attached to a room (a sandstorm,
+// solar radiation, falling rocks, etc.). It is a shared, ID-referenced
+// definition loaded from data/hazards/<id>.yaml, so one hazard can be reused
+// across a whole area and resolved by name for a future `examine` command.
+//
+// A hazard rolls an attack against everyone in the room every Speed ticks using
+// the SAME combat formulas as a mob: accuracy comes from Attack/AttackBonus, max
+// hit from MaxHit, and the player defends with their Defense level + equipment
+// bonus selected by AttackType (so existing armour and the Defense skill apply).
+// Tech protection (Kinetic Barrier / Projectile Screen / Neural Firewall) keys
+// off AttackType automatically.
+type HazardDef struct {
+ ID string `yaml:"id"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description"`
+ AttackType string `yaml:"attack_type"` // stab/slash/crush/ranged/science
+ Attack int `yaml:"attack"`
+ AttackBonus int `yaml:"attack_bonus"`
+ MaxHit int `yaml:"max_hit"`
+ Speed float64 `yaml:"speed"` // ticks between hazard rolls
+ WarnMessage string `yaml:"warn_message"`
+ HitMessage string `yaml:"hit_message"` // accepts a single %d for damage
+ MissMessage string `yaml:"miss_message"`
+ RequiredItem string `yaml:"required_item"` // equipped item that negates the hazard
+}
+
+// LoadHazard returns the shared hazard definition for id, caching results.
+func (w *World) LoadHazard(id string) (*HazardDef, error) {
+ w.hazardMu.Lock()
+ if w.hazardPathIndex == nil {
+ w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards"))
+ }
+ if def, ok := w.hazardDefs[id]; ok {
+ w.hazardMu.Unlock()
+ return def, nil
+ }
+ path, ok := w.hazardPathIndex[id]
+ w.hazardMu.Unlock()
+ if !ok {
+ return nil, fmt.Errorf("read hazard %s: no such hazard", id)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read hazard %s: %w", id, err)
+ }
+ var def HazardDef
+ if err := yaml.Unmarshal(data, &def); err != nil {
+ return nil, fmt.Errorf("parse hazard %s: %w", id, err)
+ }
+ def.ID = id
+
+ w.hazardMu.Lock()
+ w.hazardDefs[id] = &def
+ w.hazardMu.Unlock()
+ return &def, nil
+}
+
+// HazardIndex returns the set of known hazard IDs (used by startup validation).
+func (w *World) HazardIndex() map[string]bool {
+ w.hazardMu.Lock()
+ defer w.hazardMu.Unlock()
+ if w.hazardPathIndex == nil {
+ w.hazardPathIndex = action.BuildPathIndex(filepath.Join(w.dataDir, "hazards"))
+ }
+ ids := make(map[string]bool, len(w.hazardPathIndex))
+ for id := range w.hazardPathIndex {
+ ids[id] = true
+ }
+ return ids
+}
diff --git a/internal/world/mob.go b/internal/world/mob.go
index ec7db36..4864e5c 100644
--- a/internal/world/mob.go
+++ b/internal/world/mob.go
@@ -58,11 +58,23 @@ type MobDef struct {
DamageWithout string `yaml:"damage_without"`
Size string `yaml:"size"`
+ // Kind discriminates a normal combat mob ("" or "combat") from a "task"
+ // worksite (build a solar panel, survey a rock, etc.). A task mob reuses the
+ // entire combat engine but its HP drains to 0 to COMPLETE the work; it never
+ // attacks back (the room hazard, if any, supplies the danger). The progress
+ // bar is displayed inverted (filling toward 100%).
+ Kind string `yaml:"kind"`
+ Verb string `yaml:"verb"` // flavor verb for messages (default "work")
+ ProgressNoun string `yaml:"progress_noun"` // flavor noun, e.g. "construction"
+ CompleteMessage string `yaml:"complete_message"` // shown on completion
+
Talk *action.TalkConfig `yaml:"talk,omitempty"`
}
func (d *MobDef) IsTalkable() bool { return d.Talk != nil }
+func (d *MobDef) IsTask() bool { return d.Kind == "task" }
+
type MobInstance struct {
InstanceID string
DefID string
@@ -109,11 +121,18 @@ type MobInstance struct {
DamageWithout string
Size string
+ Kind string
+ Verb string
+ ProgressNoun string
+ CompleteMessage string
+
TalkConfig *action.TalkConfig
}
func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil }
+func (m *MobInstance) IsTask() bool { return m.Kind == "task" }
+
func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []int, wanderInterval float64) *MobInstance {
return &MobInstance{
InstanceID: instanceID,
@@ -153,6 +172,10 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in
FinishingBlow: def.FinishingBlow,
DamageWithout: def.DamageWithout,
Size: def.Size,
+ Kind: def.Kind,
+ Verb: def.Verb,
+ ProgressNoun: def.ProgressNoun,
+ CompleteMessage: def.CompleteMessage,
TalkConfig: def.Talk,
}
}
diff --git a/internal/world/objects.go b/internal/world/objects.go
index bf4b592..a7e0605 100644
--- a/internal/world/objects.go
+++ b/internal/world/objects.go
@@ -14,6 +14,7 @@ type ObjState struct {
DepleteTimer float64
DefID string
Name string
+ Aliases []string
Index int
RoomID int
JustRespawned bool
@@ -160,12 +161,13 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
if st.RoomID != roomID {
continue
}
- if !wordMatchesObj(lower, st.DefID, st.Name) {
+ if !wordMatchesObj(lower, st.DefID, st.Name, st.Aliases) {
continue
}
out = append(out, ObjState{
DefID: st.DefID,
Name: st.Name,
+ Aliases: st.Aliases,
Index: st.Index,
RoomID: st.RoomID,
Depleted: st.Depleted,
@@ -184,10 +186,15 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState {
return out
}
-func wordMatchesObj(lower, defID, objName string) bool {
+func wordMatchesObj(lower, defID, objName string, aliases []string) bool {
if object.WordPrefixMatch(lower, objName) {
return true
}
+ for _, alias := range aliases {
+ if object.WordPrefixMatch(lower, alias) {
+ return true
+ }
+ }
inputWords := strings.Fields(strings.ToLower(lower))
if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) {
@@ -214,6 +221,16 @@ func (w *World) SetObjName(roomID int, defID string, name string) {
}
}
+func (w *World) SetObjAliases(roomID int, defID string, aliases []string) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ for _, st := range w.objStates {
+ if st.RoomID == roomID && st.DefID == defID {
+ st.Aliases = aliases
+ }
+ }
+}
+
func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) {
w.mu.Lock()
defer w.mu.Unlock()
diff --git a/internal/world/room.go b/internal/world/room.go
index 752ec41..2441c30 100644
--- a/internal/world/room.go
+++ b/internal/world/room.go
@@ -48,6 +48,8 @@ type ExitDef struct {
Room int `yaml:"room"`
Condition *action.Condition `yaml:"condition"`
BlockedMessage string `yaml:"blocked_message"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
}
func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error {
@@ -67,11 +69,18 @@ type Room struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
+ Descriptions []RoomDesc `yaml:"descriptions,omitempty"`
Exits map[ExitDir]ExitDef `yaml:"exits"`
Objects []RoomObject `yaml:"objects"`
ItemSpawns []SpawnDef `yaml:"item_spawns"`
Mobs []RoomMob `yaml:"mobs"`
OnEnter []EnterStep `yaml:"on_enter"`
+ Hazard string `yaml:"hazard"`
+}
+
+type RoomDesc struct {
+ Text string `yaml:"text"`
+ Condition *action.Condition `yaml:"condition"`
}
type RoomMob struct {
@@ -94,8 +103,19 @@ func (rm *RoomMob) UnmarshalYAML(value *yaml.Node) error {
}
type EnterStep struct {
- Message string `yaml:"message"`
- Condition *action.Condition `yaml:"condition"`
+ Message string `yaml:"message"`
+ Condition *action.Condition `yaml:"condition"`
+ Delay int `yaml:"delay"`
+ SetFlags map[string]any `yaml:"set_flags"`
+ SetPlayerFlags map[string]any `yaml:"set_player_flags"`
+}
+
+// IsTimed reports whether the step carries cutscene semantics: a tick delay or
+// a flag mutation. A room whose qualifying on_enter steps are all non-timed is
+// printed synchronously; any timed step turns the sequence into a scheduled
+// cutscene.
+func (e EnterStep) IsTimed() bool {
+ return e.Delay > 0 || len(e.SetFlags) > 0 || len(e.SetPlayerFlags) > 0
}
type RoomObject struct {
diff --git a/internal/world/world.go b/internal/world/world.go
index b305c4b..8ed9a7c 100644
--- a/internal/world/world.go
+++ b/internal/world/world.go
@@ -19,6 +19,10 @@ type World struct {
seeded map[int]bool
objStates map[string]*ObjState
objMoves []ObjMove
+
+ hazardMu sync.Mutex
+ hazardPathIndex map[string]string
+ hazardDefs map[string]*HazardDef
}
func New(dataDir string) *World {
@@ -28,6 +32,7 @@ func New(dataDir string) *World {
groundItems: make(map[int][]*groundEntry),
seeded: make(map[int]bool),
objStates: make(map[string]*ObjState),
+ hazardDefs: make(map[string]*HazardDef),
}
}
diff --git a/worldbuilding_guide/hidden_objects.md b/worldbuilding_guide/hidden_objects.md
deleted file mode 100644
index 24fd6bc..0000000
--- a/worldbuilding_guide/hidden_objects.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## Hidden Objects
-
-Objects with `hidden: true` don't appear in the room's object listing. Players discover them by reading room descriptions or trying commands. The object is still fully interactable — `push gate`, `look gate`, etc.
-
-```yaml
-# data/objects/secret_lever.yaml
-id: secret_lever
-name: stone lever
-hidden: true
-description: "A cleverly concealed lever behind a loose stone."
-use_interactions:
- - condition:
- flag: secret_passage_open
- value: true
- not: true
- message: "You pull the lever. A grinding sound echoes from the east."
- action:
- set_flags:
- secret_passage_open: true
-```
-
-Room description hints at it:
-```yaml
-description: "A dusty corridor. One of the wall stones looks slightly out of place."
-```
-
-### Safespot Objects
-
-Safespot objects should always be `hidden: true`. They only become visible in `look` when the player's effective safespot tier meets the object's `safespot.tier` requirement. This creates a natural discovery mechanic: completing quests and achievements reveals new coverage opportunities.
-
-Players can discover safespots by:
-- Reading room descriptions for hints
-- Trying `hide <object_name>` (name matching works even for hidden objects)
-- Using `look <object>` once they know the name
-- Gaining tier through progression, which reveals safespots in `look`
-
-See [Safespots](objects.md#safespots) for the full YAML format.
-
----
-
diff --git a/worldbuilding_guide/index.md b/worldbuilding_guide/index.md
deleted file mode 100644
index e79ee1b..0000000
--- a/worldbuilding_guide/index.md
+++ /dev/null
@@ -1,17 +0,0 @@
-## Quick Reference
-
-| What you want | Where to put it |
-|---|---|
-| A room | `data/rooms/<id>.yaml` |
-| An item (sword, ore, key) | `data/items/<id>.yaml` |
-| A mob (NPC, monster) | `data/mobs/<id>.yaml` |
-| An interactive object (rock, lever, door) | `data/objects/<id>.yaml` |
-| A shared drop table | `data/drops/<id>.yaml` |
-| A crafting recipe | In the output item's YAML under a `craft:` block |
-| A help topic | `data/help/<id>.yaml` |
-| A science module | `data/modules/<id>.yaml` |
-| An agility course | `data/courses/<id>.yaml` |
-| A player house | `data/rooms/player_housing/<id>.yaml` |
-
-Behaviors (gather, talk, use) are defined **inline** in object and mob YAML
-under `gather:`, `talk:`, or `use:` keys. There is no separate behavior directory.
diff --git a/worldbuilding_guide/rooms.md b/worldbuilding_guide/rooms.md
deleted file mode 100644
index 3d6db7d..0000000
--- a/worldbuilding_guide/rooms.md
+++ /dev/null
@@ -1,134 +0,0 @@
-## Rooms
-
-Minimal room:
-```yaml
-id: 1
-name: "Town Square"
-description: "Cobblestone paths lead in all directions. A fountain gurgles peacefully."
-exits:
- north: 2
- west: 7
- east: 3
-```
-
-### Inline Color Tags
-
-Room descriptions support inline color tags using `{spec}text{/}` syntax. Untagged text uses the `room_desc` color.
-
-```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{/}`.
-
-### Exits — simple vs conditional
-
-Simple exit — always passable:
-```yaml
-exits:
- north: 2
-```
-
-Conditional exit — blocked until a world flag is set:
-```yaml
-exits:
- north:
- room: 11
- condition:
- flag: gate_open
- value: true
- blocked_message: "A heavy iron gate blocks the way north."
-```
-
-Conditional exit — blocked unless the PLAYER has a flag (key, permission, quest state):
-```yaml
-exits:
- east:
- room: 12
- condition:
- player_flag: has_vault_key
- value: true
- blocked_message: "The vault door is locked. You need a key."
-```
-
-Conditional exit with compound condition — requires both a world flag AND a player flag:
-```yaml
-exits:
- north:
- room: 20
- condition:
- all_of:
- - flag: bridge_repaired
- value: true
- - player_flag: paid_toll
- value: true
- blocked_message: "The bridge is out, and the toll collector blocks the path."
-```
-
-### Item Spawns — ground items that respawn
-
-```yaml
-item_spawns:
- - id: bronze_pickaxe
- quantity: 1
- respawn_ticks: 30 # reappears 30 ticks (18 seconds) after being picked up
- - id: copper_ore
- quantity: 3
- respawn_ticks: 50
-```
-
-### Mobs — NPCs placed in the room
-
-Simple string (no wandering):
-```yaml
-mobs:
- - "newbie_trainer"
- - "man"
-```
-
-With wander config per-instance:
-```yaml
-mobs:
- - id: man
- wander_interval: 10 # attempts to wander every 10 ticks
- - id: man
- wander_interval: 15
- wander_rooms: [1, 4, 5] # optional — only exit to these rooms
-```
-
-Mob wander config lives in the room YAML, not in the mob definition. This keeps mobs generic
-so the same `man` can wander differently depending on where it's placed. Mobs wander through
-legal (unconditioned) room exits. If no legal exits exist, the mob stays still. Mobs with
-no `wander_interval` never wander.
-
-### Objects — interactive fixtures
-
-```yaml
-objects:
- - id: copper_rock # simple placement
- - id: copper_rock # second instance
- - id: fishing_spot
- wander_rooms: [7, 8, 9] # teleports between these rooms
- wander_interval: 12 # every 12 ticks
- - id: iron_gate # hidden object (see below)
-```
-
-### On-enter scripts — messages when a player arrives
-
-```yaml
-on_enter:
- - message: "The guard barks: \"State your business!\""
- condition:
- player_flag: talked_to_guard
- not: true # only first visit
-
- - message: "The guard nods. \"Back again?\""
- condition:
- player_flag: talked_to_guard
- value: true # subsequent visits
-```
-
----
-