From 3a58125d14bb307f861b38c6c5b0a63babde7e08 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 19 Jun 2026 13:28:06 -0400 Subject: feat: all skills kind of implemented, random prototype content added --- AGENTS.md | 20 +- TODO.md | 84 +- cmd/mud/main.go | 1 + data/behaviors/estate_broker_talk.yaml | 49 + data/behaviors/netrunner_talk.yaml | 63 + data/behaviors/sawmill_operator_talk.yaml | 37 + data/courses/reactor.yaml | 69 ++ data/courses/rooftop.yaml | 60 + data/courses/vent_shaft.yaml | 51 + data/help/agility.yaml | 35 + data/help/bank.yaml | 17 + data/help/construct.yaml | 22 + data/help/drink.yaml | 28 + data/help/hacking.yaml | 23 + data/help/jack.yaml | 25 + data/items/amp_potion.yaml | 7 +- data/items/bio_serum.yaml | 5 +- data/items/full_restore.yaml | 7 +- data/items/mahogany_planks.yaml | 6 + data/items/mahogany_table.yaml | 6 + data/items/nano_restore.yaml | 5 +- data/items/oak_planks.yaml | 6 + data/items/oak_shelf.yaml | 6 + data/items/oak_table.yaml | 6 + data/items/planks.yaml | 6 + data/items/restoration_compound.yaml | 7 +- data/items/saw.yaml | 7 + data/items/science_serum.yaml | 7 +- data/items/shield_potion.yaml | 7 +- data/items/stim_cell.yaml | 5 +- data/items/stim_potion.yaml | 5 +- data/items/super_amp.yaml | 7 +- data/items/super_bio_serum.yaml | 7 +- data/items/super_shield.yaml | 7 +- data/items/super_stim.yaml | 7 +- data/items/super_stim_cell.yaml | 7 +- data/items/targeting_serum.yaml | 7 +- data/items/teak_planks.yaml | 6 + data/items/teak_table.yaml | 6 + data/items/tech_serum.yaml | 7 +- data/items/wooden_chair.yaml | 6 + data/items/wooden_shelf.yaml | 6 + data/items/wooden_table.yaml | 6 + data/mobs/ancient_construct.yaml | 47 + data/mobs/bandit.yaml | 38 + data/mobs/corrupted_scientist.yaml | 43 + data/mobs/elite_synth.yaml | 43 + data/mobs/enforcer_bot.yaml | 42 + data/mobs/estate_broker.yaml | 7 + data/mobs/feral_cyborg.yaml | 38 + data/mobs/ghoul.yaml | 38 + data/mobs/heavy_mech.yaml | 42 + data/mobs/molten_elemental.yaml | 42 + data/mobs/mutant_rat.yaml | 39 + data/mobs/plasma_wraith.yaml | 43 + data/mobs/psionic_entity.yaml | 46 + data/mobs/sawmill_operator.yaml | 7 + data/mobs/scrap_bot.yaml | 39 + data/mobs/security_turret.yaml | 41 + data/mobs/titan_sentinel.yaml | 41 + data/mobs/veteran_merc.yaml | 42 + data/mobs/void_reaper.yaml | 52 + data/mobs/war_drone.yaml | 42 + data/objects/bank_booth.yaml | 5 + data/objects/estate_directory.yaml | 5 + data/objects/netrunner.yaml | 6 + data/objects/terminal_advanced.yaml | 5 + data/objects/terminal_basic.yaml | 5 + data/objects/terminal_secure.yaml | 5 + data/objects/workbench.yaml | 5 + data/recipes/construct_mahogany_planks.yaml | 12 + data/recipes/construct_mahogany_table.yaml | 11 + data/recipes/construct_oak_planks.yaml | 12 + data/recipes/construct_oak_shelf.yaml | 11 + data/recipes/construct_oak_table.yaml | 11 + data/recipes/construct_planks.yaml | 12 + data/recipes/construct_teak_planks.yaml | 12 + data/recipes/construct_teak_table.yaml | 11 + data/recipes/construct_wooden_chair.yaml | 11 + data/recipes/construct_wooden_shelf.yaml | 11 + data/recipes/construct_wooden_table.yaml | 11 + data/rooms/1.yaml | 1 + data/rooms/10.yaml | 1 + data/rooms/12.yaml | 1 + data/rooms/16.yaml | 8 +- data/rooms/17.yaml | 6 +- data/rooms/170.yaml | 7 + data/rooms/171.yaml | 7 + data/rooms/200.yaml | 8 + data/rooms/201.yaml | 9 + data/rooms/202.yaml | 9 + data/rooms/203.yaml | 9 + data/rooms/204.yaml | 9 + data/rooms/205.yaml | 9 + data/rooms/210.yaml | 9 + data/rooms/211.yaml | 9 + data/rooms/212.yaml | 9 + data/rooms/213.yaml | 9 + data/rooms/214.yaml | 9 + data/rooms/215.yaml | 9 + data/rooms/220.yaml | 9 + data/rooms/221.yaml | 9 + data/rooms/222.yaml | 9 + data/rooms/223.yaml | 9 + data/rooms/224.yaml | 9 + data/rooms/225.yaml | 9 + data/rooms/226.yaml | 9 + data/rooms/54.yaml | 1 + data/rooms/55.yaml | 12 + data/rooms/56.yaml | 9 + data/rooms/57.yaml | 8 + data/rooms/58.yaml | 8 + data/rooms/59.yaml | 9 + data/rooms/60.yaml | 8 + data/rooms/61.yaml | 7 + data/rooms/62.yaml | 8 + data/rooms/63.yaml | 8 + data/rooms/64.yaml | 8 + data/rooms/65.yaml | 8 + data/rooms/66.yaml | 8 + data/rooms/67.yaml | 8 + data/rooms/68.yaml | 7 + data/rooms/7.yaml | 1 + data/rooms/8.yaml | 7 +- data/rooms/player_housing/200.yaml | 8 + data/rooms/player_housing/201.yaml | 8 + internal/action/behavior.go | 1 + internal/game/action.go | 7 + internal/game/action_agility.go | 180 +++ internal/game/action_burn.go | 16 + internal/game/action_fletch.go | 8 + internal/game/action_gather.go | 8 + internal/game/action_production.go | 23 +- internal/game/action_search.go | 8 + internal/game/action_state.go | 8 +- internal/game/action_steal.go | 8 + internal/game/action_talk.go | 76 ++ internal/game/action_use.go | 8 + internal/game/buff.go | 55 + internal/game/cmd_agility.go | 37 + internal/game/cmd_attack.go | 16 +- internal/game/cmd_bank.go | 345 ++++++ internal/game/cmd_clean.go | 9 + internal/game/cmd_construct.go | 157 +++ internal/game/cmd_drink.go | 150 +++ internal/game/cmd_eat.go | 16 + internal/game/cmd_estate_directory.go | 223 ++++ internal/game/cmd_jack.go | 83 ++ internal/game/cmd_look.go | 6 + internal/game/cmd_quit.go | 10 + internal/game/cmd_score.go | 13 + internal/game/cmd_trigger.go | 126 +- internal/game/cmd_use.go | 5 + internal/game/course.go | 141 +++ internal/game/game.go | 43 +- internal/game/hacking.go | 157 +++ internal/game/hacking_liars_dice.go | 395 +++++++ internal/game/hacking_mastermind.go | 177 +++ internal/game/hacking_wumpus.go | 286 +++++ internal/game/science.go | 3 + internal/net/server.go | 2 + internal/object/item.go | 3 + internal/player/player.go | 57 + internal/world/world.go | 6 +- skill_plans/construction.md | 1670 +-------------------------- worldbuilding_guide/README.md | 2 + worldbuilding_guide/bank.md | 68 ++ worldbuilding_guide/construction.md | 208 ++++ worldbuilding_guide/hacking.md | 71 ++ worldbuilding_guide/index.md | 4 + 170 files changed, 5192 insertions(+), 1739 deletions(-) create mode 100644 data/behaviors/estate_broker_talk.yaml create mode 100644 data/behaviors/netrunner_talk.yaml create mode 100644 data/behaviors/sawmill_operator_talk.yaml create mode 100644 data/courses/reactor.yaml create mode 100644 data/courses/rooftop.yaml create mode 100644 data/courses/vent_shaft.yaml create mode 100644 data/help/agility.yaml create mode 100644 data/help/bank.yaml create mode 100644 data/help/construct.yaml create mode 100644 data/help/drink.yaml create mode 100644 data/help/hacking.yaml create mode 100644 data/help/jack.yaml create mode 100644 data/items/mahogany_planks.yaml create mode 100644 data/items/mahogany_table.yaml create mode 100644 data/items/oak_planks.yaml create mode 100644 data/items/oak_shelf.yaml create mode 100644 data/items/oak_table.yaml create mode 100644 data/items/planks.yaml create mode 100644 data/items/saw.yaml create mode 100644 data/items/teak_planks.yaml create mode 100644 data/items/teak_table.yaml create mode 100644 data/items/wooden_chair.yaml create mode 100644 data/items/wooden_shelf.yaml create mode 100644 data/items/wooden_table.yaml create mode 100644 data/mobs/ancient_construct.yaml create mode 100644 data/mobs/bandit.yaml create mode 100644 data/mobs/corrupted_scientist.yaml create mode 100644 data/mobs/elite_synth.yaml create mode 100644 data/mobs/enforcer_bot.yaml create mode 100644 data/mobs/estate_broker.yaml create mode 100644 data/mobs/feral_cyborg.yaml create mode 100644 data/mobs/ghoul.yaml create mode 100644 data/mobs/heavy_mech.yaml create mode 100644 data/mobs/molten_elemental.yaml create mode 100644 data/mobs/mutant_rat.yaml create mode 100644 data/mobs/plasma_wraith.yaml create mode 100644 data/mobs/psionic_entity.yaml create mode 100644 data/mobs/sawmill_operator.yaml create mode 100644 data/mobs/scrap_bot.yaml create mode 100644 data/mobs/security_turret.yaml create mode 100644 data/mobs/titan_sentinel.yaml create mode 100644 data/mobs/veteran_merc.yaml create mode 100644 data/mobs/void_reaper.yaml create mode 100644 data/mobs/war_drone.yaml create mode 100644 data/objects/bank_booth.yaml create mode 100644 data/objects/estate_directory.yaml create mode 100644 data/objects/netrunner.yaml create mode 100644 data/objects/terminal_advanced.yaml create mode 100644 data/objects/terminal_basic.yaml create mode 100644 data/objects/terminal_secure.yaml create mode 100644 data/objects/workbench.yaml create mode 100644 data/recipes/construct_mahogany_planks.yaml create mode 100644 data/recipes/construct_mahogany_table.yaml create mode 100644 data/recipes/construct_oak_planks.yaml create mode 100644 data/recipes/construct_oak_shelf.yaml create mode 100644 data/recipes/construct_oak_table.yaml create mode 100644 data/recipes/construct_planks.yaml create mode 100644 data/recipes/construct_teak_planks.yaml create mode 100644 data/recipes/construct_teak_table.yaml create mode 100644 data/recipes/construct_wooden_chair.yaml create mode 100644 data/recipes/construct_wooden_shelf.yaml create mode 100644 data/recipes/construct_wooden_table.yaml create mode 100644 data/rooms/170.yaml create mode 100644 data/rooms/171.yaml create mode 100644 data/rooms/200.yaml create mode 100644 data/rooms/201.yaml create mode 100644 data/rooms/202.yaml create mode 100644 data/rooms/203.yaml create mode 100644 data/rooms/204.yaml create mode 100644 data/rooms/205.yaml create mode 100644 data/rooms/210.yaml create mode 100644 data/rooms/211.yaml create mode 100644 data/rooms/212.yaml create mode 100644 data/rooms/213.yaml create mode 100644 data/rooms/214.yaml create mode 100644 data/rooms/215.yaml create mode 100644 data/rooms/220.yaml create mode 100644 data/rooms/221.yaml create mode 100644 data/rooms/222.yaml create mode 100644 data/rooms/223.yaml create mode 100644 data/rooms/224.yaml create mode 100644 data/rooms/225.yaml create mode 100644 data/rooms/226.yaml create mode 100644 data/rooms/55.yaml create mode 100644 data/rooms/56.yaml create mode 100644 data/rooms/57.yaml create mode 100644 data/rooms/58.yaml create mode 100644 data/rooms/59.yaml create mode 100644 data/rooms/60.yaml create mode 100644 data/rooms/61.yaml create mode 100644 data/rooms/62.yaml create mode 100644 data/rooms/63.yaml create mode 100644 data/rooms/64.yaml create mode 100644 data/rooms/65.yaml create mode 100644 data/rooms/66.yaml create mode 100644 data/rooms/67.yaml create mode 100644 data/rooms/68.yaml create mode 100644 data/rooms/player_housing/200.yaml create mode 100644 data/rooms/player_housing/201.yaml create mode 100644 internal/game/action_agility.go create mode 100644 internal/game/buff.go create mode 100644 internal/game/cmd_agility.go create mode 100644 internal/game/cmd_bank.go create mode 100644 internal/game/cmd_construct.go create mode 100644 internal/game/cmd_drink.go create mode 100644 internal/game/cmd_estate_directory.go create mode 100644 internal/game/cmd_jack.go create mode 100644 internal/game/course.go create mode 100644 internal/game/hacking.go create mode 100644 internal/game/hacking_liars_dice.go create mode 100644 internal/game/hacking_mastermind.go create mode 100644 internal/game/hacking_wumpus.go create mode 100644 worldbuilding_guide/bank.md create mode 100644 worldbuilding_guide/construction.md create mode 100644 worldbuilding_guide/hacking.md diff --git a/AGENTS.md b/AGENTS.md index a047bdc..8903105 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,17 @@ internal/ | `action_clean.go` | advanceClean — background clean action lifecycle | | `equip_stats.go` | playerEquipBonuses — sums all equipped items into one ItemStats | | `aggro.go` | checkAggro — aggressive mob attack on room enter | +| `cmd_bank.go` | handleBankInput — deposit/withdraw/browse in bank interface | +| `cmd_construct.go` | doConstruct — construction production command at workbench | +| `cmd_estate_directory.go` | startEstateDirectory, lookEstateDirectory — player house directory, homeowner scanning, teleport | +| `course.go` | CourseStore, CourseConfig, ObstacleDef, ObstacleInfo — agility course YAML loader, room-to-obstacle lookup, obstacleVerbs | +| `cmd_agility.go` | doObstacle — agility obstacle command handler | +| `action_agility.go` | startObstacle, advanceObstacle, obstacleFail, calcFailChance — 3-phase obstacle lifecycle | +| `hacking.go` | HackingMinigame interface, HackingSession, terminalDefs, handleHackingInput, endHacking, hackingXP | +| `hacking_wumpus.go` | WumpusGame — Hunt the Rogue AI (20-node dodecahedron, probes, hazards) | +| `hacking_mastermind.go` | MastermindGame — Code Breaker (4-digit cipher, 10 guesses, feedback) | +| `hacking_liars_dice.go` | LiarsDiceGame — Signal Bluff (hidden dice bidding vs AI, wilds, exact calls) | +| `cmd_jack.go` | doJack, findTerminalInRoom — jack into terminals command handler | ## Key Conventions @@ -199,6 +210,10 @@ Item and object YAML `color` fields also support gradient syntax: `color: "g:196 | `stoke [item]` | Add logs to existing fire for XP | | `walk ` | BFS pathfinding to a room by number | | `walk ` | Direction sequence like `3n2e1u` (capped by agility level) | +| `construct` / `make [item]` | Construction: make planks or furniture at a workbench (Active, production system) | +| `bank` / `use bank` | Open bank interface at a bank booth (deposit, withdraw, browse, leave) | +| `scramble` / `jump` / `swing` / `balance` / `climb` / `crawl` / `vault` / `leap` / `slide` | Agility obstacle verbs (Active, per-room validation) | +| `jack` / `jackin [terminal]` | Jack into a terminal for hacking minigames (Active) | | `quit` | 10-tick rest sequence, then return to menu (blocked during combat) | ## Command Dispatch @@ -259,6 +274,7 @@ The `verbSkill` map maps verb → skill name for default target resolution: | `smithing` | smith | Smithing bars into items at anvil | | `cleaning` | clean | Cleaning grimy herbs (background action) | | `mixing` | mix | Mixing potions (Pharmacy) | +| `constructing` | construct/make | Construction via production system (workbench) | ### Behavior Types (YAML-driven) @@ -527,6 +543,8 @@ From `internal/net/server.go`: | `StateHowMany` | "How many?" production count prompt | | `StateSmithProduct` | Smith product selection with numbered table (#, name, or partial name input) | | `StateShop` | Buy/sell/browse in shop (buy, sell, browse, leave commands) | +| `StateBank` | Bank interface (deposit, withdraw, browse, leave commands) | +| `StateHacking` | Jacked into a terminal running a minigame | | `StateColorChoice` | New account color preference | ## Adding a New Skill @@ -568,7 +586,7 @@ The unified production cycle handles: "How many?" prompt, start message, timed l | Production | Cooking, Smithing, Crafting, Fletching, Pharmacy, Construction, Firemaking | | Utility | Agility | -Implemented: Mining, Fishing, Woodcutting, Firemaking, Cooking, Smithing (smelting + smithing), Fletching, Pharmacy (herb cleaning + potion mixing), Technology (25 techs, battery drain, 1-tick flicking, combat integration). The rest are stubs (skill defined, XP tracked, but no behaviors/objects/items yet). +Implemented: Mining, Fishing, Woodcutting, Firemaking, Cooking, Smithing (smelting + smithing), Fletching, Pharmacy (herb cleaning + potion mixing), Technology (25 techs, battery drain, 1-tick flicking, combat integration), Construction (planks, furniture, player houses), Agility (3 obstacle courses, 9 verbs, lap tracking), Hacking (3 minigames, terminals, StateHacking). The rest are stubs (skill defined, XP tracked, but no behaviors/objects/items yet). ## Places to Be Careful diff --git a/TODO.md b/TODO.md index 98f19fc..ed3da83 100644 --- a/TODO.md +++ b/TODO.md @@ -18,83 +18,15 @@ Detailed implementation plans for each skill/system are in `skill_plans/`. - [x] Step 5: Scavenging — mine_scrap behavior verified. Created 10 junk items (solar, hydro, cosmic, eco, chaos, bio, nature, law, death, blood — all stackable) with matching identifier tools and altar objects. `id`/`identify` command (Active): scans room for altar, checks for matching identifier in inventory, converts all scrap to junk on next tick with level-based multipliers, awards Scavenging XP. 10 altar rooms (150-159) branching from Scavenging Post (room 9). Help files created. - [x] Step 6: Science — 40 mods (20 combat, 2 processing, 3 utility, 7 transport, 4 enchant, 4 chip), ModDef/AllMods in science.go. `trigger`/`cast` command (Active) routes to combat/transport/processing/utility/enchant handlers. `autocast`/`auto` command (Instant) sets science combat autocast. `mods`/`modlist` command (Instant) shows available mods with effective junk costs. Integrated autocast into `startCombat()` in cmd_attack.go with 5-tick science speed and melee fallback on junk depletion. `scienceAttack()` with Science level + science_attack roll, elemental weakness +30% accuracy, mod MaxHit damage. Junk cost system: deck-equipped removes scrap_metal, elemental deck provides unlimited junk type. Added `ProvidesJunk` to ItemDef, `AutocastMod` to Player, `FindByInput` to RecipeStore, `science_mod` color target. 9 deck items, 24 jewelry items, 8 bolt items, nutrient_bar item. Help files created. - [x] Step 7: Shop System — Added `shop` talk node action that opens a buy/sell interface. `ShopConfig` with items + buy/sell prices defined in behavior YAML. `StateShop` session state with `buy`, `sell`, `browse`/`list`, `leave` commands. Items bought with credits, sold back at reduced prices. Created General Store clerk (Town Square) and Weapons Smith (Forge) NPCs. Created help files and worldbuilding guide docs. - ---- - - [x] Step 8: Thieving — `steal`/`thieve` command (Active) with continuous steal action. `sneak` command (Instant toggle) with guard-watching notifications. Success formula: 0.5 base + 0.03 per level, halved when guard watches. Mob stealing with aggro on failure. Object stealing with guard mechanic (SneakTick, watch/away cycles). Guard confrontation dialog (bribe 500cr, jail, fight). Created Market Stall object, Farmer + Bioengineer mobs, credit_stick item (searchable), 11 seed items, 5 new drop tables, 3 new rooms (160-162), 3 help files. Updated Man mob for stealability. - ---- - -## Step 9: Assassin (Slayer) - -See `skill_plans/assassin.md`. Task system, reputation, slayer-only mobs. Depends on combat being solid and having enough mobs to assign as tasks. - - [x] **Assassin System** — Added `task` command (Instant) to check current assignment. Created Client NPC with talk behavior: assigns random mob kill tasks, skip/extend via reputation, reputation shop. Task state stored in player flags (task_mob, task_count, task_remaining, tasks_completed, streak, reputation). In `endCombat()`, on-task kills award Assassin XP (mob.MaxHP * 2) + reputation. Streak bonuses at 10th/50th/100th/250th/1000th task. Created 4 slayer-only mobs (slug/drone/crawler/phantom) with assassin_level requirements, finishing blow items (slug+salt, crawler+acid_vial), and damage-without equipment (drone needs insulated_gloves, phantom needs spectral_visor). Added assassin equipment items (salt, acid_vial, insulated_gloves, spectral_visor) sold by Client. Added 5 new rooms (50-54: Assassin Den + Sewer Tunnels chain). Created help files (task, assassin). - ---- - -## Step 10: Pharmacy - -See `skill_plans/pharmacy.md`. Herb cleaning and potion mixing. Can work initially with herbs as mob drops; Farming adds the growing mechanic later. - - [x] **Pharmacy Skill** — Add `clean` command (Free, background action like fletching): auto-cleans grimy herbs in inventory one at a time, each clean awards Pharmacy XP based on herb level. Add `mix` command (Active, production system): no station required, shows recipe table, mixes potions. Create ~14 grimy herb items and ~14 clean herb items (guam through torstol). Create ~16 potion items (stim potion, fortify potion, restore serum, etc. — sci-fi names). Create vial_of_water and ~14 unfinished potion items. Create ~11 reagent items (eye_of_newt, limpwurt_root, etc.). Create ~30 recipes (14 clean-herb, 16 mix-potion). Add grimy herbs to mob drop tables. Potion effects are item-only for now (no buff system — just items in inventory for future use or sale). - ---- - -## Step 11: Farming - -See `skill_plans/farming.md`. Grows herbs for Pharmacy and allotment crops. Per-player plot state via player flags. - - [x] **Farming Skill** — Add `plant`, `harvest`, `rake`, `water`, `cure`, `inspect` commands. Farming state tracked per-player via player flags (each plot stores seed, growth stage, watered, diseased status). Add `FarmTick()` that runs every ~500 ticks for online players: advances growth stages, rolls disease chance (watering prevents), diseased plants die if not cured. Create seed items (14 herb seeds, 7 allotment seeds). Create farming tools (rake, spade, watering_can, plant_cure). Create plot objects (herb_patch, allotment_patch, flower_patch). Create tool_shed object with talk behavior for store/retrieve interaction. Harvesting gives 3-15 herbs or 3-10 crops based on farming level. Create farming area rooms (150-152) south of room 15. - ---- - -## Step 12: Bank System - -Quality of life — by this point players have many items. Simple deposit/withdraw at bank booth objects. - -- [ ] **Bank System** — Add bank storage to player (saved in character YAML, no slot limit or large slot limit). Create bank_booth object. `use bank` or `bank` command opens bank interface in a new session state. Commands: `deposit `, `withdraw `, `deposit all`, `list`/`browse`, `leave`. Bank is shared across all characters on the same account. Place bank booths in town and key areas. - ---- - -## Step 13: Construction - -See `skill_plans/construction.md`. Player housing and workshop crafting. Very independent — can be done in any order. - -- [ ] **Construction Skill** — Create real estate broker NPC (talk behavior, sells house for 10 credits via `cost` + `set_player_flags`). Create Local Neighborhood room north of room 16 with Directory object. Directory: `look` shows all homeowners (scan character files), `use` teleports to a house. Player houses are virtual rooms generated in code (not YAML files). Each house has entrance + workshop. Workshop has workbench station object. Add `make`/`construct` command (Active, production system). Create plank items (regular through mahogany). Create construction recipes: logs → planks (very slow, ~20 ticks), planks → furniture items (shelf, table, chair, etc. — sellable items with credit value). All recipes use `type: "construction"`, `station: ["workbench"]`. - ---- - -## Step 14: Agility - -See `skill_plans/agility.md`. Obstacle courses with custom commands. Independent. - -- [ ] **Agility Skill** — Create course definition system: `data/courses/.yaml` defines a sequence of obstacle rooms with custom verbs (scramble, jump, swing, balance, climb, etc.), messages per phase, ticks per phase, and XP per obstacle. Add a single `doObstacle()` handler that all obstacle verbs route to. Course loader builds a `roomID → {courseID, obstacleIndex, verb}` map. Each obstacle is a 3-phase multi-tick action (approach → attempt → complete/fail). Fail chance based on agility vs required level; failure deals minor damage and teleports to course start. Track laps per course in player flags. Create 3 courses: Ventilation Shaft (Lv1, 5 obstacles), Rooftop (Lv20, 6 obstacles), Reactor (Lv50, 7 obstacles). Create ~18 obstacle rooms with descriptions. Big XP drop on course completion. - ---- - -## Step 15: Hacking - -See `skill_plans/hacking.md`. Jack into terminals, play minigames for Hacking XP. Most independent skill. - -- [ ] **Hacking Skill** — Add `StateHacking` session state. Add `jack`/`jackin` command (Active). Create `HackingMinigame` interface with `Init(level) string` and `HandleInput(input) (output, done, won)`. Terminal objects are registered in a Go map (not YAML behaviors). Implement 3 minigames: Hunt the Wumpus (Lv1, 20-node dodecahedron, probes, hazards), Mastermind/Code Breaker (Lv20, 4-digit cipher, 10 guesses, feedback), Liar's Dice/Signal Bluff (Lv40, hidden dice bidding/bluffing vs AI, wilds, exact calls). Create terminal objects and Netrunner NPC with tips dialog. Award XP on completion (more for winning, bonus for efficiency). Place terminals in rooms. - ---- - -## Step 16: World Building - -Content additions that flesh out the game world after all systems are in place. - -- [ ] More rooms — expand the asteroid world with distinct areas: residential district, industrial sector, reactor level, docking bay, science wing, maintenance tunnels -- [ ] More mobs — fill out the combat level curve with 15-20 more mobs at various levels -- [ ] Quest lines — use existing talk node + flag system to create multi-step quest chains connecting the different areas -- [ ] Ensure player actions broadcast to other players in the same room (e.g., "John starts mining a copper rock!", "Jane jacks into a terminal.") - ---- - -## Step 17: Polish - -- [ ] Admin commands — kick, teleport, spawn items, god mode, broadcast -- [ ] PvP combat and multi-combat zones -- [ ] Persist ground items and mob state across restarts (serialize to YAML on shutdown, load on startup) -- [ ] Potion buff system — consuming potions gives temporary stat boosts (builds on Pharmacy items created in Step 10) +- [x] **Bank System** — Add bank storage to player (saved in character YAML, no slot limit or large slot limit). Create bank_booth object. `use bank` or `bank` command opens bank interface in a new session state. Commands: `deposit `, `withdraw `, `deposit all`, `list`/`browse`, `leave`. Bank is shared across all characters on the same account. Place bank booths in town and key areas. +- [x] **Construction Skill** — Create real estate broker NPC (talk behavior, sells house for 10 credits via `cost` + `set_player_flags`). Create Local Neighborhood room north of room 16 with Directory object. Directory: `look` shows all homeowners who have purchased a player house in this specific area (scan character files), `use` teleports to a house. Player houses are real YAML rooms in `data/rooms/player_housing/`. Each house has entrance + workshop. Workshop has workbench station object. Add `make`/`construct` command (Active, production system). Create plank items (regular through mahogany). Create construction recipes: logs → planks (very slow, ~20 ticks), planks → furniture items (shelf, table, chair, etc. — sellable items with credit value). All recipes use `type: "construction"`, `station: ["workbench"]`. Add sawmill operator NPC in lumberyard room (east of room 16): turns logs into planks for credits via talk node `sawmill` action. Add Plank Make science mod (Lv86 Utility): converts logs to planks at 70% sawmill cost. +- [x] **Agility Skill** — Create course definition system: `data/courses/.yaml` defines a sequence of obstacle rooms with custom verbs (scramble, jump, swing, balance, climb, etc.), messages per phase, ticks per phase, and XP per obstacle. Add a single `doObstacle()` handler that all obstacle verbs route to. Course loader builds a `roomID → {courseID, obstacleIndex, verb}` map. Each obstacle is a 3-phase multi-tick action (approach → attempt → complete/fail). Fail chance based on agility vs required level; failure deals minor damage and teleports to course start. Track laps per course in player flags. Create 3 courses: Ventilation Shaft (Lv1, 5 obstacles), Rooftop (Lv20, 6 obstacles), Reactor (Lv50, 7 obstacles). Create ~18 obstacle rooms with descriptions. Big XP drop on course completion. +- [x] **Hacking Skill** — Add `StateHacking` session state. Add `jack`/`jackin` command (Active). Create `HackingMinigame` interface with `Init(level) string` and `HandleInput(input) (output, done, won)`. Terminal objects are registered in a Go map (not YAML behaviors). Implement 3 minigames: Hunt the Wumpus (Lv1, 20-node dodecahedron, probes, hazards), Mastermind/Code Breaker (Lv20, 4-digit cipher, 10 guesses, feedback), Liar's Dice/Signal Bluff (Lv40, hidden dice bidding/bluffing vs AI, wilds, exact calls). Create terminal objects and Netrunner NPC with tips dialog. Award XP on completion (more for winning, bonus for efficiency). Place terminals in rooms. + +- [x] More mobs — 18 new mobs filling Lv15-93 gaps (bandit, mutant_rat, scrap_bot, security_turret, ghoul, corrupted_scientist, war_drone, enforcer_bot, plasma_wraith, feral_cyborg, heavy_mech, elite_synth, veteran_merc, molten_elemental, titan_sentinel, psionic_entity, ancient_construct, void_reaper), 14 new dungeon rooms (55-68), iron_sentinel placed in room 64 +- [x] Player action broadcasts — gathering, combat, burning, stoking, searching, stealing, agility, production, use, resting, fletching, cleaning all broadcast to room. Follows existing toggle/arrive/say pattern. +- [x] Potion buff system — `drink` command (ClassFree), 3-tick cooldown. 16 potion YAMLs updated with `potion_effect`, `potion_bonus`, `potion_duration`. PotionBuff struct on Player. BuffTick decrements durations. buffLevelBonus() applied in combat (attack/strength/defense/ranged/science roll formulas). Buffs displayed in `score`. Supports stat-boost, heal, battery-restore, and all_combat potion types. diff --git a/cmd/mud/main.go b/cmd/mud/main.go index 55e3bbf..497f427 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -56,6 +56,7 @@ func main() { g.VisualTick() g.SneakTick() g.FarmTick() + g.BuffTick() return true }) diff --git a/data/behaviors/estate_broker_talk.yaml b/data/behaviors/estate_broker_talk.yaml new file mode 100644 index 0000000..b14323d --- /dev/null +++ b/data/behaviors/estate_broker_talk.yaml @@ -0,0 +1,49 @@ +id: estate_broker_talk +type: talk +nodes: + start: + message: "Welcome to the Local Neighborhood development! I'm selling premium housing plots for only 10 credits. Interested in buying a house?" + options: + - text: "Yes, I'd like to buy a house." + condition: + not: + player_flag: owns_house_local_neighborhood + action: + cost: 10 + set_player_flags: + owns_house_local_neighborhood: "local_neighborhood" + next: purchased + - text: "I already own a house here." + condition: + player_flag: owns_house_local_neighborhood + next: already_own + - text: "No thanks." + next: goodbye + - text: "What do I get with a house?" + next: info + purchased: + message: "Excellent! The plot is yours. You'll find your new home in the Local Neighborhood just north of here. Use the Estate Directory terminal to enter your house. The home comes with a workshop — perfect for construction projects." + options: + - text: "Thanks!" + next: start + already_own: + message: "Yes, you're already a proud homeowner here! Your house is waiting for you. Use the Estate Directory to enter it, or expand your workshop." + options: + - text: "Good to know." + next: start + info: + message: "Each house comes with an entrance hall and a workshop equipped with a workbench. You can use the workbench with a saw to turn logs into planks, or assemble planks into furniture. Furniture can be sold for a profit or displayed in your home. Future developments will allow adding more rooms to your house." + options: + - text: "Sounds great! I'll buy one." + condition: + not: + player_flag: owns_house_local_neighborhood + action: + cost: 10 + set_player_flags: + owns_house_local_neighborhood: "local_neighborhood" + next: purchased + - text: "Maybe later." + next: goodbye + goodbye: + message: "Come back when you're ready to invest in your future!" diff --git a/data/behaviors/netrunner_talk.yaml b/data/behaviors/netrunner_talk.yaml new file mode 100644 index 0000000..e20f12f --- /dev/null +++ b/data/behaviors/netrunner_talk.yaml @@ -0,0 +1,63 @@ +id: netrunner_talk +type: talk +nodes: + start: + message: "The Netrunner looks up. \"You want in on the network? I can give you some pointers.\"" + options: + - text: "\"What is hacking?\"" + goto: what_is + - text: "\"Where can I find terminals?\"" + goto: terminals + - text: "\"Any tips for the basic terminal?\"" + goto: tips_basic + - text: "\"What about the advanced terminal?\"" + goto: tips_advanced + condition: + player_flag: hacking_asked_basic + value: true + - text: "\"Tell me about the secure terminal.\"" + goto: tips_secure + condition: + player_flag: hacking_asked_advanced + value: true + - text: "\"Goodbye.\"" + end: true + what_is: + message: "\"Hacking's about jacking into terminals and running their gauntlets. Each terminal runs a different challenge -- puzzles, hunts, pattern games. Crack 'em and you get better at it. Simple as that.\"" + options: + - text: "\"Where can I find terminals?\"" + goto: terminals + - text: "\"Goodbye.\"" + end: true + terminals: + message: "\"There's a basic terminal right here in this lab -- good for beginners. The station down east has an advanced one if you've got the chops. And if you're really good, there's a secure terminal deep in the compound. You'll need the skills to match though.\"" + options: + - text: "\"Any tips?\"" + goto: tips_basic + - text: "\"Thanks.\"" + end: true + tips_basic: + message: "\"The basic terminal runs an old rogue AI hunt. Twenty nodes in a network. You've got probes to flush it out. Listen for the warnings -- corrupted data means it's close, voids mean a trap, static means ICE. Shoot into adjacent nodes, don't walk into danger.\"" + action: + set_player_flags: + hacking_asked_basic: true + options: + - text: "\"What about the advanced terminal?\"" + goto: tips_advanced + - text: "\"Thanks.\"" + end: true + tips_advanced: + message: "\"Advanced terminal runs a cipher crack. Four symbols, no repeats. You get feedback after each guess -- locked means right spot, found means right symbol wrong spot. Process of elimination. Ten tries.\"" + action: + set_player_flags: + hacking_asked_advanced: true + options: + - text: "\"Tell me about the secure terminal.\"" + goto: tips_secure + - text: "\"Thanks.\"" + end: true + tips_secure: + message: "\"Secure terminal? Signal Bluff. It's a bluffing game -- you and the system each roll hidden dice and take turns making claims about the total. Ones are wild. Call the bluff or raise the stakes. Read the probabilities, play the odds, and don't let it psych you out.\"" + options: + - text: "\"Thanks.\"" + end: true diff --git a/data/behaviors/sawmill_operator_talk.yaml b/data/behaviors/sawmill_operator_talk.yaml new file mode 100644 index 0000000..338ee61 --- /dev/null +++ b/data/behaviors/sawmill_operator_talk.yaml @@ -0,0 +1,37 @@ +id: sawmill_operator_talk +type: talk +nodes: + start: + message: "Welcome to the lumberyard! I can turn your logs into planks — for a small fee, of course. What can I do for ya?" + options: + - text: "Turn all my logs into planks." + condition: + any_of: + - has_item: logs + - has_item: oak_logs + - has_item: teak_logs + - has_item: mahogany_logs + next: prices + - text: "What are your prices?" + next: prices + - text: "Nothing for now." + next: goodbye + prices: + message: "Here's what I charge per log:\n Regular logs: 5 credits\n Oak logs: 10 credits\n Teak logs: 20 credits\n Mahogany logs: 40 credits\n\nWant me to process your logs?" + options: + - text: "Yes, process all my logs." + condition: + any_of: + - has_item: logs + - has_item: oak_logs + - has_item: teak_logs + - has_item: mahogany_logs + next: process + - text: "No thanks." + next: goodbye + process: + message: "" + action: + sawmill: true + goodbye: + message: "Come back anytime you need more planks!" diff --git a/data/courses/reactor.yaml b/data/courses/reactor.yaml new file mode 100644 index 0000000..fcf2824 --- /dev/null +++ b/data/courses/reactor.yaml @@ -0,0 +1,69 @@ +id: "reactor" +name: "Reactor Course" +required_level: 50 +start_room: 200 +completion_xp: 350 +obstacles: + - room_id: 220 + verb: climb + ticks_per_phase: 3 + xp: 40 + fail_damage: [3, 6] + messages: + - "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 + verb: swing + ticks_per_phase: 3 + xp: 45 + fail_damage: [3, 7] + messages: + - "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 + verb: balance + ticks_per_phase: 3 + xp: 50 + fail_damage: [4, 7] + messages: + - "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 + verb: jump + ticks_per_phase: 2 + xp: 45 + fail_damage: [3, 6] + messages: + - "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 + verb: crawl + ticks_per_phase: 3 + xp: 40 + fail_damage: [3, 5] + messages: + - "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 + verb: vault + ticks_per_phase: 2 + xp: 50 + fail_damage: [4, 7] + messages: + - "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 + verb: leap + ticks_per_phase: 3 + xp: 55 + fail_damage: [4, 8] + messages: + - "The final obstacle: a massive chasm over the reactor coolant pool..." + - "You sprint along the narrow runway, the abyss yawning below..." + - "You launch into the void, arms windmilling, and crash onto the far platform!" diff --git a/data/courses/rooftop.yaml b/data/courses/rooftop.yaml new file mode 100644 index 0000000..4cc6748 --- /dev/null +++ b/data/courses/rooftop.yaml @@ -0,0 +1,60 @@ +id: "rooftop" +name: "Rooftop Course" +required_level: 20 +start_room: 200 +completion_xp: 120 +obstacles: + - room_id: 210 + verb: climb + ticks_per_phase: 2 + xp: 15 + fail_damage: [2, 4] + messages: + - "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 + verb: jump + ticks_per_phase: 2 + xp: 22 + fail_damage: [2, 4] + messages: + - "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 + verb: swing + ticks_per_phase: 3 + xp: 20 + fail_damage: [2, 5] + messages: + - "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 + verb: balance + ticks_per_phase: 3 + xp: 18 + fail_damage: [2, 4] + messages: + - "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 + verb: vault + ticks_per_phase: 2 + xp: 15 + fail_damage: [2, 3] + messages: + - "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 + verb: leap + ticks_per_phase: 2 + xp: 20 + fail_damage: [3, 5] + messages: + - "You stare at the final gap -- the widest yet..." + - "You take a deep breath, charge forward, and throw yourself into the air..." + - "You barely catch the far ledge, pull yourself up, and stand triumphant!" diff --git a/data/courses/vent_shaft.yaml b/data/courses/vent_shaft.yaml new file mode 100644 index 0000000..a59a16c --- /dev/null +++ b/data/courses/vent_shaft.yaml @@ -0,0 +1,51 @@ +id: "vent_shaft" +name: "Ventilation Shaft Course" +required_level: 1 +start_room: 200 +completion_xp: 40 +obstacles: + - room_id: 201 + verb: scramble + ticks_per_phase: 2 + xp: 8 + fail_damage: [1, 2] + messages: + - "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 + verb: balance + ticks_per_phase: 2 + xp: 8 + fail_damage: [1, 2] + messages: + - "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 + verb: jump + ticks_per_phase: 2 + xp: 7 + fail_damage: [1, 2] + messages: + - "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 + verb: crawl + ticks_per_phase: 3 + xp: 7 + fail_damage: [1, 1] + messages: + - "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 + verb: slide + ticks_per_phase: 2 + xp: 10 + fail_damage: [1, 2] + messages: + - "You sit at the top of the emergency chute..." + - "You push off and accelerate down the smooth metal surface..." + - "You shoot out the bottom of the chute and land on your feet!" diff --git a/data/help/agility.yaml b/data/help/agility.yaml new file mode 100644 index 0000000..2dc298e --- /dev/null +++ b/data/help/agility.yaml @@ -0,0 +1,35 @@ +id: agility +title: "Agility" +body: | + Agility is trained by completing obstacle courses. Each course is a sequence + of rooms with obstacles that you traverse using special commands. + + COMMANDS + scramble, jump, swing, balance, climb, crawl, vault, leap, slide + Each obstacle room tells you which command to use. Type it to begin + the obstacle. After a few ticks of sequenced messages, you'll either + succeed and move to the next obstacle, or slip and fall. + + COURSES + Ventilation Shaft Level 1 - 5 obstacles, 80 XP/lap + Rooftop Level 20 - 6 obstacles, 230 XP/lap + Reactor Level 50 - 7 obstacles, 675 XP/lap + + FAILING + Each obstacle has a chance to fail. If you fail, you take minor damage + and are teleported back to the Agility Training Grounds. Fail chance + decreases as your agility level increases above the course requirement. + + LAP TRACKING + The game tracks how many laps you've completed on each course. Your + lap count is displayed when you finish a course. + + TIPS + - The "down" exit in any obstacle room returns you to the Training + Grounds without penalty (but no XP either). + - Agility level also determines how many steps you can queue with the + "walk" command. + - Graceful equipment and Cape of Agility reduce movement speed. +related: + - skills + - walk diff --git a/data/help/bank.yaml b/data/help/bank.yaml new file mode 100644 index 0000000..7b505ae --- /dev/null +++ b/data/help/bank.yaml @@ -0,0 +1,17 @@ +name: "bank" +category: "Interaction" +description: | + Store items in your personal bank vault to free up inventory space. + + Find a bank booth and type 'bank' or 'use bank' to access + your bank. While banking, these commands are available: + + browse List your bank contents + list Same as browse + deposit Move an item from your inventory to the bank + deposit all Deposit everything from your inventory + withdraw Move an item from the bank to your inventory + leave Exit the bank interface + + Bank booths can be found in Town Square and the Forge. + Items in storage are safe and accessible from any bank booth. diff --git a/data/help/construct.yaml b/data/help/construct.yaml new file mode 100644 index 0000000..7bd18b2 --- /dev/null +++ b/data/help/construct.yaml @@ -0,0 +1,22 @@ +name: "construct" +category: "Production" +description: | + Construct furniture and process logs into planks at a workbench. + + Use 'construct' or 'make' at a workbench to build items. + + You'll need a saw to turn logs into planks. Planks can then be + assembled into furniture like shelves, tables, and chairs. + + Higher level planks (oak, teak, mahogany) produce more valuable + furniture and require higher construction levels. + + Alternative ways to get planks: + - Visit the sawmill operator in the lumberyard (east of Construction + Site) to convert logs to planks for a credit fee + - Use the Plank Make science mod (Lv86) to convert logs at 70% + of the sawmill cost using junk and credits + + You can also buy a house from the estate broker at the Construction + Site. Your house includes a private workshop with a workbench and + bank booth. diff --git a/data/help/drink.yaml b/data/help/drink.yaml new file mode 100644 index 0000000..98843b3 --- /dev/null +++ b/data/help/drink.yaml @@ -0,0 +1,28 @@ +name: "drink" +category: "Skills" +description: | + Drink potions to gain temporary stat boosts or restore resources. + + Usage: drink + + Potions provide various effects: + - Stim potions: boost Attack + - Amp potions: boost Strength + - Shield potions: boost Defense + - Tech serum: boosts Technology + - Science serum: boosts Science + - Targeting serum: boosts Ranged + - Restoration compound: boosts all combat stats + - Bio serum / Nano restore / Full restore: restore hitpoints + - Stim cells: restore battery power + + Stat boosts are percentage-based and last for a limited number + of ticks. Use "score" to see your active buffs and remaining time. + + Drinking has a 3-tick cooldown between uses. + + Drinking during combat replaces your next attack. Drinking outside + combat cancels any current action (except combat) and takes effect + on the next tick. + + See also: pharmacy, mix, clean, score diff --git a/data/help/hacking.yaml b/data/help/hacking.yaml new file mode 100644 index 0000000..c3aa67a --- /dev/null +++ b/data/help/hacking.yaml @@ -0,0 +1,23 @@ +id: hacking +title: "Hacking" +body: | + Hacking is trained by jacking into terminals and completing + their minigames. Different terminals offer different challenges + at different difficulty levels. + + Find terminals scattered around the world. Use the 'jack' command + to connect. Each terminal runs a unique puzzle -- solve it for XP. + Winning gives full XP; losing or quitting early gives a small + consolation amount. + + Talk to the Netrunner in the Hacking Lab for tips on each + terminal's challenge. + + XP scales slightly with your Hacking level above the terminal's + requirement, but the main progression is unlocking harder terminals + with bigger rewards. + + See also: help jack +related: + - jack + - skills diff --git a/data/help/jack.yaml b/data/help/jack.yaml new file mode 100644 index 0000000..33f404a --- /dev/null +++ b/data/help/jack.yaml @@ -0,0 +1,25 @@ +id: jack +title: "Jack" +body: | + Jack into a terminal to start a hacking minigame. + + Usage: jack [terminal] + + Connects to a hacking terminal in the room and starts its + minigame. Each terminal type offers a different challenge + and requires a minimum Hacking level. + + If there's only one terminal in the room, you can just type 'jack'. + + While jacked in, all input goes to the minigame. Type 'jack out' + or 'quit' to disconnect early. + + Terminal types: + Basic terminal - Level 1 - Hunt the Rogue AI + Advanced terminal - Level 20 - Code Breaker + Secure terminal - Level 40 - Signal Bluff + + See also: help hacking +related: + - hacking + - skills diff --git a/data/items/amp_potion.yaml b/data/items/amp_potion.yaml index 0d88a53..8167932 100644 --- a/data/items/amp_potion.yaml +++ b/data/items/amp_potion.yaml @@ -1,6 +1,9 @@ id: amp_potion name: "amp potion" -color: "255" -description: "A milky white potion that temporarily amplifies physical strength. [Effect not yet implemented]" +color: "220" +description: "A glowing gold potion that amplifies physical strength." value: 55 stackable: false +potion_effect: "strength" +potion_bonus: 10 +potion_duration: 200 diff --git a/data/items/bio_serum.yaml b/data/items/bio_serum.yaml index 4795749..b397e46 100644 --- a/data/items/bio_serum.yaml +++ b/data/items/bio_serum.yaml @@ -1,6 +1,9 @@ id: bio_serum name: "bio serum" color: "48" -description: "A clear green serum that neutralizes biological toxins. [Effect not yet implemented]" +description: "A green serum that neutralizes biological toxins and restores health." value: 45 stackable: false +potion_effect: "heal" +potion_bonus: 60 +potion_duration: 0 diff --git a/data/items/full_restore.yaml b/data/items/full_restore.yaml index d34d94d..96f6036 100644 --- a/data/items/full_restore.yaml +++ b/data/items/full_restore.yaml @@ -1,6 +1,9 @@ id: full_restore name: "full restore" -color: "207" -description: "A shimmering pink potion. Restores all diminished stats simultaneously. [Effect not yet implemented]" +color: "208" +description: "A powerful orange potion that completely restores health." value: 200 stackable: false +potion_effect: "heal" +potion_bonus: 500 +potion_duration: 0 diff --git a/data/items/mahogany_planks.yaml b/data/items/mahogany_planks.yaml new file mode 100644 index 0000000..d551321 --- /dev/null +++ b/data/items/mahogany_planks.yaml @@ -0,0 +1,6 @@ +id: mahogany_planks +name: mahogany planks +color: "96" +description: "Luxurious mahogany planks with a deep reddish hue." +value: 120 +stackable: false diff --git a/data/items/mahogany_table.yaml b/data/items/mahogany_table.yaml new file mode 100644 index 0000000..245b929 --- /dev/null +++ b/data/items/mahogany_table.yaml @@ -0,0 +1,6 @@ +id: mahogany_table +name: mahogany table +color: "96" +description: "A luxurious mahogany table, the centerpiece of any room." +value: 240 +stackable: false diff --git a/data/items/nano_restore.yaml b/data/items/nano_restore.yaml index 2714b9f..e430b49 100644 --- a/data/items/nano_restore.yaml +++ b/data/items/nano_restore.yaml @@ -1,6 +1,9 @@ id: nano_restore name: "nano restore" color: "208" -description: "An orange potion containing nanobots that restore diminished attributes. [Effect not yet implemented]" +description: "An orange potion containing nanobots that restore diminished attributes." value: 70 stackable: false +potion_effect: "heal" +potion_bonus: 100 +potion_duration: 0 diff --git a/data/items/oak_planks.yaml b/data/items/oak_planks.yaml new file mode 100644 index 0000000..29376c6 --- /dev/null +++ b/data/items/oak_planks.yaml @@ -0,0 +1,6 @@ +id: oak_planks +name: oak planks +color: "102" +description: "Sturdy oak planks, ideal for furniture." +value: 40 +stackable: false diff --git a/data/items/oak_shelf.yaml b/data/items/oak_shelf.yaml new file mode 100644 index 0000000..0f08fa5 --- /dev/null +++ b/data/items/oak_shelf.yaml @@ -0,0 +1,6 @@ +id: oak_shelf +name: oak shelf +color: "94" +description: "A solid oak shelf, built to last." +value: 80 +stackable: false diff --git a/data/items/oak_table.yaml b/data/items/oak_table.yaml new file mode 100644 index 0000000..e94b372 --- /dev/null +++ b/data/items/oak_table.yaml @@ -0,0 +1,6 @@ +id: oak_table +name: oak table +color: "94" +description: "A fine oak table with carved legs." +value: 100 +stackable: false diff --git a/data/items/planks.yaml b/data/items/planks.yaml new file mode 100644 index 0000000..67de72e --- /dev/null +++ b/data/items/planks.yaml @@ -0,0 +1,6 @@ +id: planks +name: planks +color: "137" +description: "Some regular wooden planks, ready for construction." +value: 20 +stackable: false diff --git a/data/items/restoration_compound.yaml b/data/items/restoration_compound.yaml index b4179bc..913153f 100644 --- a/data/items/restoration_compound.yaml +++ b/data/items/restoration_compound.yaml @@ -1,6 +1,9 @@ id: restoration_compound name: "restoration compound" -color: "214" -description: "A golden compound that restores hitpoints and boosts defense, but drains other stats. [Effect not yet implemented]" +color: "51" +description: "A teal compound that enhances all combat capabilities." value: 250 stackable: false +potion_effect: "all_combat" +potion_bonus: 15 +potion_duration: 150 diff --git a/data/items/saw.yaml b/data/items/saw.yaml new file mode 100644 index 0000000..4a7ad6a --- /dev/null +++ b/data/items/saw.yaml @@ -0,0 +1,7 @@ +id: saw +name: saw +tool_type: saw +color: "152" +description: "A sharp-toothed saw for cutting planks from logs at a workbench." +value: 30 +stackable: false diff --git a/data/items/science_serum.yaml b/data/items/science_serum.yaml index 08b807e..e78f915 100644 --- a/data/items/science_serum.yaml +++ b/data/items/science_serum.yaml @@ -1,6 +1,9 @@ id: science_serum name: "science serum" -color: "93" -description: "A violet serum that heightens scientific cognition and casting ability. [Effect not yet implemented]" +color: "141" +description: "A violet serum that heightens scientific cognition." value: 200 stackable: false +potion_effect: "science" +potion_bonus: 10 +potion_duration: 200 diff --git a/data/items/shield_potion.yaml b/data/items/shield_potion.yaml index ccc2d0b..4cd91e8 100644 --- a/data/items/shield_potion.yaml +++ b/data/items/shield_potion.yaml @@ -1,6 +1,9 @@ id: shield_potion name: "shield potion" -color: "39" -description: "A luminous blue potion that temporarily reinforces defensive capability. [Effect not yet implemented]" +color: "21" +description: "A deep blue potion that reinforces defensive capability." value: 85 stackable: false +potion_effect: "defense" +potion_bonus: 10 +potion_duration: 200 diff --git a/data/items/stim_cell.yaml b/data/items/stim_cell.yaml index 03930e7..b431532 100644 --- a/data/items/stim_cell.yaml +++ b/data/items/stim_cell.yaml @@ -1,6 +1,9 @@ id: stim_cell name: "stim cell" color: "226" -description: "A bright yellow energy supplement that restores stamina. [Effect not yet implemented]" +description: "A bright yellow energy supplement that restores stamina." value: 65 stackable: false +potion_effect: "battery" +potion_bonus: 25 +potion_duration: 0 diff --git a/data/items/stim_potion.yaml b/data/items/stim_potion.yaml index 19841d0..de51291 100644 --- a/data/items/stim_potion.yaml +++ b/data/items/stim_potion.yaml @@ -1,6 +1,9 @@ id: stim_potion name: "stim potion" color: "196" -description: "A bubbling red potion that temporarily boosts attack capability. [Effect not yet implemented]" +description: "A bubbling red potion that temporarily boosts attack capability." value: 30 stackable: false +potion_effect: "attack" +potion_bonus: 10 +potion_duration: 200 diff --git a/data/items/super_amp.yaml b/data/items/super_amp.yaml index 153e01f..3bea59b 100644 --- a/data/items/super_amp.yaml +++ b/data/items/super_amp.yaml @@ -1,6 +1,9 @@ id: super_amp name: "super amp" -color: "231" -description: "A dangerously concentrated strength amplifier. Handle carefully. [Effect not yet implemented]" +color: "220" +description: "A concentrated gold potion that significantly amplifies physical strength." value: 155 stackable: false +potion_effect: "strength" +potion_bonus: 20 +potion_duration: 200 diff --git a/data/items/super_bio_serum.yaml b/data/items/super_bio_serum.yaml index d2ef4de..fbc5ebe 100644 --- a/data/items/super_bio_serum.yaml +++ b/data/items/super_bio_serum.yaml @@ -1,6 +1,9 @@ id: super_bio_serum name: "super bio serum" -color: "34" -description: "A concentrated antitoxin. Provides extended poison immunity. [Effect not yet implemented]" +color: "48" +description: "A concentrated green serum that powerfully restores health." value: 130 stackable: false +potion_effect: "heal" +potion_bonus: 120 +potion_duration: 0 diff --git a/data/items/super_shield.yaml b/data/items/super_shield.yaml index 5f6053d..7545b2e 100644 --- a/data/items/super_shield.yaml +++ b/data/items/super_shield.yaml @@ -1,6 +1,9 @@ id: super_shield name: "super shield" -color: "27" -description: "A deep indigo potion that provides significant defensive enhancement. [Effect not yet implemented]" +color: "21" +description: "A concentrated blue potion that provides significant defensive enhancement." value: 175 stackable: false +potion_effect: "defense" +potion_bonus: 20 +potion_duration: 200 diff --git a/data/items/super_stim.yaml b/data/items/super_stim.yaml index d82704c..c290c5f 100644 --- a/data/items/super_stim.yaml +++ b/data/items/super_stim.yaml @@ -1,6 +1,9 @@ id: super_stim name: "super stim" -color: "160" -description: "A potent dark-red stimulant. Dramatically boosts attack capability. [Effect not yet implemented]" +color: "196" +description: "A potent red potion that dramatically boosts attack capability." value: 120 stackable: false +potion_effect: "attack" +potion_bonus: 20 +potion_duration: 200 diff --git a/data/items/super_stim_cell.yaml b/data/items/super_stim_cell.yaml index 7952603..1f25597 100644 --- a/data/items/super_stim_cell.yaml +++ b/data/items/super_stim_cell.yaml @@ -1,6 +1,9 @@ id: super_stim_cell name: "super stim cell" -color: "220" -description: "An advanced energy compound that fully restores stamina. [Effect not yet implemented]" +color: "226" +description: "A concentrated yellow energy supplement that fully restores stamina." value: 140 stackable: false +potion_effect: "battery" +potion_bonus: 50 +potion_duration: 0 diff --git a/data/items/targeting_serum.yaml b/data/items/targeting_serum.yaml index fd583d6..b10ebc5 100644 --- a/data/items/targeting_serum.yaml +++ b/data/items/targeting_serum.yaml @@ -1,6 +1,9 @@ id: targeting_serum name: "targeting serum" -color: "70" -description: "A dark green serum that sharpens ranged targeting systems. [Effect not yet implemented]" +color: "202" +description: "A pink serum that sharpens ranged targeting." value: 190 stackable: false +potion_effect: "ranged" +potion_bonus: 10 +potion_duration: 200 diff --git a/data/items/teak_planks.yaml b/data/items/teak_planks.yaml new file mode 100644 index 0000000..f0a0642 --- /dev/null +++ b/data/items/teak_planks.yaml @@ -0,0 +1,6 @@ +id: teak_planks +name: teak planks +color: "100" +description: "Fine teak planks with a rich grain." +value: 80 +stackable: false diff --git a/data/items/teak_table.yaml b/data/items/teak_table.yaml new file mode 100644 index 0000000..5c2c5c5 --- /dev/null +++ b/data/items/teak_table.yaml @@ -0,0 +1,6 @@ +id: teak_table +name: teak table +color: "100" +description: "An elegant teak table with a polished surface." +value: 160 +stackable: false diff --git a/data/items/tech_serum.yaml b/data/items/tech_serum.yaml index fd1b857..aac3fdc 100644 --- a/data/items/tech_serum.yaml +++ b/data/items/tech_serum.yaml @@ -1,6 +1,9 @@ id: tech_serum name: "tech serum" -color: "51" -description: "A cyan serum that temporarily enhances technology interface capability. [Effect not yet implemented]" +color: "75" +description: "A cyan serum that enhances technology interface." value: 100 stackable: false +potion_effect: "technology" +potion_bonus: 10 +potion_duration: 200 diff --git a/data/items/wooden_chair.yaml b/data/items/wooden_chair.yaml new file mode 100644 index 0000000..16220ef --- /dev/null +++ b/data/items/wooden_chair.yaml @@ -0,0 +1,6 @@ +id: wooden_chair +name: wooden chair +color: "130" +description: "A comfortable wooden chair with a smooth finish." +value: 35 +stackable: false diff --git a/data/items/wooden_shelf.yaml b/data/items/wooden_shelf.yaml new file mode 100644 index 0000000..3b0d97d --- /dev/null +++ b/data/items/wooden_shelf.yaml @@ -0,0 +1,6 @@ +id: wooden_shelf +name: wooden shelf +color: "130" +description: "A simple wooden shelf, useful for storing small items." +value: 40 +stackable: false diff --git a/data/items/wooden_table.yaml b/data/items/wooden_table.yaml new file mode 100644 index 0000000..171a9bb --- /dev/null +++ b/data/items/wooden_table.yaml @@ -0,0 +1,6 @@ +id: wooden_table +name: wooden table +color: "130" +description: "A sturdy wooden table, perfect for dining or displaying objects." +value: 50 +stackable: false diff --git a/data/mobs/ancient_construct.yaml b/data/mobs/ancient_construct.yaml new file mode 100644 index 0000000..0e2d04e --- /dev/null +++ b/data/mobs/ancient_construct.yaml @@ -0,0 +1,47 @@ +id: ancient_construct +name: ancient construct +description: "An impossibly old machine from before the regression, still operational. Its design is alien even by modern standards, covered in glowing runes that predate recorded history." +attack: 58 +strength: 62 +defense: 66 +hp: 152 +speed: 4 +aggressive: true +respawn_ticks: 150 +attack_type: slash +attack_bonus: 55 +strength_bonus: 55 +stab_defense: 95 +slash_defense: 100 +crush_defense: 85 +science_defense: -40 +ranged_defense: 70 +weakness: chaos +idle_descriptions: + - "etches glowing symbols into the air" + - "reconfigures its form with clicking sounds" + - "projects a holographic star map" + - "emits a resonant chime that shakes the walls" +combat_descriptions: + - "slices at %s with a blade of pure light" + - "unleashes a cascade of ancient energy at %s" + - "teleports behind %s and strikes" +drops: + remains: scrap_metal + loot: + - item_id: credits + weight: 40 + quantity: 10000 + - item_id: bloodjunk + weight: 20 + quantity: 8 + - item_id: deathjunk + weight: 15 + quantity: 8 + - table: gem_table + weight: 10 + - item_id: uncut_diamond + weight: 5 + quantity: 3 + - table: herb_table_high + weight: 10 diff --git a/data/mobs/bandit.yaml b/data/mobs/bandit.yaml new file mode 100644 index 0000000..e3cc0ae --- /dev/null +++ b/data/mobs/bandit.yaml @@ -0,0 +1,38 @@ +id: bandit +name: bandit +description: "A scrappy outlaw with a cybernetic arm and patched armor. He eyes you with predatory intent." +attack: 10 +strength: 12 +defense: 12 +hp: 28 +speed: 5 +aggressive: true +respawn_ticks: 30 +attack_type: slash +attack_bonus: 5 +strength_bonus: 5 +stab_defense: 10 +slash_defense: 15 +crush_defense: 8 +science_defense: -5 +ranged_defense: 12 +idle_descriptions: + - "scans the horizon for targets" + - "wipes a blade on his sleeve" + - "spits on the ground" + - "adjusts a cybernetic knuckle" +combat_descriptions: + - "slashes at %s with a serrated blade" + - "delivers a crushing punch with a cybernetic fist" + - "lunges at %s with a battle cry" +drops: + remains: bones + loot: + - item_id: credits + weight: 70 + quantity: 30 + - item_id: credits + weight: 20 + quantity: 80 + - table: herb_table_low + weight: 10 diff --git a/data/mobs/corrupted_scientist.yaml b/data/mobs/corrupted_scientist.yaml new file mode 100644 index 0000000..962dd3a --- /dev/null +++ b/data/mobs/corrupted_scientist.yaml @@ -0,0 +1,43 @@ +id: corrupted_scientist +name: corrupted scientist +description: "A researcher whose mind was consumed by their own experiments. Energy crackles around their fingertips and their lab coat is singed and tattered." +attack: 1 +strength: 1 +defense: 18 +hp: 40 +science: 50 +speed: 5 +aggressive: true +respawn_ticks: 45 +attack_type: science +attack_bonus: 0 +strength_bonus: 0 +stab_defense: 25 +slash_defense: 25 +crush_defense: 20 +science_defense: 35 +ranged_defense: -5 +weakness: eco +idle_descriptions: + - "mutters incomprehensible formulas" + - "energy arcs between their fingers" + - "scribbles equations on the wall in soot" + - "stares at their own hands in horror" +combat_descriptions: + - "blasts %s with a burst of unstable energy" + - "unleashes a wave of chaotic force at %s" + - "channels raw power through their hands at %s" +drops: + remains: bones + loot: + - item_id: credits + weight: 50 + quantity: 150 + - item_id: solarjunk + weight: 20 + quantity: 2 + - item_id: hydrojunk + weight: 15 + quantity: 2 + - table: herb_table_mid + weight: 15 diff --git a/data/mobs/elite_synth.yaml b/data/mobs/elite_synth.yaml new file mode 100644 index 0000000..9a4a3e8 --- /dev/null +++ b/data/mobs/elite_synth.yaml @@ -0,0 +1,43 @@ +id: elite_synth +name: elite synth +description: "A sleek combat android with glowing blue optics and a compact energy weapon. Its chassis is polished alloy and its movements are disturbingly fluid." +attack: 1 +strength: 1 +defense: 38 +hp: 104 +science: 65 +speed: 4 +aggressive: true +respawn_ticks: 75 +attack_type: science +attack_bonus: 0 +strength_bonus: 0 +stab_defense: 45 +slash_defense: 45 +crush_defense: 40 +science_defense: 55 +ranged_defense: -15 +weakness: chaos +idle_descriptions: + - "calibrates targeting sensors with a soft chime" + - "projects a holographic tactical display" + - "emits a binary chirp" + - "reconfigures its weapon array silently" +combat_descriptions: + - "unleashes a coherent energy beam at %s" + - "fires a pulse of disruptor energy at %s" + - "overloads its emitters and blasts %s" +drops: + remains: circuit_board + loot: + - item_id: credits + weight: 50 + quantity: 1500 + - item_id: biojunk + weight: 20 + quantity: 3 + - item_id: naturejunk + weight: 15 + quantity: 3 + - table: gem_table + weight: 15 diff --git a/data/mobs/enforcer_bot.yaml b/data/mobs/enforcer_bot.yaml new file mode 100644 index 0000000..623e210 --- /dev/null +++ b/data/mobs/enforcer_bot.yaml @@ -0,0 +1,42 @@ +id: enforcer_bot +name: enforcer bot +description: "A heavy riot control automaton with impact shields and a shock baton. It moves with deliberate, crushing steps." +attack: 30 +strength: 35 +defense: 32 +hp: 70 +speed: 5 +aggressive: true +respawn_ticks: 55 +attack_type: crush +attack_bonus: 25 +strength_bonus: 25 +stab_defense: 40 +slash_defense: 40 +crush_defense: 50 +science_defense: -15 +ranged_defense: 30 +weakness: solar +idle_descriptions: + - "patrols the area with heavy footsteps" + - "deploys its riot shield with a hiss" + - "announces a warning in garbled speech" + - "tests its shock baton against a wall" +combat_descriptions: + - "smashes %s with a shock baton" + - "slams its riot shield into %s" + - "delivers a stunning blow to %s" +drops: + remains: scrap_metal + loot: + - item_id: credits + weight: 60 + quantity: 350 + - item_id: scrap_metal + weight: 20 + quantity: 5 + - item_id: credits + weight: 15 + quantity: 800 + - table: gem_table + weight: 5 diff --git a/data/mobs/estate_broker.yaml b/data/mobs/estate_broker.yaml new file mode 100644 index 0000000..0b56c4c --- /dev/null +++ b/data/mobs/estate_broker.yaml @@ -0,0 +1,7 @@ +id: estate_broker +name: estate broker +behavior: estate_broker_talk +unique: true +color: "227" +description: "A sharply-dressed real estate agent with a datapad and a practiced smile." +idle: "The estate broker flashes a professional smile." diff --git a/data/mobs/feral_cyborg.yaml b/data/mobs/feral_cyborg.yaml new file mode 100644 index 0000000..e05f01b --- /dev/null +++ b/data/mobs/feral_cyborg.yaml @@ -0,0 +1,38 @@ +id: feral_cyborg +name: feral cyborg +description: "A heavily augmented human whose implants have driven them to madness. Razor-sharp blades extend from their forearms and their optical implant glows red." +attack: 34 +strength: 36 +defense: 32 +hp: 82 +speed: 4 +aggressive: true +respawn_ticks: 50 +attack_type: slash +attack_bonus: 30 +strength_bonus: 28 +stab_defense: 35 +slash_defense: 45 +crush_defense: 30 +science_defense: -10 +ranged_defense: 35 +idle_descriptions: + - "paces back and forth, muttering" + - "retracts and extends forearm blades" + - "emits a distorted laugh" + - "scratches at their own cybernetics" +combat_descriptions: + - "slashes %s with forearm blades" + - "delivers a powered kick to %s" + - "grapples %s with augmented strength" +drops: + remains: bones + loot: + - item_id: credits + weight: 60 + quantity: 300 + - item_id: scrap_metal + weight: 25 + quantity: 3 + - table: herb_table_mid + weight: 15 diff --git a/data/mobs/ghoul.yaml b/data/mobs/ghoul.yaml new file mode 100644 index 0000000..4c4bfa8 --- /dev/null +++ b/data/mobs/ghoul.yaml @@ -0,0 +1,38 @@ +id: ghoul +name: ghoul +description: "A radiation-scarred crew member, barely recognizable as human. Its flesh glows faintly and its eyes are hollow sockets." +attack: 22 +strength: 22 +defense: 20 +hp: 54 +speed: 4 +aggressive: true +respawn_ticks: 35 +attack_type: crush +attack_bonus: 15 +strength_bonus: 12 +stab_defense: 20 +slash_defense: 25 +crush_defense: 15 +science_defense: -5 +ranged_defense: 20 +weakness: hydro +idle_descriptions: + - "shambles aimlessly and moans" + - "scratches at the walls leaving glowing marks" + - "stares blankly into the darkness" + - "emits a low, rattling groan" +combat_descriptions: + - "claws at %s with irradiated hands" + - "lunges at %s with a gurgling shriek" + - "bites %s with decaying teeth" +drops: + remains: bones + loot: + - item_id: credits + weight: 60 + quantity: 80 + - item_id: scrap_metal + weight: 25 + - table: herb_table_low + weight: 15 diff --git a/data/mobs/heavy_mech.yaml b/data/mobs/heavy_mech.yaml new file mode 100644 index 0000000..fa8db8c --- /dev/null +++ b/data/mobs/heavy_mech.yaml @@ -0,0 +1,42 @@ +id: heavy_mech +name: heavy mech +description: "A lumbering assault mech with layered armor plating and a rotary cannon arm. Steam vents from its exhaust ports with each step." +attack: 40 +strength: 44 +defense: 44 +hp: 94 +speed: 5 +aggressive: true +respawn_ticks: 70 +attack_type: crush +attack_bonus: 35 +strength_bonus: 35 +stab_defense: 55 +slash_defense: 55 +crush_defense: 65 +science_defense: -25 +ranged_defense: 40 +weakness: solar +idle_descriptions: + - "scans the room with a blue optical beam" + - "vents a cloud of steam" + - "rotates its cannon with a mechanical whine" + - "announces a threat level assessment" +combat_descriptions: + - "fires its rotary cannon at %s" + - "smashes %s with a hydraulic fist" + - "unleashes a missile salvo at %s" +drops: + remains: scrap_metal + loot: + - item_id: credits + weight: 55 + quantity: 1000 + - item_id: scrap_metal + weight: 25 + quantity: 8 + - table: gem_table + weight: 10 + - item_id: lawjunk + weight: 10 + quantity: 2 diff --git a/data/mobs/molten_elemental.yaml b/data/mobs/molten_elemental.yaml new file mode 100644 index 0000000..315e27a --- /dev/null +++ b/data/mobs/molten_elemental.yaml @@ -0,0 +1,42 @@ +id: molten_elemental +name: molten elemental +description: "A flowing mass of liquid metal and superheated slag, roughly humanoid. The air around it shimmers with heat and droplets of molten alloy drip to the floor." +attack: 48 +strength: 52 +defense: 52 +hp: 115 +speed: 5 +aggressive: true +respawn_ticks: 80 +attack_type: crush +attack_bonus: 40 +strength_bonus: 42 +stab_defense: 60 +slash_defense: 60 +crush_defense: 70 +science_defense: -30 +ranged_defense: 45 +weakness: hydro +idle_descriptions: + - "bubbles and churns, throwing off sparks" + - "melts a nearby chunk of metal absent-mindedly" + - "pulses with an inner orange glow" + - "drips glowing droplets onto the floor" +combat_descriptions: + - "hurls a glob of molten metal at %s" + - "engulfs %s in a wave of liquid fire" + - "smashes %s with a superheated limb" +drops: + remains: scrap_metal + loot: + - item_id: credits + weight: 50 + quantity: 2000 + - item_id: biojunk + weight: 20 + quantity: 3 + - item_id: bloodjunk + weight: 15 + quantity: 2 + - table: herb_table_high + weight: 15 diff --git a/data/mobs/mutant_rat.yaml b/data/mobs/mutant_rat.yaml new file mode 100644 index 0000000..11ec0ad --- /dev/null +++ b/data/mobs/mutant_rat.yaml @@ -0,0 +1,39 @@ +id: mutant_rat +name: mutant rat +description: "A bloated rodent with glowing green eyes and exposed muscle tissue. Bio-engineering runoff has made it unnaturally aggressive." +attack: 13 +strength: 13 +defense: 12 +hp: 32 +speed: 4 +aggressive: true +respawn_ticks: 25 +attack_type: crush +attack_bonus: 8 +strength_bonus: 6 +stab_defense: 15 +slash_defense: 15 +crush_defense: 10 +science_defense: -10 +ranged_defense: 5 +weakness: bio +idle_descriptions: + - "scurries frantically in circles" + - "gnaws on a rusted pipe" + - "emits a gurgling screech" + - "twitches unnaturally" +combat_descriptions: + - "bites %s with glowing teeth" + - "slashes %s with mutated claws" + - "lunges at %s with unnatural speed" +drops: + remains: bones + loot: + - item_id: credits + weight: 60 + quantity: 20 + - item_id: scrap_metal + weight: 30 + - item_id: credits + weight: 10 + quantity: 60 diff --git a/data/mobs/plasma_wraith.yaml b/data/mobs/plasma_wraith.yaml new file mode 100644 index 0000000..9600ddc --- /dev/null +++ b/data/mobs/plasma_wraith.yaml @@ -0,0 +1,43 @@ +id: plasma_wraith +name: plasma wraith +description: "A shimmering entity of contained plasma, vaguely humanoid in shape. It radiates intense heat and hums with barely contained energy." +attack: 1 +strength: 1 +defense: 22 +hp: 56 +science: 65 +speed: 4 +aggressive: true +respawn_ticks: 60 +attack_type: science +attack_bonus: 0 +strength_bonus: 0 +stab_defense: 30 +slash_defense: 30 +crush_defense: 25 +science_defense: 45 +ranged_defense: -10 +weakness: hydro +idle_descriptions: + - "drifts silently, leaving heat shimmer in its wake" + - "pulses with internal light" + - "phases partially through a wall" + - "emits a low frequency hum" +combat_descriptions: + - "lashes %s with a whip of plasma" + - "engulfs %s in a superheated cloud" + - "discharges a concentrated beam at %s" +drops: + remains: ectoplasm + loot: + - item_id: credits + weight: 50 + quantity: 400 + - item_id: chaosjunk + weight: 20 + quantity: 2 + - item_id: cosmicjunk + weight: 15 + quantity: 2 + - table: gem_table + weight: 15 diff --git a/data/mobs/psionic_entity.yaml b/data/mobs/psionic_entity.yaml new file mode 100644 index 0000000..4b71836 --- /dev/null +++ b/data/mobs/psionic_entity.yaml @@ -0,0 +1,46 @@ +id: psionic_entity +name: psionic entity +description: "A barely visible distortion in the air, only detectable by the pressure it exerts on your mind. When it focuses, you see a shimmering outline of something vast and ancient." +attack: 1 +strength: 1 +defense: 42 +hp: 128 +science: 97 +speed: 4 +aggressive: true +respawn_ticks: 100 +attack_type: science +attack_bonus: 0 +strength_bonus: 0 +stab_defense: 50 +slash_defense: 50 +crush_defense: 45 +science_defense: 70 +ranged_defense: -20 +idle_descriptions: + - "probes the edges of your consciousness" + - "flickers between visible and invisible states" + - "emits a subsonic pulse you feel in your teeth" + - "momentarily shows you visions of another dimension" +combat_descriptions: + - "assaults %s's mind with psionic force" + - "tears at %s's perception of reality" + - "unleashes a devastating mental blast at %s" +drops: + remains: ectoplasm + loot: + - item_id: credits + weight: 40 + quantity: 5000 + - item_id: lawjunk + weight: 20 + quantity: 5 + - item_id: cosmicjunk + weight: 15 + quantity: 5 + - table: gem_table + weight: 15 + - item_id: spectral_visor + weight: 5 + - table: herb_table_high + weight: 5 diff --git a/data/mobs/sawmill_operator.yaml b/data/mobs/sawmill_operator.yaml new file mode 100644 index 0000000..64f6e45 --- /dev/null +++ b/data/mobs/sawmill_operator.yaml @@ -0,0 +1,7 @@ +id: sawmill_operator +name: sawmill operator +behavior: sawmill_operator_talk +unique: true +color: "186" +description: "A burly worker in a thick apron, covered in sawdust. He operates the industrial sawmill." +idle: "The sawmill operator wipes sawdust from his goggles." diff --git a/data/mobs/scrap_bot.yaml b/data/mobs/scrap_bot.yaml new file mode 100644 index 0000000..5b5c3e3 --- /dev/null +++ b/data/mobs/scrap_bot.yaml @@ -0,0 +1,39 @@ +id: scrap_bot +name: scrap bot +description: "A malfunctioning maintenance robot that has gone rogue. Its chassis is covered in dents and rust, and one optical sensor flickers erratically." +attack: 15 +strength: 15 +defense: 14 +hp: 30 +speed: 5 +aggressive: true +respawn_ticks: 30 +attack_type: crush +attack_bonus: 10 +strength_bonus: 8 +stab_defense: 20 +slash_defense: 15 +crush_defense: 25 +science_defense: -5 +ranged_defense: 10 +idle_descriptions: + - "beeps erratically and twitches" + - "bumps into a wall and reverses" + - "emits a burst of static" + - "scans the floor with a flickering beam" +combat_descriptions: + - "rams %s with its chassis" + - "swings a broken manipulator arm at %s" + - "discharges a spark at %s" +drops: + remains: scrap_metal + loot: + - item_id: credits + weight: 60 + quantity: 40 + - item_id: scrap_metal + weight: 30 + quantity: 2 + - item_id: credits + weight: 10 + quantity: 100 diff --git a/data/mobs/security_turret.yaml b/data/mobs/security_turret.yaml new file mode 100644 index 0000000..3d6b774 --- /dev/null +++ b/data/mobs/security_turret.yaml @@ -0,0 +1,41 @@ +id: security_turret +name: security turret +description: "A ceiling-mounted auto-turret with twin barrels and a red targeting laser. It tracks you with mechanical precision." +attack: 1 +strength: 1 +defense: 16 +hp: 44 +ranged: 34 +speed: 5 +aggressive: true +respawn_ticks: 40 +attack_type: ranged +attack_bonus: 0 +strength_bonus: 0 +stab_defense: 30 +slash_defense: 20 +crush_defense: 40 +science_defense: -20 +ranged_defense: 15 +weakness: solar +idle_descriptions: + - "pans the room with a targeting laser" + - "emits a rhythmic clicking sound" + - "calibrates its targeting sensors" + - "beeps a warning tone" +combat_descriptions: + - "fires twin bolts at %s" + - "unleashes a burst of rounds at %s" + - "locks onto %s and opens fire" +drops: + remains: scrap_metal + loot: + - item_id: credits + weight: 70 + quantity: 100 + - item_id: scrap_metal + weight: 20 + quantity: 3 + - item_id: credits + weight: 10 + quantity: 250 diff --git a/data/mobs/titan_sentinel.yaml b/data/mobs/titan_sentinel.yaml new file mode 100644 index 0000000..554ab4b --- /dev/null +++ b/data/mobs/titan_sentinel.yaml @@ -0,0 +1,41 @@ +id: titan_sentinel +name: titan sentinel +description: "A colossal automaton, the bigger brother of the iron sentinel. Its armor is reinforced adamantite alloy and its optics burn with crimson fire." +attack: 50 +strength: 52 +defense: 56 +hp: 132 +speed: 5 +aggressive: true +respawn_ticks: 120 +attack_type: crush +attack_bonus: 48 +strength_bonus: 45 +stab_defense: 90 +slash_defense: 90 +crush_defense: 80 +science_defense: -35 +ranged_defense: 60 +weakness: solar +idle_descriptions: + - "stands motionless, gyros humming" + - "scans the area with a sweeping crimson beam" + - "vents superheated exhaust" + - "rotates its armored torso with a deep grinding sound" +combat_descriptions: + - "smashes %s with a titan-grade fist" + - "unleashes a devastating plasma barrage at %s" + - "drives %s into the ground with overwhelming force" +drops: + loot: + - item_id: scrap_metal + weight: 30 + quantity: 10 + - item_id: credits + weight: 50 + quantity: 8000 + - table: gem_table + weight: 15 + - item_id: deathjunk + weight: 5 + quantity: 5 diff --git a/data/mobs/veteran_merc.yaml b/data/mobs/veteran_merc.yaml new file mode 100644 index 0000000..a33e73d --- /dev/null +++ b/data/mobs/veteran_merc.yaml @@ -0,0 +1,42 @@ +id: veteran_merc +name: veteran merc +description: "A battle-hardened soldier with a high-powered rifle and tactical armor covered in campaign patches. Their cybernetic eye locks onto you instantly." +attack: 1 +strength: 1 +defense: 40 +hp: 93 +ranged: 79 +speed: 5 +aggressive: true +respawn_ticks: 65 +attack_type: ranged +attack_bonus: 0 +strength_bonus: 0 +stab_defense: 40 +slash_defense: 40 +crush_defense: 35 +science_defense: 20 +ranged_defense: 55 +idle_descriptions: + - "checks their rifle's ammunition counter" + - "adjusts their tactical vest" + - "scans the area through their scope" + - "spits and mutters a war story" +combat_descriptions: + - "fires a high-caliber round at %s" + - "puts %s in their crosshairs and squeezes the trigger" + - "unleashes suppressive fire at %s" +drops: + remains: bones + loot: + - item_id: credits + weight: 55 + quantity: 1200 + - item_id: chaosjunk + weight: 15 + quantity: 2 + - item_id: deathjunk + weight: 15 + quantity: 2 + - table: herb_table_mid + weight: 15 diff --git a/data/mobs/void_reaper.yaml b/data/mobs/void_reaper.yaml new file mode 100644 index 0000000..8c48d07 --- /dev/null +++ b/data/mobs/void_reaper.yaml @@ -0,0 +1,52 @@ +id: void_reaper +name: void reaper +description: "A shadowy figure that seems to absorb light itself. Its form shifts between a hooded specter and an armored construct. A scythe of dark energy flickers in its grasp." +attack: 62 +strength: 68 +defense: 72 +hp: 170 +speed: 3 +aggressive: true +respawn_ticks: 200 +attack_type: slash +attack_bonus: 60 +strength_bonus: 60 +stab_defense: 100 +slash_defense: 110 +crush_defense: 90 +science_defense: -45 +ranged_defense: 75 +weakness: bio +assassin_level: 80 +finishing_blow: lawjunk +idle_descriptions: + - "flickers in and out of existence" + - "draws the warmth from the surrounding air" + - "whispers in a language that predates humanity" + - "reaches out with shadowy tendrils" +combat_descriptions: + - "swings its void scythe at %s" + - "drains the life force from %s" + - "envelops %s in absolute darkness" +drops: + remains: ectoplasm + loot: + - item_id: credits + weight: 30 + quantity: 20000 + - item_id: law_identifier + weight: 5 + - item_id: death_identifier + weight: 5 + - item_id: blood_identifier + weight: 5 + - item_id: bloodjunk + weight: 20 + quantity: 10 + - table: gem_table + weight: 15 + - item_id: uncut_diamond + weight: 10 + quantity: 5 + - table: herb_table_high + weight: 10 diff --git a/data/mobs/war_drone.yaml b/data/mobs/war_drone.yaml new file mode 100644 index 0000000..c9d1bbe --- /dev/null +++ b/data/mobs/war_drone.yaml @@ -0,0 +1,42 @@ +id: war_drone +name: war drone +description: "An upgraded combat drone with reinforced plating and a heavier weapons array. Its targeting systems whir as it locks onto you." +attack: 25 +strength: 28 +defense: 25 +hp: 65 +speed: 4 +aggressive: true +respawn_ticks: 50 +attack_type: slash +attack_bonus: 20 +strength_bonus: 18 +stab_defense: 35 +slash_defense: 30 +crush_defense: 40 +science_defense: -10 +ranged_defense: 20 +weakness: solar +idle_descriptions: + - "hovers aggressively, weapons armed" + - "emits a low, threatening hum" + - "paints a targeting reticle on the wall" + - "cycles through weapon modes with a click" +combat_descriptions: + - "fires a plasma bolt at %s" + - "slashes at %s with a rotary blade" + - "launches a concussion grenade at %s" +drops: + remains: circuit_board + loot: + - item_id: credits + weight: 60 + quantity: 200 + - item_id: scrap_metal + weight: 20 + quantity: 4 + - item_id: credits + weight: 10 + quantity: 500 + - table: gem_table + weight: 10 diff --git a/data/objects/bank_booth.yaml b/data/objects/bank_booth.yaml new file mode 100644 index 0000000..0cc9fc7 --- /dev/null +++ b/data/objects/bank_booth.yaml @@ -0,0 +1,5 @@ +id: bank_booth +name: bank booth +color: "226" +description: "A sleek terminal with a biometric scanner. You can deposit or withdraw items here. Type 'bank' to access it." +inroom_description: "A bank booth is set into the wall." diff --git a/data/objects/estate_directory.yaml b/data/objects/estate_directory.yaml new file mode 100644 index 0000000..3c1ecec --- /dev/null +++ b/data/objects/estate_directory.yaml @@ -0,0 +1,5 @@ +id: estate_directory +name: Estate Directory +color: "39" +description: "A holographic terminal displaying property records. The screen glows with a soft cyan light." +inroom_description: "An estate directory terminal stands in the center of the neighborhood." diff --git a/data/objects/netrunner.yaml b/data/objects/netrunner.yaml new file mode 100644 index 0000000..f81f7c5 --- /dev/null +++ b/data/objects/netrunner.yaml @@ -0,0 +1,6 @@ +id: netrunner +name: Netrunner +color: "39" +behavior: netrunner_talk +description: "A wiry figure hunched over a custom rig, fingers dancing across holographic keys. Cables snake from the back of their neck into the terminal. They glance up at you with augmented eyes." +inroom_description: "A {39}Netrunner{/} sits cross-legged on the floor, wired into the network." diff --git a/data/objects/terminal_advanced.yaml b/data/objects/terminal_advanced.yaml new file mode 100644 index 0000000..ca2cde7 --- /dev/null +++ b/data/objects/terminal_advanced.yaml @@ -0,0 +1,5 @@ +id: terminal_advanced +name: advanced terminal +color: "33" +description: "A sleek terminal with a holographic display. Encrypted data streams flow across the screen in complex patterns. {33}[Level 20 Hacking]{/}" +inroom_description: "An {33}advanced terminal{/} projects a holographic interface." diff --git a/data/objects/terminal_basic.yaml b/data/objects/terminal_basic.yaml new file mode 100644 index 0000000..3e1b35a --- /dev/null +++ b/data/objects/terminal_basic.yaml @@ -0,0 +1,5 @@ +id: terminal_basic +name: basic terminal +color: "40" +description: "A battered terminal with a cracked screen. Faded text scrolls across the display -- it looks like it's running some kind of legacy intrusion detection system. {40}[Level 1 Hacking]{/}" +inroom_description: "A {40}basic terminal{/} hums quietly against the wall." diff --git a/data/objects/terminal_secure.yaml b/data/objects/terminal_secure.yaml new file mode 100644 index 0000000..f196a86 --- /dev/null +++ b/data/objects/terminal_secure.yaml @@ -0,0 +1,5 @@ +id: terminal_secure +name: secure terminal +color: "196" +description: "A heavily reinforced terminal embedded in the wall. Warning glyphs pulse across its surface. A bluffing protocol runs on loop -- it's probing you as much as you're probing it. {196}[Level 40 Hacking]{/}" +inroom_description: "A {196}secure terminal{/} glows with warning indicators." diff --git a/data/objects/workbench.yaml b/data/objects/workbench.yaml new file mode 100644 index 0000000..d3dd396 --- /dev/null +++ b/data/objects/workbench.yaml @@ -0,0 +1,5 @@ +id: workbench +name: workbench +color: "172" +description: "A sturdy wooden workbench covered in sawdust, nails, and blueprints. The surface is worn from years of crafting." +inroom_description: "A workbench stands against the wall, cluttered with tools and wood shavings." diff --git a/data/recipes/construct_mahogany_planks.yaml b/data/recipes/construct_mahogany_planks.yaml new file mode 100644 index 0000000..07e0f40 --- /dev/null +++ b/data/recipes/construct_mahogany_planks.yaml @@ -0,0 +1,12 @@ +id: construct_mahogany_planks +type: construction +level: 50 +xp: 30 +wait: 20 +station: [workbench] +tool: saw +consume: + - items: [mahogany_logs] + qty: 1 +output: mahogany_planks +message: "You saw the mahogany logs into luxurious mahogany planks." diff --git a/data/recipes/construct_mahogany_table.yaml b/data/recipes/construct_mahogany_table.yaml new file mode 100644 index 0000000..252736f --- /dev/null +++ b/data/recipes/construct_mahogany_table.yaml @@ -0,0 +1,11 @@ +id: construct_mahogany_table +type: construction +level: 52 +xp: 120 +wait: 6 +station: [workbench] +consume: + - items: [mahogany_planks] + qty: 3 +output: mahogany_table +message: "You build a luxurious mahogany table." diff --git a/data/recipes/construct_oak_planks.yaml b/data/recipes/construct_oak_planks.yaml new file mode 100644 index 0000000..5858208 --- /dev/null +++ b/data/recipes/construct_oak_planks.yaml @@ -0,0 +1,12 @@ +id: construct_oak_planks +type: construction +level: 15 +xp: 10 +wait: 20 +station: [workbench] +tool: saw +consume: + - items: [oak_logs] + qty: 1 +output: oak_planks +message: "You saw the oak logs into sturdy oak planks." diff --git a/data/recipes/construct_oak_shelf.yaml b/data/recipes/construct_oak_shelf.yaml new file mode 100644 index 0000000..8661679 --- /dev/null +++ b/data/recipes/construct_oak_shelf.yaml @@ -0,0 +1,11 @@ +id: construct_oak_shelf +type: construction +level: 18 +xp: 40 +wait: 6 +station: [workbench] +consume: + - items: [oak_planks] + qty: 2 +output: oak_shelf +message: "You assemble the oak planks into a sturdy shelf." diff --git a/data/recipes/construct_oak_table.yaml b/data/recipes/construct_oak_table.yaml new file mode 100644 index 0000000..e8eff24 --- /dev/null +++ b/data/recipes/construct_oak_table.yaml @@ -0,0 +1,11 @@ +id: construct_oak_table +type: construction +level: 20 +xp: 50 +wait: 6 +station: [workbench] +consume: + - items: [oak_planks] + qty: 3 +output: oak_table +message: "You build a fine oak table." diff --git a/data/recipes/construct_planks.yaml b/data/recipes/construct_planks.yaml new file mode 100644 index 0000000..4a2a363 --- /dev/null +++ b/data/recipes/construct_planks.yaml @@ -0,0 +1,12 @@ +id: construct_planks +type: construction +level: 1 +xp: 5 +wait: 20 +station: [workbench] +tool: saw +consume: + - items: [logs] + qty: 1 +output: planks +message: "You saw the logs into some regular planks." diff --git a/data/recipes/construct_teak_planks.yaml b/data/recipes/construct_teak_planks.yaml new file mode 100644 index 0000000..022ff7f --- /dev/null +++ b/data/recipes/construct_teak_planks.yaml @@ -0,0 +1,12 @@ +id: construct_teak_planks +type: construction +level: 35 +xp: 20 +wait: 20 +station: [workbench] +tool: saw +consume: + - items: [teak_logs] + qty: 1 +output: teak_planks +message: "You saw the teak logs into fine teak planks." diff --git a/data/recipes/construct_teak_table.yaml b/data/recipes/construct_teak_table.yaml new file mode 100644 index 0000000..db926b8 --- /dev/null +++ b/data/recipes/construct_teak_table.yaml @@ -0,0 +1,11 @@ +id: construct_teak_table +type: construction +level: 38 +xp: 80 +wait: 6 +station: [workbench] +consume: + - items: [teak_planks] + qty: 3 +output: teak_table +message: "You build an elegant teak table." diff --git a/data/recipes/construct_wooden_chair.yaml b/data/recipes/construct_wooden_chair.yaml new file mode 100644 index 0000000..cad6200 --- /dev/null +++ b/data/recipes/construct_wooden_chair.yaml @@ -0,0 +1,11 @@ +id: construct_wooden_chair +type: construction +level: 8 +xp: 30 +wait: 6 +station: [workbench] +consume: + - items: [planks] + qty: 2 +output: wooden_chair +message: "You craft a comfortable wooden chair." diff --git a/data/recipes/construct_wooden_shelf.yaml b/data/recipes/construct_wooden_shelf.yaml new file mode 100644 index 0000000..f09a199 --- /dev/null +++ b/data/recipes/construct_wooden_shelf.yaml @@ -0,0 +1,11 @@ +id: construct_wooden_shelf +type: construction +level: 4 +xp: 20 +wait: 6 +station: [workbench] +consume: + - items: [planks] + qty: 2 +output: wooden_shelf +message: "You assemble the planks into a wooden shelf." diff --git a/data/recipes/construct_wooden_table.yaml b/data/recipes/construct_wooden_table.yaml new file mode 100644 index 0000000..8451f31 --- /dev/null +++ b/data/recipes/construct_wooden_table.yaml @@ -0,0 +1,11 @@ +id: construct_wooden_table +type: construction +level: 6 +xp: 25 +wait: 6 +station: [workbench] +consume: + - items: [planks] + qty: 3 +output: wooden_table +message: "You build a sturdy wooden table." diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml index fa3fb9b..c4ec261 100644 --- a/data/rooms/1.yaml +++ b/data/rooms/1.yaml @@ -10,6 +10,7 @@ exits: objects: - id: lumby_fountain - id: charging_station + - id: bank_booth mobs: - "newbie_trainer" - "general_store_clerk" diff --git a/data/rooms/10.yaml b/data/rooms/10.yaml index 73ac75a..edeac4d 100644 --- a/data/rooms/10.yaml +++ b/data/rooms/10.yaml @@ -7,3 +7,4 @@ exits: mobs: - id: skeleton - id: skeleton + - id: mutant_rat diff --git a/data/rooms/12.yaml b/data/rooms/12.yaml index 0c4443b..80380b8 100644 --- a/data/rooms/12.yaml +++ b/data/rooms/12.yaml @@ -9,6 +9,7 @@ mobs: objects: - id: furnace - id: anvil + - id: bank_booth spawns: - item_id: copper_ore quantity: 3 diff --git a/data/rooms/16.yaml b/data/rooms/16.yaml index 115d55b..3776f4f 100644 --- a/data/rooms/16.yaml +++ b/data/rooms/16.yaml @@ -1,8 +1,12 @@ id: 16 name: "Construction Site" -description: "A half-built structure surrounded by planks, nails, and blueprints. This area is not yet accessible." +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: 17 + east: 171 west: 15 + north: 170 +objects: + - id: workbench mobs: - id: greater_drone + - id: estate_broker diff --git a/data/rooms/17.yaml b/data/rooms/17.yaml index 80b1dfc..512ec9e 100644 --- a/data/rooms/17.yaml +++ b/data/rooms/17.yaml @@ -1,6 +1,10 @@ id: 17 name: "Bonfire" -description: "A ring of stones surrounds a bed of ash. A few unlit logs wait nearby. This area is not yet accessible." +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 +mobs: + - id: scrap_bot + - id: scrap_bot diff --git a/data/rooms/170.yaml b/data/rooms/170.yaml new file mode 100644 index 0000000..35d539f --- /dev/null +++ b/data/rooms/170.yaml @@ -0,0 +1,7 @@ +id: 170 +name: "Local Neighborhood" +description: "A quiet cul-de-sac of modest homes, each with a small garden plot. An estate directory terminal glows in the center, listing residents and property information." +exits: + south: 16 +objects: + - id: estate_directory diff --git a/data/rooms/171.yaml b/data/rooms/171.yaml new file mode 100644 index 0000000..552ccd4 --- /dev/null +++ b/data/rooms/171.yaml @@ -0,0 +1,7 @@ +id: 171 +name: "Lumberyard" +description: "Stacks of fresh-cut timber fill the air with the scent of pine. A massive industrial sawmill hums in the background, operated by a burly worker." +exits: + west: 16 +mobs: + - id: sawmill_operator diff --git a/data/rooms/200.yaml b/data/rooms/200.yaml new file mode 100644 index 0000000..469e214 --- /dev/null +++ b/data/rooms/200.yaml @@ -0,0 +1,8 @@ +id: 200 +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 diff --git a/data/rooms/201.yaml b/data/rooms/201.yaml new file mode 100644 index 0000000..2beb2ec --- /dev/null +++ b/data/rooms/201.yaml @@ -0,0 +1,9 @@ +id: 201 +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 + blocked_message: "" diff --git a/data/rooms/202.yaml b/data/rooms/202.yaml new file mode 100644 index 0000000..b9c0292 --- /dev/null +++ b/data/rooms/202.yaml @@ -0,0 +1,9 @@ +id: 202 +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 + blocked_message: "" diff --git a/data/rooms/203.yaml b/data/rooms/203.yaml new file mode 100644 index 0000000..4f5d883 --- /dev/null +++ b/data/rooms/203.yaml @@ -0,0 +1,9 @@ +id: 203 +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 + blocked_message: "" diff --git a/data/rooms/204.yaml b/data/rooms/204.yaml new file mode 100644 index 0000000..2d82040 --- /dev/null +++ b/data/rooms/204.yaml @@ -0,0 +1,9 @@ +id: 204 +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 + blocked_message: "" diff --git a/data/rooms/205.yaml b/data/rooms/205.yaml new file mode 100644 index 0000000..d814f7d --- /dev/null +++ b/data/rooms/205.yaml @@ -0,0 +1,9 @@ +id: 205 +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 + blocked_message: "" diff --git a/data/rooms/210.yaml b/data/rooms/210.yaml new file mode 100644 index 0000000..2b86500 --- /dev/null +++ b/data/rooms/210.yaml @@ -0,0 +1,9 @@ +id: 210 +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 + blocked_message: "" diff --git a/data/rooms/211.yaml b/data/rooms/211.yaml new file mode 100644 index 0000000..8b1f069 --- /dev/null +++ b/data/rooms/211.yaml @@ -0,0 +1,9 @@ +id: 211 +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 + blocked_message: "" diff --git a/data/rooms/212.yaml b/data/rooms/212.yaml new file mode 100644 index 0000000..0393a7e --- /dev/null +++ b/data/rooms/212.yaml @@ -0,0 +1,9 @@ +id: 212 +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 + blocked_message: "" diff --git a/data/rooms/213.yaml b/data/rooms/213.yaml new file mode 100644 index 0000000..be3b736 --- /dev/null +++ b/data/rooms/213.yaml @@ -0,0 +1,9 @@ +id: 213 +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 + blocked_message: "" diff --git a/data/rooms/214.yaml b/data/rooms/214.yaml new file mode 100644 index 0000000..9f9e081 --- /dev/null +++ b/data/rooms/214.yaml @@ -0,0 +1,9 @@ +id: 214 +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 + blocked_message: "" diff --git a/data/rooms/215.yaml b/data/rooms/215.yaml new file mode 100644 index 0000000..7ea39f9 --- /dev/null +++ b/data/rooms/215.yaml @@ -0,0 +1,9 @@ +id: 215 +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 + blocked_message: "" diff --git a/data/rooms/220.yaml b/data/rooms/220.yaml new file mode 100644 index 0000000..d6826a3 --- /dev/null +++ b/data/rooms/220.yaml @@ -0,0 +1,9 @@ +id: 220 +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 + blocked_message: "" diff --git a/data/rooms/221.yaml b/data/rooms/221.yaml new file mode 100644 index 0000000..936ee65 --- /dev/null +++ b/data/rooms/221.yaml @@ -0,0 +1,9 @@ +id: 221 +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 + blocked_message: "" diff --git a/data/rooms/222.yaml b/data/rooms/222.yaml new file mode 100644 index 0000000..119db01 --- /dev/null +++ b/data/rooms/222.yaml @@ -0,0 +1,9 @@ +id: 222 +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 + blocked_message: "" diff --git a/data/rooms/223.yaml b/data/rooms/223.yaml new file mode 100644 index 0000000..0e557f6 --- /dev/null +++ b/data/rooms/223.yaml @@ -0,0 +1,9 @@ +id: 223 +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 + blocked_message: "" diff --git a/data/rooms/224.yaml b/data/rooms/224.yaml new file mode 100644 index 0000000..4e70f7a --- /dev/null +++ b/data/rooms/224.yaml @@ -0,0 +1,9 @@ +id: 224 +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 + blocked_message: "" diff --git a/data/rooms/225.yaml b/data/rooms/225.yaml new file mode 100644 index 0000000..3413792 --- /dev/null +++ b/data/rooms/225.yaml @@ -0,0 +1,9 @@ +id: 225 +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 + blocked_message: "" diff --git a/data/rooms/226.yaml b/data/rooms/226.yaml new file mode 100644 index 0000000..6faa2c1 --- /dev/null +++ b/data/rooms/226.yaml @@ -0,0 +1,9 @@ +id: 226 +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 + blocked_message: "" diff --git a/data/rooms/54.yaml b/data/rooms/54.yaml index 5bb6a1d..e86e5b7 100644 --- a/data/rooms/54.yaml +++ b/data/rooms/54.yaml @@ -3,6 +3,7 @@ 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 mobs: - "phantom" - "phantom" diff --git a/data/rooms/55.yaml b/data/rooms/55.yaml new file mode 100644 index 0000000..8000170 --- /dev/null +++ b/data/rooms/55.yaml @@ -0,0 +1,12 @@ +id: 55 +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 +mobs: + - id: security_turret diff --git a/data/rooms/56.yaml b/data/rooms/56.yaml new file mode 100644 index 0000000..375696d --- /dev/null +++ b/data/rooms/56.yaml @@ -0,0 +1,9 @@ +id: 56 +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 +mobs: + - id: ghoul + - id: ghoul + - id: corrupted_scientist diff --git a/data/rooms/57.yaml b/data/rooms/57.yaml new file mode 100644 index 0000000..714197d --- /dev/null +++ b/data/rooms/57.yaml @@ -0,0 +1,8 @@ +id: 57 +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 +mobs: + - id: war_drone + - id: war_drone diff --git a/data/rooms/58.yaml b/data/rooms/58.yaml new file mode 100644 index 0000000..f4d1829 --- /dev/null +++ b/data/rooms/58.yaml @@ -0,0 +1,8 @@ +id: 58 +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 +mobs: + - id: enforcer_bot diff --git a/data/rooms/59.yaml b/data/rooms/59.yaml new file mode 100644 index 0000000..a1cc594 --- /dev/null +++ b/data/rooms/59.yaml @@ -0,0 +1,9 @@ +id: 59 +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 +mobs: + - id: plasma_wraith + - id: plasma_wraith + - id: feral_cyborg diff --git a/data/rooms/60.yaml b/data/rooms/60.yaml new file mode 100644 index 0000000..e9b7114 --- /dev/null +++ b/data/rooms/60.yaml @@ -0,0 +1,8 @@ +id: 60 +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 +mobs: + - id: heavy_mech diff --git a/data/rooms/61.yaml b/data/rooms/61.yaml new file mode 100644 index 0000000..a85c7ba --- /dev/null +++ b/data/rooms/61.yaml @@ -0,0 +1,7 @@ +id: 61 +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 +mobs: + - id: elite_synth diff --git a/data/rooms/62.yaml b/data/rooms/62.yaml new file mode 100644 index 0000000..9ed1fad --- /dev/null +++ b/data/rooms/62.yaml @@ -0,0 +1,8 @@ +id: 62 +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 +mobs: + - id: veteran_merc diff --git a/data/rooms/63.yaml b/data/rooms/63.yaml new file mode 100644 index 0000000..b6bf53e --- /dev/null +++ b/data/rooms/63.yaml @@ -0,0 +1,8 @@ +id: 63 +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 +mobs: + - id: molten_elemental diff --git a/data/rooms/64.yaml b/data/rooms/64.yaml new file mode 100644 index 0000000..b412992 --- /dev/null +++ b/data/rooms/64.yaml @@ -0,0 +1,8 @@ +id: 64 +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 +mobs: + - id: iron_sentinel diff --git a/data/rooms/65.yaml b/data/rooms/65.yaml new file mode 100644 index 0000000..47aed6b --- /dev/null +++ b/data/rooms/65.yaml @@ -0,0 +1,8 @@ +id: 65 +name: "Titan's Rest" +description: "A colossal vault built to house something immense. The ceiling vanishes into darkness above, and the walls are etched with maintenance schematics for a machine of staggering proportions. At the center, a titan sentinel awakens." +exits: + up: 64 + down: 66 +mobs: + - id: titan_sentinel diff --git a/data/rooms/66.yaml b/data/rooms/66.yaml new file mode 100644 index 0000000..755d5e2 --- /dev/null +++ b/data/rooms/66.yaml @@ -0,0 +1,8 @@ +id: 66 +name: "Psionic Nexus" +description: "Reality grows thin here. The walls are covered in strange geometric patterns that hurt to look at directly. A low hum vibrates through your skull, and the air seems to bend around a central point of unseen pressure." +exits: + up: 65 + down: 67 +mobs: + - id: psionic_entity diff --git a/data/rooms/67.yaml b/data/rooms/67.yaml new file mode 100644 index 0000000..6b8d663 --- /dev/null +++ b/data/rooms/67.yaml @@ -0,0 +1,8 @@ +id: 67 +name: "Ancient Chamber" +description: "A perfectly spherical room carved from an unknown black metal. Glowing runes spiral across every surface in patterns that predate the asteroid itself. At the center, an ancient construct stirs from its eons-long vigil." +exits: + up: 66 + down: 68 +mobs: + - id: ancient_construct diff --git a/data/rooms/68.yaml b/data/rooms/68.yaml new file mode 100644 index 0000000..f631981 --- /dev/null +++ b/data/rooms/68.yaml @@ -0,0 +1,7 @@ +id: 68 +name: "The Void" +description: "The bottom of the world. Light itself seems to hesitate before entering this place. The floor is unnaturally smooth black glass, and the only sound is the beating of your own heart. Something waits in the darkness — something that was here before the asteroid, before the colony, before anything." +exits: + up: 67 +mobs: + - id: void_reaper diff --git a/data/rooms/7.yaml b/data/rooms/7.yaml index 7458314..37dfed9 100644 --- a/data/rooms/7.yaml +++ b/data/rooms/7.yaml @@ -6,3 +6,4 @@ exits: mobs: - id: goblin - id: goblin + - id: bandit diff --git a/data/rooms/8.yaml b/data/rooms/8.yaml index 4e36af4..b82c1bb 100644 --- a/data/rooms/8.yaml +++ b/data/rooms/8.yaml @@ -1,6 +1,11 @@ id: 8 name: "Hacking Lab" -description: "A dimly lit room filled with old terminals and flickering screens. This area is not yet accessible." +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 +objects: + - id: terminal_basic + - id: terminal_advanced + - id: terminal_secure + - id: netrunner diff --git a/data/rooms/player_housing/200.yaml b/data/rooms/player_housing/200.yaml new file mode 100644 index 0000000..1781818 --- /dev/null +++ b/data/rooms/player_housing/200.yaml @@ -0,0 +1,8 @@ +id: 200 +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: 201 +objects: + - id: charging_station diff --git a/data/rooms/player_housing/201.yaml b/data/rooms/player_housing/201.yaml new file mode 100644 index 0000000..dbc1fd9 --- /dev/null +++ b/data/rooms/player_housing/201.yaml @@ -0,0 +1,8 @@ +id: 201 +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: 200 +objects: + - id: workbench + - id: bank_booth diff --git a/internal/action/behavior.go b/internal/action/behavior.go index 236ab81..e1a79e3 100644 --- a/internal/action/behavior.go +++ b/internal/action/behavior.go @@ -55,6 +55,7 @@ type NodeAction struct { SkipTask bool `yaml:"skip_task"` ExtendTask bool `yaml:"extend_task"` ReputationCost int `yaml:"reputation_cost"` + Sawmill bool `yaml:"sawmill"` } type ShopConfig struct { diff --git a/internal/game/action.go b/internal/game/action.go index be35ae2..dc21927 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -153,6 +153,11 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { return } + if obj != nil && obj.ID == "estate_directory" { + g.startEstateDirectory(sess, verb) + return + } + if bh.Type != verb { if obj != nil { sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) @@ -251,6 +256,8 @@ func (g *Game) AdvanceActions() { g.advanceWater(sess, p) case "cure": g.advanceCure(sess, p) + case "obstacle": + g.advanceObstacle(sess, p) default: if productionActionTypes[p.Action.Type] { g.advanceProduction(sess, p) diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go new file mode 100644 index 0000000..089f026 --- /dev/null +++ b/internal/game/action_agility.go @@ -0,0 +1,180 @@ +package game + +import ( + "fmt" + "math/rand" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) startObstacle(sess *net.Session, p *player.Player, info *ObstacleInfo) { + failChance := g.calcFailChance(p, info) + + msgs := make([]any, len(info.Messages)) + for i, m := range info.Messages { + msgs[i] = m + } + + gerund := info.Verb + "ing" + if g, ok := verbGerund[info.Verb]; ok { + gerund = g + } + + p.Action = &action.Action{ + Type: "obstacle", + TargetID: info.CourseID, + TargetName: info.CourseName, + WaitLeft: 0, + Data: map[string]any{ + "course_id": info.CourseID, + "phase": 0, + "obstacle_index": info.ObstacleIndex, + "total_obstacles": info.TotalObstacles, + "next_room": info.NextRoom, + "start_room": info.StartRoom, + "obstacle_xp": info.ObstacleXP, + "completion_xp": info.CompletionXP, + "fail_chance": failChance, + "fail_damage_min": info.FailDamage[0], + "fail_damage_max": info.FailDamage[1], + "messages": msgs, + "ticks_per_phase": info.TicksPerPhase, + "required_level": info.RequiredLevel, + }, + } + + p.ActionState = &ActionState{ + Type: ActionTraversing, + Verb: gerund, + TargetName: info.CourseName, + } + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s on the %s.", p.Name, gerund, info.CourseName))) + } + } + } +} + +func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { + data := p.Action.Data + phase := data["phase"].(int) + messages := data["messages"].([]any) + ticksPerPhase := data["ticks_per_phase"].(float64) + + if phase >= len(messages) { + g.CancelAction(p) + return + } + + msg := messages[phase].(string) + sess.WriteLine(g.colorize(sess, "broadcast", msg)) + + if phase == 1 { + failChance := data["fail_chance"].(float64) + if rand.Float64() < failChance { + g.obstacleFail(sess, p, data) + return + } + } + + nextPhase := phase + 1 + + if nextPhase >= len(messages) { + obstacleXP := data["obstacle_xp"].(int) + completionXP := data["completion_xp"].(int) + nextRoom := data["next_room"].(int) + startRoom := data["start_room"].(int) + obstacleIndex := data["obstacle_index"].(int) + totalObstacles := data["total_obstacles"].(int) + courseID := data["course_id"].(string) + + if obstacleXP > 0 { + if newLevel := p.AddSkillXP(player.Agility, obstacleXP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", + fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) + } + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) + } + } + + isLastObstacle := obstacleIndex == totalObstacles-1 + + if isLastObstacle { + if completionXP > 0 { + if newLevel := p.AddSkillXP(player.Agility, completionXP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", + fmt.Sprintf("*** You are now level %d agility! ***", newLevel))) + } + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) + } + } + + lapKey := "agility_laps_" + courseID + laps := getPlayerFlagInt(p, lapKey) + 1 + setPlayerFlag(p, lapKey, laps) + + courseName := p.Action.TargetName + sess.WriteLine(g.colorize(sess, "broadcast", + fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) + + g.CancelAction(p) + g.teleportPlayer(sess, p, startRoom) + } else { + g.CancelAction(p) + g.teleportPlayer(sess, p, nextRoom) + } + + g.AccountStore.SaveCharacter(p) + return + } + + data["phase"] = nextPhase + p.Action.WaitLeft = engine.ToTicks(ticksPerPhase) +} + +func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string]any) { + startRoom := data["start_room"].(int) + failMin := data["fail_damage_min"].(int) + failMax := data["fail_damage_max"].(int) + + damage := failMin + if failMax > failMin { + damage = failMin + rand.Intn(failMax-failMin+1) + } + + sess.WriteLine(g.colorize(sess, "damage", "You slip and fall!")) + + p.HP -= damage + if p.HP < 1 { + p.HP = 1 + } + sess.WriteLine(g.colorize(sess, "damage", + fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) + + g.AccountStore.SaveCharacter(p) + g.CancelAction(p) + g.teleportPlayer(sess, p, startRoom) +} + +func (g *Game) calcFailChance(p *player.Player, info *ObstacleInfo) float64 { + level := p.Level(player.Agility) + required := info.RequiredLevel + chance := 0.30 - float64(level-required)*0.01 + if chance < 0.05 { + chance = 0.05 + } + if chance > 0.60 { + chance = 0.60 + } + return chance +} diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index 0b7db2c..0230f24 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -107,6 +107,14 @@ func (g *Game) startBurn(sess *net.Session, p *player.Player, itemID string, fro p.ActionState = &ActionState{Type: ActionBurning} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts building a fire.", p.Name))) + } + } + } + p.Action = &action.Action{ Type: "burn", TargetID: itemID, @@ -145,6 +153,14 @@ func (g *Game) startStoke(sess *net.Session, p *player.Player, itemID string) { p.ActionState = &ActionState{Type: ActionStoking} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s tends to the fire.", p.Name))) + } + } + } + p.Action = &action.Action{ Type: "stoke", TargetID: itemID, diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go index 25f2966..ddb4d2c 100644 --- a/internal/game/action_fletch.go +++ b/internal/game/action_fletch.go @@ -119,6 +119,14 @@ func (g *Game) startTimedFletch(sess *net.Session, p *player.Player, recipe *act } p.BackgroundActionState = &ActionState{Type: ActionFletching, TargetName: outputName} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts fletching.", p.Name))) + } + } + } + sess.WriteLine(fmt.Sprintf("\nYou begin fletching %s.", outputName)) } diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index 8b45129..edc8545 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -147,6 +147,14 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } p.ActionState = &ActionState{Type: ActionGathering, TargetName: obj.Name, ToolName: toolName, Verb: verb} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s the %s.", p.Name, verb, obj.Name))) + } + } + } + data := map[string]any{ "behavior_id": obj.BehaviorID, "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), diff --git a/internal/game/action_production.go b/internal/game/action_production.go index c44f3d6..788cadb 100644 --- a/internal/game/action_production.go +++ b/internal/game/action_production.go @@ -27,13 +27,14 @@ type productionTypeInfo struct { } var productionTypes = map[string]productionTypeInfo{ - "cooking": {"cook", "cooking"}, - "smelting": {"smelt", "smelting"}, - "smithing": {"smith", "smithing"}, - "crafting": {"craft", "crafting"}, - "combine": {"combine", "combining"}, - "fletching": {"fletch", "fletching"}, - "pharmacy": {"mix", "mixing"}, + "cooking": {"cook", "cooking"}, + "smelting": {"smelt", "smelting"}, + "smithing": {"smith", "smithing"}, + "crafting": {"craft", "crafting"}, + "combine": {"combine", "combining"}, + "fletching": {"fletch", "fletching"}, + "pharmacy": {"mix", "mixing"}, + "construction": {"construct", "constructing"}, } var productionActionTypes map[string]bool @@ -253,6 +254,14 @@ func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *acti } p.ActionState = &ActionState{Type: ActionProducing, TargetName: outputName, Verb: displayVerb} + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts %s.", p.Name, displayVerb))) + } + } + } } func (g *Game) startProductionFromRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { diff --git a/internal/game/action_search.go b/internal/game/action_search.go index d45066d..884157e 100644 --- a/internal/game/action_search.go +++ b/internal/game/action_search.go @@ -34,6 +34,14 @@ func (g *Game) startSearch(sess *net.Session, p *player.Player, itemID string, s } p.ActionState = &ActionState{Type: ActionSearching, TargetName: def.Name} + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s searches a %s.", p.Name, def.Name))) + } + } + } } func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { diff --git a/internal/game/action_state.go b/internal/game/action_state.go index 61288c9..55d3392 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -31,7 +31,9 @@ const ( ActionHarvesting ActionType = "harvesting_crop" ActionRaking ActionType = "raking" ActionWatering ActionType = "watering" - ActionCuring ActionType = "curing" + ActionCuring ActionType = "curing" + ActionTraversing ActionType = "traversing" + ActionHacking ActionType = "hacking" ) type ActionState struct { @@ -103,6 +105,10 @@ func (a *ActionState) Description() string { return "watering a " + a.TargetName case ActionCuring: return "curing a " + a.TargetName + case ActionTraversing: + return a.Verb + " across " + a.TargetName + case ActionHacking: + return "jacked into a " + a.TargetName } return "" } diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go index dac90f2..5347c2a 100644 --- a/internal/game/action_steal.go +++ b/internal/game/action_steal.go @@ -221,6 +221,14 @@ func (g *Game) startSteal(sess *net.Session, p *player.Player, targetType string sess.WriteLine(fmt.Sprintf("You attempt to steal from the %s...", targetName)) p.ActionState = &ActionState{Type: ActionStealing, TargetName: targetName} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s glances around the room.", p.Name))) + } + } + } + p.Action = &action.Action{ Type: "steal", TargetID: targetID, diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index 1a21e7f..c18e254 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -106,6 +106,13 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { return } + if p.Action.Data != nil { + if bid, ok := p.Action.Data["behavior_id"].(string); ok && bid == "estate_directory_talk" { + g.handleEstateDirectoryTalk(sess, input) + return + } + } + input = strings.TrimSpace(input) lower := strings.ToLower(input) if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { @@ -263,4 +270,73 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.AccountStore.SaveCharacter(p) } } + if na.Sawmill { + g.processSawmill(sess, p) + } +} + +func (g *Game) processSawmill(sess *net.Session, p *player.Player) { + logTypes := map[string]struct { + plankID string + cost int + name string + }{ + "logs": {"planks", 5, "regular planks"}, + "oak_logs": {"oak_planks", 10, "oak planks"}, + "teak_logs": {"teak_planks", 20, "teak planks"}, + "mahogany_logs": {"mahogany_planks", 40, "mahogany planks"}, + } + + totalCost := 0 + totalPlanks := 0 + type plankResult struct { + plankID string + qty int + } + + var results []plankResult + + for logID, info := range logTypes { + qty := p.CountItem(logID) + if qty == 0 { + continue + } + cost := qty * info.cost + totalCost += cost + totalPlanks += qty + results = append(results, plankResult{info.plankID, qty}) + p.RemoveItem(logID, qty) + } + + if totalPlanks == 0 { + sess.WriteLine("You don't have any logs to process.") + return + } + + if p.Credits < totalCost { + sess.WriteLine(fmt.Sprintf("You need %d credits to process all your logs (you have %d).", totalCost, p.Credits)) + return + } + + p.Credits -= totalCost + + processed := 0 + for _, r := range results { + for i := 0; i < r.qty; i++ { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, r.plankID, r.qty-i) + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: r.plankID, Quantity: 1}) + processed++ + } + } + + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("The sawmill operator processes your logs into %d planks for %d credits.", processed, totalCost)) + if processed < totalPlanks { + sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", totalPlanks-processed)) + } } diff --git a/internal/game/action_use.go b/internal/game/action_use.go index b71ec17..78bf028 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -36,6 +36,14 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD p.ActionState = &ActionState{Type: ActionUsing, TargetName: obj.Name} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name))) + } + } + } + p.Action = &action.Action{ Type: "use", TargetID: obj.ID, diff --git a/internal/game/buff.go b/internal/game/buff.go new file mode 100644 index 0000000..39abd8a --- /dev/null +++ b/internal/game/buff.go @@ -0,0 +1,55 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) BuffTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + if sess.Player == nil { + continue + } + p := sess.Player.(*player.Player) + remaining := make([]player.PotionBuff, 0, len(p.ActiveBuffs)) + dirty := false + for _, buff := range p.ActiveBuffs { + buff.TicksLeft-- + if buff.TicksLeft > 0 { + remaining = append(remaining, buff) + } else { + dirty = true + } + } + if dirty { + p.ActiveBuffs = remaining + sess.WriteLine(g.colorize(sess, "broadcast", "\nYour potion buff has worn off.")) + } + } +} + +func (g *Game) buffLevelBonus(p *player.Player, stat string) int { + if g.Hub == nil { + return 0 + } + totalPercent := 0 + for _, buff := range p.ActiveBuffs { + if strings.EqualFold(buff.Stat, stat) { + totalPercent += buff.BonusPercent + } + } + if totalPercent == 0 { + return 0 + } + baseLevel := p.Level(player.SkillName(strings.ToLower(stat))) + return baseLevel * totalPercent / 100 +} + +func (g *Game) totalBuffedLevel(sess *net.Session, p *player.Player, stat string) int { + return p.Level(player.SkillName(strings.ToLower(stat))) + g.buffLevelBonus(p, stat) +} diff --git a/internal/game/cmd_agility.go b/internal/game/cmd_agility.go new file mode 100644 index 0000000..3bd777f --- /dev/null +++ b/internal/game/cmd_agility.go @@ -0,0 +1,37 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doObstacle(sess *net.Session, verb string) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + info := g.CourseStore.GetObstacle(p.RoomID) + if info == nil || info.Verb != verb { + sess.WriteLine("You can't do that here.") + return + } + + agilityLevel := p.Level(player.Agility) + if agilityLevel < info.RequiredLevel { + sess.WriteLine(fmt.Sprintf("You need level %d agility to attempt this course.", info.RequiredLevel)) + return + } + + if p.Action != nil { + g.CancelAction(p) + } + g.CancelBackgroundAction(p) + + g.startObstacle(sess, p, info) +} diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index a8dc5d3..77cd569 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -164,6 +164,14 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn } p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s attacks %s!", p.Name, mobDisplayName(mob, true)))) + } + } + } + g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool { cs := combat.GetCombat(p.Name) if cs == nil || !cs.Active { @@ -243,7 +251,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI if isRanged { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) equipAttack := totals.RangedAttack - effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged") attRoll = combat.AttackRoll(effectiveRanged, rangedBonus, equipAttack) mobDefBonus := combat.SelectDefenseBonus(attackType, @@ -256,14 +264,14 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI equipAttack := combat.SelectAttackBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) - effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + effectiveAttack := p.Level(player.Attack) + g.techLevelBonus(p, "attack") + g.buffLevelBonus(p, "attack") attRoll = combat.AttackRoll(effectiveAttack, attBonus, equipAttack) mobDefBonus := combat.SelectDefenseBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) + maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) } if combat.HitCheck(attRoll, defRoll) { @@ -362,7 +370,7 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst equipDef := combat.SelectDefenseBonus(mobAttackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) - defRoll := combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense"), defStyleBonus, equipDef) + defRoll := combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) if combat.HitCheck(attRoll, defRoll) { maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go new file mode 100644 index 0000000..ff3a3ac --- /dev/null +++ b/internal/game/cmd_bank.go @@ -0,0 +1,345 @@ +package game + +import ( + "fmt" + "sort" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) handleBankInput(sess *net.Session, input string) { + if sess.Player == nil { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + input = strings.TrimSpace(input) + parts := strings.Fields(strings.ToLower(input)) + if len(parts) == 0 { + g.showBankBrowse(sess) + return + } + + cmd := parts[0] + + switch cmd { + case "deposit": + if len(parts) < 2 { + sess.WriteLine("Deposit what?") + } else if parts[1] == "all" { + g.doBankDepositAll(sess) + } else { + g.doBankDeposit(sess, strings.Join(parts[1:], " ")) + } + case "withdraw": + if len(parts) < 2 { + sess.WriteLine("Withdraw what?") + } else { + g.doBankWithdraw(sess, strings.Join(parts[1:], " ")) + } + case "browse", "list": + g.showBankBrowse(sess) + case "leave", "bye", "exit", "quit": + g.leaveBank(sess) + return + default: + sess.WriteLine("Commands: deposit , deposit all, withdraw , browse, leave") + } + g.writeBankPrompt(sess) +} + +func (g *Game) writeBankPrompt(sess *net.Session) { + sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "Bank> "))) +} + +func (g *Game) showBankBrowse(sess *net.Session) { + p, _ := sess.Player.(*player.Player) + + unicode := p.OptionBool("unicode") + + t := Table{ + Title: "Bank Contents", + Columns: []string{"#", "Item", "Quantity"}, + } + + type bankEntry struct { + slot int + name string + id string + qty int + } + + var entries []bankEntry + for _, i := range p.BankSlots() { + slot := p.BankSlot(i) + if slot == nil { + continue + } + def, _ := g.ItemStore.Load(slot.ItemID) + itemName := slot.ItemID + if def != nil { + itemName = def.Name + } + entries = append(entries, bankEntry{slot: i, name: itemName, id: slot.ItemID, qty: slot.Quantity}) + } + + if len(entries) == 0 { + sess.WriteLine("\nYour bank is empty.") + return + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].slot < entries[j].slot + }) + + for _, e := range entries { + t.Rows = append(t.Rows, []string{ + fmt.Sprintf("%d", e.slot+1), + g.colorize(sess, "item", e.name), + fmt.Sprintf("%d", e.qty), + }) + } + + for _, line := range t.Render(unicode) { + sess.WriteLine(line) + } +} + +func (g *Game) doBankDeposit(sess *net.Session, input string) { + p, _ := sess.Player.(*player.Player) + + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine("You don't have that item.") + return + } + + if len(matches) > 1 { + g.showWhichOne(sess, matches) + return + } + + match := matches[0] + + slot := p.InvSlot(match.Slot) + if slot == nil { + return + } + + itemID := slot.ItemID + qty := slot.Quantity + def, _ := g.ItemStore.Load(itemID) + + p.SetInvSlot(match.Slot, nil) + + bankIdx := p.NextBankSlot() + p.SetBankSlot(bankIdx, &player.InventorySlot{ + ItemID: itemID, + Quantity: qty, + Quality: slot.Quality, + }) + + g.AccountStore.SaveCharacter(p) + + displayName := itemID + if def != nil { + displayName = def.Name + } + itemColor := g.itemColorize(sess, def, displayName) + if qty > 1 { + sess.WriteLine(fmt.Sprintf("You deposit %d %ss.", qty, itemColor)) + } else { + sess.WriteLine(fmt.Sprintf("You deposit a %s.", itemColor)) + } +} + +func (g *Game) doBankDepositAll(sess *net.Session) { + p, _ := sess.Player.(*player.Player) + + var deposited int + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + bankIdx := p.NextBankSlot() + p.SetBankSlot(bankIdx, &player.InventorySlot{ + ItemID: slot.ItemID, + Quantity: slot.Quantity, + Quality: slot.Quality, + }) + p.SetInvSlot(i, nil) + deposited++ + } + + if deposited == 0 { + sess.WriteLine("Your inventory is already empty.") + return + } + + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You deposit %d item%s.", deposited, plural(deposited))) +} + +func (g *Game) doBankWithdraw(sess *net.Session, input string) { + p, _ := sess.Player.(*player.Player) + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + var entries []struct { + slot int + name string + id string + qty int + } + + for _, i := range p.BankSlots() { + bs := p.BankSlot(i) + if bs == nil { + continue + } + def, _ := g.ItemStore.Load(bs.ItemID) + itemName := bs.ItemID + if def != nil { + itemName = def.Name + } + entries = append(entries, struct { + slot int + name string + id string + qty int + }{slot: i, name: itemName, id: bs.ItemID, qty: bs.Quantity}) + } + + idx, err := parseChoiceIndex(input) + if err == nil && idx > 0 && idx <= len(entries) { + e := entries[idx-1] + g.withdrawBankItem(sess, p, e.slot) + return + } + + var matches []struct { + slot int + name string + id string + qty int + } + lower := strings.ToLower(input) + for _, e := range entries { + if world.WordPrefixMatch(e.name, lower) { + matches = append(matches, e) + } + } + + if len(matches) == 1 { + g.withdrawBankItem(sess, p, matches[0].slot) + return + } + + for _, e := range entries { + def, _ := g.ItemStore.Load(e.id) + if def != nil && def.MatchesName(input) { + matches = append(matches, e) + } + } + + if len(matches) == 0 { + sess.WriteLine("That item isn't in your bank. Use 'browse' to see your bank contents.") + return + } + + if len(matches) > 1 { + var names []string + for _, m := range matches { + names = append(names, m.name) + } + sess.WriteLine("That's ambiguous, which one?") + for _, n := range names { + sess.WriteLine(fmt.Sprintf(" - %s", n)) + } + return + } + + g.withdrawBankItem(sess, p, matches[0].slot) +} + +func (g *Game) withdrawBankItem(sess *net.Session, p *player.Player, bankSlotIdx int) { + bs := p.BankSlot(bankSlotIdx) + if bs == nil { + return + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + itemID := bs.ItemID + qty := bs.Quantity + def, _ := g.ItemStore.Load(itemID) + + if def != nil && def.Stackable { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + } else { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: 1}) + if qty > 1 { + bs.Quantity-- + g.AccountStore.SaveCharacter(p) + displayName := itemID + if def != nil { + displayName = def.Name + } + itemColor := g.itemColorize(sess, def, displayName) + sess.WriteLine(fmt.Sprintf("You withdraw a %s.", itemColor)) + return + } + } + + p.SetBankSlot(bankSlotIdx, nil) + g.AccountStore.SaveCharacter(p) + + displayName := itemID + if def != nil { + displayName = def.Name + } + itemColor := g.itemColorize(sess, def, displayName) + if qty > 1 { + sess.WriteLine(fmt.Sprintf("You withdraw %d %ss.", qty, itemColor)) + } else { + sess.WriteLine(fmt.Sprintf("You withdraw a %s.", itemColor)) + } +} + +func (g *Game) canUseBank(p *player.Player) bool { + instances := g.World.FindObjInstances(p.RoomID, "bank booth") + return len(instances) > 0 +} + +func (g *Game) doBank(sess *net.Session) { + p, _ := sess.Player.(*player.Player) + + if !g.canUseBank(p) { + sess.WriteLine("There is no bank here.") + return + } + + sess.State = net.StateBank + sess.WriteLine(g.colorize(sess, "dialog", "\nYou access the bank terminal.")) + g.showBankBrowse(sess) + g.writeBankPrompt(sess) +} + +func (g *Game) leaveBank(sess *net.Session) { + sess.State = net.StateGame + g.writePrompt(sess) +} diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go index b3d8408..70dedb9 100644 --- a/internal/game/cmd_clean.go +++ b/internal/game/cmd_clean.go @@ -1,6 +1,7 @@ package game import ( + "fmt" "strings" "thehouseoficarus/internal/action" @@ -62,6 +63,14 @@ func (g *Game) doClean(sess *net.Session, input string) { } p.BackgroundActionState = &ActionState{Type: ActionCleaning} + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s starts cleaning herbs.", p.Name))) + } + } + } + sess.WriteLine("\nYou begin cleaning herbs.") } diff --git a/internal/game/cmd_construct.go b/internal/game/cmd_construct.go new file mode 100644 index 0000000..4ab6409 --- /dev/null +++ b/internal/game/cmd_construct.go @@ -0,0 +1,157 @@ +package game + +import ( + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) doConstruct(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + g.CancelAction(p) + + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + sess.WriteLine("Error loading recipes.") + return + } + + var constructRecipes []action.RecipeDef + for _, r := range allRecipes { + if r.Type != "construction" { + continue + } + if !g.constructRecipeVisible(p, &r) { + continue + } + constructRecipes = append(constructRecipes, r) + } + + if input != "" { + g.doConstructWithInput(sess, p, constructRecipes, input) + return + } + + lastRecipeID, _ := p.Flags["last_construct"].(string) + if lastRecipeID != "" { + for i := range constructRecipes { + if constructRecipes[i].ID == lastRecipeID { + r := &constructRecipes[i] + if g.canDoConstructRecipe(p, r) { + g.startConstructRecipe(sess, p, r, 0) + return + } + break + } + } + } + + available := g.availableConstructRecipes(p, constructRecipes) + if len(available) == 0 { + sess.WriteLine("You don't have any materials to construct.") + return + } + + if p.OptionBool("construct_all") && len(available) == 1 { + g.startConstructRecipe(sess, p, &available[0], 0) + return + } + + g.showProductionTable(sess, p, available, "Construction", "construction", "last_construct", "Construct", false) +} + +func (g *Game) doConstructWithInput(sess *net.Session, p *player.Player, constructRecipes []action.RecipeDef, input string) { + qty, productName := parseQty(input) + productName = strings.TrimSpace(productName) + + var matched []action.RecipeDef + for _, r := range constructRecipes { + outDef, _ := g.ItemStore.Load(r.Output) + name := r.Output + if outDef != nil { + name = outDef.Name + } + if world.WordPrefixMatch(productName, name) { + matched = append(matched, r) + } + } + + if len(matched) == 0 { + sess.WriteLine("You can't construct that.") + return + } + + var available []action.RecipeDef + for _, r := range matched { + if g.canDoConstructRecipe(p, &r) { + available = append(available, r) + } + } + + if len(available) == 0 { + sess.WriteLine("You don't have the materials or level for that.") + return + } + + if len(available) == 1 { + g.startConstructRecipe(sess, p, &available[0], qty) + return + } + + g.showProductionTable(sess, p, available, "Construction", "construction", "last_construct", "Construct", false) +} + +func (g *Game) constructRecipeVisible(p *player.Player, r *action.RecipeDef) bool { + if len(r.Station) > 0 { + stID, _ := g.findStation(p.RoomID, r.Station) + if stID == "" { + return false + } + } + if r.Tool != "" && !g.hasToolType(p, r.Tool) { + return false + } + return true +} + +func (g *Game) canDoConstructRecipe(p *player.Player, r *action.RecipeDef) bool { + if p.Level(player.Construction) < r.Level { + return false + } + if !r.HasAllItemsQty(p.CountItem) { + return false + } + if len(r.Station) > 0 { + stID, _ := g.findStation(p.RoomID, r.Station) + if stID == "" { + return false + } + } + if r.Tool != "" && !g.hasToolType(p, r.Tool) { + return false + } + return true +} + +func (g *Game) availableConstructRecipes(p *player.Player, allConstruct []action.RecipeDef) []action.RecipeDef { + var result []action.RecipeDef + for _, r := range allConstruct { + if r.HasAllItemsQty(p.CountItem) { + result = append(result, r) + } + } + return result +} + +func (g *Game) startConstructRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { + if p.Flags == nil { + p.Flags = make(map[string]any) + } + p.Flags["last_construct"] = recipe.ID + g.AccountStore.SaveCharacter(p) + + g.startProductionFromRecipe(sess, p, recipe, count) +} diff --git a/internal/game/cmd_drink.go b/internal/game/cmd_drink.go new file mode 100644 index 0000000..c7f9b34 --- /dev/null +++ b/internal/game/cmd_drink.go @@ -0,0 +1,150 @@ +package game + +import ( + "fmt" + "strings" + "time" + + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" +) + +func (g *Game) doDrink(sess *net.Session, args []string) { + p := sess.Player.(*player.Player) + + if len(args) == 0 { + sess.WriteLine("Drink what?") + return + } + + input := strings.Join(args, " ") + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) + return + } + + unique := uniqueItemNames(matches) + if len(unique) > 1 { + g.showWhichOne(sess, matches) + return + } + + itemID := matches[0].ID + def, _ := g.ItemStore.Load(itemID) + + if def == nil { + sess.WriteLine("Something went wrong.") + return + } + + if def.PotionEffect == "" && def.HealValue <= 0 { + sess.WriteLine(fmt.Sprintf("You can't drink the %s.", g.itemColorize(sess, def, def.Name))) + return + } + + if p.ConsumeCooldown > 0 { + sess.WriteLine("You're still recovering from your last drink.") + return + } + + g.cancelRest(p.Name) + + if p.Action == nil && len(p.WalkSequence) == 0 { + g.applyPotion(sess, p, itemID, def) + return + } + + g.CancelAction(p) + + g.consumeQueue[p.Name] = &QueuedCommand{ + Session: sess, + Command: "drink", + Args: itemID, + Timestamp: time.Now(), + } + + if !p.OptionBool("queue_silently") { + sess.WriteLine(fmt.Sprintf("\nYou prepare to drink the %s.", g.itemColorize(sess, def, def.Name))) + } +} + +func (g *Game) applyPotion(sess *net.Session, p *player.Player, itemID string, def *object.ItemDef) { + if def == nil { + var err error + def, err = g.ItemStore.Load(itemID) + if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) { + return + } + } + + p.RemoveItem(itemID, 1) + + effect := strings.ToLower(def.PotionEffect) + + switch effect { + case "battery": + batteryRestore := def.PotionBonus + p.Battery += float64(batteryRestore) + if p.Battery > 100 { + p.Battery = 100 + } + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your battery is restored by %d%%.", + g.itemColorize(sess, def, def.Name), batteryRestore)) + + case "heal", "": + healAmount := def.PotionBonus + if healAmount <= 0 { + healAmount = def.HealValue + } + if healAmount <= 0 { + healAmount = 50 + } + before := p.HP + maxHP := p.Level(player.Hitpoints) + if p.HP+healAmount > maxHP { + p.HP = maxHP + } else { + p.HP += healAmount + } + actualHeal := p.HP - before + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. You restore %d hitpoints.", + g.itemColorize(sess, def, def.Name), actualHeal)) + + case "all_combat": + duration := engine.ToTicks(def.PotionDuration) + buffs := []string{"attack", "strength", "defense"} + for _, stat := range buffs { + p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ + Stat: stat, + BonusPercent: def.PotionBonus, + TicksLeft: duration, + }) + } + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your combat stats are boosted by %d%% for %d ticks.", + g.itemColorize(sess, def, def.Name), def.PotionBonus, duration)) + + default: + duration := engine.ToTicks(def.PotionDuration) + p.ActiveBuffs = append(p.ActiveBuffs, player.PotionBuff{ + Stat: effect, + BonusPercent: def.PotionBonus, + TicksLeft: duration, + }) + sess.WriteLine(fmt.Sprintf("\nYou drink the %s. Your %s is boosted by %d%% for %d ticks.", + g.itemColorize(sess, def, def.Name), effect, def.PotionBonus, duration)) + } + + p.ConsumeCooldown = 3 + p.ActionState = &ActionState{Type: ActionEating} + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s drinks a %s.", p.Name, def.Name))) + } + } + } +} diff --git a/internal/game/cmd_eat.go b/internal/game/cmd_eat.go index 6a74979..e6a0386 100644 --- a/internal/game/cmd_eat.go +++ b/internal/game/cmd_eat.go @@ -111,6 +111,22 @@ func (g *Game) processConsumeQueue(p *player.Player, sess *net.Session) bool { itemID := qc.Args def, err := g.ItemStore.Load(itemID) + + if qc.Command == "drink" { + if err != nil || (def.PotionEffect == "" && def.HealValue <= 0) { + return false + } + if p.ConsumeCooldown > 0 { + return false + } + if !p.HasItem(itemID) { + sess.WriteLine("You no longer have that to drink.") + return false + } + g.applyPotion(sess, p, itemID, def) + return true + } + if err != nil || def.EatMessage == "" { return false } diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go new file mode 100644 index 0000000..6c66ca4 --- /dev/null +++ b/internal/game/cmd_estate_directory.go @@ -0,0 +1,223 @@ +package game + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + + "gopkg.in/yaml.v3" +) + +func (g *Game) startEstateDirectory(sess *net.Session, verb string) { + p := sess.Player.(*player.Player) + + if verb == "toggle" { + sess.WriteLine("You can't do that with the directory.") + return + } + + homeowners := g.scanHomeowners("owns_house_local_neighborhood") + ownedByMe := false + for _, h := range homeowners { + if h == p.Name { + ownedByMe = true + break + } + } + + if verb == "talk" { + g.showEstateDirectoryTalk(sess, p, homeowners, ownedByMe) + return + } + + if verb == "use" { + if ownedByMe { + houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood") + if houseRoom > 0 { + g.CancelAction(p) + g.teleportPlayer(sess, p, houseRoom) + return + } + } + sess.WriteLine("You don't own a house here. Talk to the estate broker to purchase one.") + return + } +} + +func (g *Game) lookEstateDirectory(sess *net.Session) { + homeowners := g.scanHomeowners("owns_house_local_neighborhood") + sess.WriteLine("") + sess.WriteLine(g.colorize(sess, "dialog", "Estate Directory")) + sess.WriteLine(fmt.Sprintf(" A holographic terminal displaying property records for the Local Neighborhood.")) + sess.WriteLine("") + if len(homeowners) == 0 { + sess.WriteLine(" No registered homeowners in this area.") + } else { + sess.WriteLine(" Registered homeowners:") + for i, name := range homeowners { + sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, name)) + } + } + sess.WriteLine("") + sess.WriteLine(" Use the directory to enter your home, or talk to the estate broker to purchase.") +} + +func (g *Game) showEstateDirectoryTalk(sess *net.Session, p *player.Player, homeowners []string, ownedByMe bool) { + sess.WriteLine("") + sess.WriteLine(g.colorize(sess, "dialog", "The directory terminal hums quietly, displaying a list of registered homeowners...")) + + if ownedByMe { + sess.WriteLine("") + sess.WriteLine(" You are a registered homeowner in this neighborhood.") + sess.WriteLine(" Type 'enter' to go to your house, or 'leave' to step away.") + sess.State = net.StateTalk + p.ActionState = &ActionState{Type: ActionTalking, TargetName: "Estate Directory"} + p.Action = &action.Action{ + Type: "talk", + TargetID: "estate_directory", + TargetName: "Estate Directory", + Data: map[string]any{"node": "start", "behavior_id": "estate_directory_talk"}, + } + sess.Write("\nChoice: ") + return + } + + sess.WriteLine("") + if len(homeowners) > 0 { + sess.WriteLine(" Currently owned by: " + strings.Join(homeowners, ", ")) + } + sess.WriteLine(" You don't own a house here yet.") + sess.WriteLine(" Talk to the estate broker to purchase a plot for 10 credits.") +} + +func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { + input = strings.TrimSpace(strings.ToLower(input)) + + switch input { + case "enter": + p, ok := sess.Player.(*player.Player) + if !ok { + return + } + houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood") + if houseRoom > 0 { + sess.State = net.StateGame + p.Action = nil + p.ActionState = nil + g.teleportPlayer(sess, p, houseRoom) + return + } + sess.State = net.StateGame + p.Action = nil + p.ActionState = nil + g.writePrompt(sess) + case "leave", "bye", "exit", "quit": + p, ok := sess.Player.(*player.Player) + if ok { + p.Action = nil + p.ActionState = nil + } + sess.State = net.StateGame + g.writePrompt(sess) + default: + sess.WriteLine("Type 'enter' to go to your house, or 'leave' to step away.") + sess.Write("\nChoice: ") + } +} + +func (g *Game) scanHomeowners(flagKey string) []string { + var homeowners []string + + charsDir := filepath.Join(g.dataDir, "players", "characters") + entries, err := os.ReadDir(charsDir) + if err != nil { + return homeowners + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + + name := strings.TrimSuffix(entry.Name(), ".yaml") + data, err := os.ReadFile(filepath.Join(charsDir, entry.Name())) + if err != nil { + continue + } + + var p struct { + Flags map[string]any `yaml:"flags"` + } + if err := yaml.Unmarshal(data, &p); err != nil { + continue + } + + if p.Flags != nil { + if val, ok := p.Flags[flagKey]; ok && val != nil { + owned := false + switch v := val.(type) { + case bool: + owned = v + case string: + owned = v != "" && v != "false" + } + if owned { + homeowners = append(homeowners, name) + } + } + } + } + + sort.Strings(homeowners) + return homeowners +} + +func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int { + if p.Flags == nil { + return 0 + } + val, ok := p.Flags[flagKey] + if !ok || val == nil { + return 0 + } + + v, ok := val.(string) + if !ok || v == "" { + return 0 + } + + houseMap := map[string]int{ + "local_neighborhood": 200, + } + return houseMap[v] +} + +func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) { + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s disappears in a shimmer of light.", p.Name)) + } + } + } + + p.RoomID = roomID + if g.Hub != nil { + g.Hub.LeaveRoom(sess) + g.Hub.EnterRoom(sess, p.RoomID) + } + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.seedRoomObjects(p.RoomID) + g.AccountStore.SaveCharacter(p) + g.doLook(sess) + g.RunEnterSteps(sess, p.RoomID) + g.writePrompt(sess) +} diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go new file mode 100644 index 0000000..db157f3 --- /dev/null +++ b/internal/game/cmd_jack.go @@ -0,0 +1,83 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doJack(sess *net.Session, target string) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if target == "" { + target = g.findTerminalInRoom(p.RoomID) + if target == "" { + sess.WriteLine("There's no terminal here to jack into.") + return + } + } + + instances := g.World.FindObjInstances(p.RoomID, strings.ToLower(target)) + if len(instances) == 0 { + sess.WriteLine("You don't see that here.") + return + } + + defID := instances[0].DefID + tDef, ok := terminalDefs[defID] + if !ok { + sess.WriteLine("You can't jack into that.") + return + } + + hackLevel := p.Level(player.Hacking) + if hackLevel < tDef.Level { + sess.WriteLine(fmt.Sprintf("You need level %d Hacking to use this terminal.", tDef.Level)) + return + } + + g.CancelAction(p) + g.CancelBackgroundAction(p) + + minigame := tDef.Minigame() + display := minigame.Init(hackLevel) + + g.hackingStates[p.Name] = &HackingSession{ + Minigame: minigame, + TerminalID: defID, + Level: hackLevel, + ReqLevel: tDef.Level, + Started: false, + } + + p.ActionState = &ActionState{Type: ActionHacking, TargetName: tDef.Name} + sess.State = net.StateHacking + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s jacks into a terminal.", p.Name)) + } + } + } + + sess.WriteLine(display) + sess.Write("\nhack> ") +} + +func (g *Game) findTerminalInRoom(roomID int) string { + for _, st := range g.World.FindObjInstances(roomID, "") { + if strings.HasPrefix(st.DefID, "terminal_") { + return st.Name + } + } + return "" +} diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 4abd89e..6c83130 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -480,6 +480,12 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { if len(instances) > 0 { st := &instances[0] def, _ := g.ObjectStore.Load(st.DefID) + + if st.DefID == "estate_directory" { + g.lookEstateDirectory(sess) + return + } + sess.WriteLine("") if len(instances) > 1 { sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name)) diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 89b6e7f..2ec0221 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -1,6 +1,8 @@ package game import ( + "fmt" + "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" @@ -19,6 +21,14 @@ func (g *Game) doQuit(sess *net.Session) { p.ActionState = &ActionState{Type: ActionResting} g.cancelRest(p.Name) + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s sits down to rest.", p.Name))) + } + } + } + ticksLeft := 10 id := g.Ticks.Subscribe(engine.ToTicks(1), func() bool { switch ticksLeft { diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 2005903..82c0803 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -57,4 +57,17 @@ func (g *Game) doScore(sess *net.Session) { } } } + + if len(p.ActiveBuffs) > 0 { + sess.WriteLine("") + sess.WriteLine("Active Buffs:") + for _, buff := range p.ActiveBuffs { + sess.WriteLine(fmt.Sprintf(" %s +%d%% %s (%d ticks)", + color.Render(mode, color.Parse("82"), "[BUFF]"), + buff.BonusPercent, + color.Render(mode, color.Parse("75"), buff.Stat), + buff.TicksLeft, + )) + } + } } diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index 8e3a84e..7ef4913 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -228,6 +228,8 @@ func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, g.triggerEmGrab(sess, p, mod, targetArg) case "superheat": g.triggerSuperheat(sess, p, mod, targetArg) + case "plank_make": + g.triggerPlankMake(sess, p, mod, targetArg) } } @@ -440,6 +442,128 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef } } +func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + logTypes := map[string]struct { + plankID string + name string + cost int + }{ + "logs": {"planks", "planks", 3}, + "oak_logs": {"oak_planks", "oak planks", 7}, + "teak_logs": {"teak_planks", "teak planks", 14}, + "mahogany_logs": {"mahogany_planks", "mahogany planks", 28}, + } + + if targetArg != "" { + slot, inv := g.findInventoryItem(p, targetArg) + if slot < 0 { + sess.WriteLine("You don't have that item.") + return + } + info, ok := logTypes[inv.ItemID] + if !ok { + sess.WriteLine("You can't turn that into planks.") + return + } + + if p.Credits < info.cost { + sess.WriteLine(fmt.Sprintf("You need %d credits for Plank Make (70%% of sawmill cost).", info.cost)) + return + } + + if p.Action != nil { + g.CancelAction(p) + } + + g.consumeJunkCost(p, mod) + + p.RemoveItem(inv.ItemID, 1) + p.Credits -= info.cost + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) + + sciXP := int(mod.BaseXP) + if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) + } + if newLevel := p.AddSkillXP(player.Construction, 10); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d construction! ***", p.Level(player.Construction)))) + } + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You convert the log into %s.", info.name)) + if p.OptionBool("xp_drops") { + parts := []string{fmt.Sprintf("+%dxp sci", sciXP), "+10xp con"} + sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) + } + return + } + + for logID, info := range logTypes { + qty := p.CountItem(logID) + if qty == 0 { + continue + } + + totalCost := info.cost * qty + if p.Credits < totalCost { + continue + } + + if p.Action != nil { + g.CancelAction(p) + } + + g.consumeJunkCost(p, mod) + + p.RemoveItem(logID, qty) + p.Credits -= totalCost + + processed := 0 + for i := 0; i < qty; i++ { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + g.World.AddGroundItem(p.RoomID, info.plankID, qty-i) + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: info.plankID, Quantity: 1}) + processed++ + } + + sciXP := int(mod.BaseXP) * qty + if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d science! ***", p.Level(player.Science)))) + } + conXP := 10 * qty + if newLevel := p.AddSkillXP(player.Construction, conXP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d construction! ***", p.Level(player.Construction)))) + } + g.AccountStore.SaveCharacter(p) + + sess.WriteLine(fmt.Sprintf("You convert %d logs into %s for %d credits.", processed, info.name, totalCost)) + if processed < qty { + sess.WriteLine(fmt.Sprintf("Your inventory is full. The remaining %d planks fall to the ground.", qty-processed)) + } + if p.OptionBool("xp_drops") { + parts := []string{fmt.Sprintf("+%dxp sci", sciXP), fmt.Sprintf("+%dxp con", conXP)} + sess.WriteLine(g.colorize(sess, "xp", "("+strings.Join(parts, ", ")+")")) + } + return + } + + sess.WriteLine("You don't have any logs to convert.") +} + func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { if chipInfo, ok := chipMap[mod.ID]; ok { g.triggerChipBolts(sess, p, mod, chipInfo) @@ -568,7 +692,7 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob g.consumeJunkCost(p, mod) equipSciBonus := g.totalEquipScienceAttack(p) - attRoll := (p.Level(player.Science) + 8) * (equipSciBonus + 64) + attRoll := (p.Level(player.Science) + g.buffLevelBonus(p, "science") + 8) * (equipSciBonus + 64) mobSciDef := mob.ScienceDefense defRoll := (mob.Defense + 9) * (mobSciDef + 64) diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 7d40cc2..95a7b5b 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -25,6 +25,11 @@ func (g *Game) doUse(sess *net.Session, input string) { lower := strings.ToLower(input) + if lower == "bank" || lower == "bank booth" || strings.HasPrefix(lower, "bank ") { + g.doBank(sess) + return + } + sep := "" if strings.Contains(lower, " on ") { sep = " on " diff --git a/internal/game/course.go b/internal/game/course.go new file mode 100644 index 0000000..335c97f --- /dev/null +++ b/internal/game/course.go @@ -0,0 +1,141 @@ +package game + +import ( + "os" + "path/filepath" + "sync" + + "gopkg.in/yaml.v3" +) + +type ObstacleDef struct { + RoomID int `yaml:"room_id"` + Verb string `yaml:"verb"` + TicksPerPhase float64 `yaml:"ticks_per_phase"` + XP int `yaml:"xp"` + FailDamage [2]int `yaml:"fail_damage"` + Messages []string `yaml:"messages"` +} + +type CourseConfig struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + RequiredLevel int `yaml:"required_level"` + StartRoom int `yaml:"start_room"` + CompletionXP int `yaml:"completion_xp"` + Obstacles []ObstacleDef `yaml:"obstacles"` +} + +type ObstacleInfo struct { + CourseID string + CourseName string + ObstacleIndex int + TotalObstacles int + Verb string + Messages []string + TicksPerPhase float64 + ObstacleXP int + CompletionXP int + FailDamage [2]int + NextRoom int + StartRoom int + RequiredLevel int +} + +var obstacleVerbs = map[string]bool{} + +var verbGerund = map[string]string{ + "scramble": "scrambling", + "jump": "jumping", + "swing": "swinging", + "balance": "balancing", + "climb": "climbing", + "crawl": "crawling", + "vault": "vaulting", + "leap": "leaping", + "slide": "sliding", +} + +type CourseStore struct { + dataDir string + mu sync.Mutex + courses map[string]*CourseConfig + roomToObstacle map[int]*ObstacleInfo + loaded bool +} + +func NewCourseStore(dataDir string) *CourseStore { + return &CourseStore{ + dataDir: dataDir, + courses: make(map[string]*CourseConfig), + } +} + +func (cs *CourseStore) LoadAll() { + cs.mu.Lock() + defer cs.mu.Unlock() + cs.loadAllLocked() +} + +func (cs *CourseStore) GetObstacle(roomID int) *ObstacleInfo { + cs.mu.Lock() + defer cs.mu.Unlock() + if !cs.loaded { + cs.loadAllLocked() + } + return cs.roomToObstacle[roomID] +} + +func (cs *CourseStore) loadAllLocked() { + cs.loaded = true + cs.roomToObstacle = make(map[int]*ObstacleInfo) + localVerbs := make(map[string]bool) + + pattern := filepath.Join(cs.dataDir, "courses", "*.yaml") + files, _ := filepath.Glob(pattern) + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue + } + var cfg CourseConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + continue + } + cs.courses[cfg.ID] = &cfg + + totalObstacles := len(cfg.Obstacles) + for i, obs := range cfg.Obstacles { + nextRoom := 0 + if i < totalObstacles-1 { + nextRoom = cfg.Obstacles[i+1].RoomID + } + + completionXP := 0 + if i == totalObstacles-1 { + completionXP = cfg.CompletionXP + } + + info := &ObstacleInfo{ + CourseID: cfg.ID, + CourseName: cfg.Name, + ObstacleIndex: i, + TotalObstacles: totalObstacles, + Verb: obs.Verb, + Messages: obs.Messages, + TicksPerPhase: obs.TicksPerPhase, + ObstacleXP: obs.XP, + CompletionXP: completionXP, + FailDamage: obs.FailDamage, + NextRoom: nextRoom, + StartRoom: cfg.StartRoom, + RequiredLevel: cfg.RequiredLevel, + } + cs.roomToObstacle[obs.RoomID] = info + localVerbs[obs.Verb] = true + } + } + + obstacleVerbs = localVerbs +} diff --git a/internal/game/game.go b/internal/game/game.go index e800a78..03300a4 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -41,6 +41,7 @@ type Game struct { MobStore *world.MobStore BehaviorStore *action.Store RecipeStore *action.RecipeStore + CourseStore *CourseStore Hub *net.Hub Ticks *engine.Engine WorldFlags map[string]any @@ -56,10 +57,11 @@ type Game struct { pendingDepletions []pendingDepletion guardWatchTimers map[string]int farmTickCounter int + hackingStates map[string]*HackingSession } func New(dataDir string, colorConfig *config.ColorsConfig) *Game { - return &Game{ + g := &Game{ World: world.New(dataDir), ObjectStore: object.NewObjectStore(dataDir), ItemStore: object.NewItemStore(dataDir), @@ -67,6 +69,7 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { MobStore: world.NewMobStore(dataDir), BehaviorStore: action.NewStore(dataDir), RecipeStore: action.NewRecipeStore(dataDir), + CourseStore: NewCourseStore(dataDir), Ticks: engine.New(), WorldFlags: make(map[string]any), ColorConfig: colorConfig, @@ -78,7 +81,10 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { consumeQueue: make(map[string]*QueuedCommand), pendingDepletions: nil, guardWatchTimers: make(map[string]int), + hackingStates: make(map[string]*HackingSession), } + g.CourseStore.LoadAll() + return g } func (g *Game) SetHub(hub *net.Hub) { @@ -89,6 +95,7 @@ func (g *Game) SetHub(hub *net.Hub) { g.charsMu.Lock() delete(g.loggedInChars, p.Name) g.charsMu.Unlock() + delete(g.hackingStates, p.Name) } }) } @@ -126,6 +133,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleTalkInput(sess, input) case net.StateShop: g.handleShopInput(sess, input) + case net.StateBank: + g.handleBankInput(sess, input) case net.StateDropAllConfirm: g.handleDropAllConfirm(sess, input) case net.StateRecipeChoice: @@ -136,6 +145,8 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleProductChoice(sess, input) case net.StateColorChoice: g.handleColorChoice(sess, input) + case net.StateHacking: + g.handleHackingInput(sess, input) } } @@ -160,14 +171,20 @@ func classifyCommand(cmd string) CommandClass { "id", "identify", "steal", "thieve", "trigger", "cast", - "plant", "harvest", "rake", "water", "cure": + "plant", "harvest", "rake", "water", "cure", + "bank", "construct", "make": return ClassActive - case "eat", "fletch", "clean": + case "jack", "jackin": + return ClassActive + case "eat", "fletch", "clean", "drink": return ClassFree } if _, ok := verbAliases[cmd]; ok { return ClassActive } + if obstacleVerbs[cmd] { + return ClassActive + } return ClassUnknown } @@ -404,6 +421,9 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI case "eat": g.doEat(sess, strings.Join(args, " ")) return + case "drink": + g.doDrink(sess, args) + return case "fletch": g.doFletch(sess, strings.Join(args, " ")) return @@ -512,6 +532,16 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI case "walk": g.doWalk(sess, args) return + case "bank": + g.doBank(sess) + return + case "construct", "make": + g.doConstruct(sess, strings.Join(args, " ")) + return + case "jack", "jackin": + g.CancelAction(p) + g.doJack(sess, strings.Join(args, " ")) + return case "eq", "equipment", "equip", "wear", "wield": g.doWear(sess, strings.Join(args, " ")) return @@ -519,6 +549,10 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI g.doRemove(sess, strings.Join(args, " ")) return default: + if obstacleVerbs[cmd] { + g.doObstacle(sess, cmd) + return + } if a, ok := verbAliases[cmd]; ok { g.CancelAction(p) switch a { @@ -565,7 +599,8 @@ func (g *Game) ProcessQueuedCommands() { switch as.Type { case ActionGathering, ActionCombating, ActionUsing, ActionTalking, ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring: + ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, + ActionTraversing: default: if as.Type != ActionMoving || p.MoveTicks <= 0 { p.ActionState = nil diff --git a/internal/game/hacking.go b/internal/game/hacking.go new file mode 100644 index 0000000..dfefd5c --- /dev/null +++ b/internal/game/hacking.go @@ -0,0 +1,157 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +type HackingMinigame interface { + Init(level int) string + HandleInput(input string) (output string, done bool, won bool) + Name() string +} + +type XPBonuser interface { + BonusXP() int +} + +type HackingSession struct { + Minigame HackingMinigame + TerminalID string + Level int + ReqLevel int + Started bool +} + +type TerminalDef struct { + ObjectID string + Level int + Minigame func() HackingMinigame + BaseXPWin int + BaseXPLose int + Name string +} + +var terminalDefs = map[string]TerminalDef{ + "terminal_basic": { + ObjectID: "terminal_basic", + Level: 1, + Minigame: func() HackingMinigame { return NewWumpusGame() }, + BaseXPWin: 50, + BaseXPLose: 10, + Name: "Hunt the Wumpus", + }, + "terminal_advanced": { + ObjectID: "terminal_advanced", + Level: 20, + Minigame: func() HackingMinigame { return NewMastermindGame() }, + BaseXPWin: 150, + BaseXPLose: 30, + Name: "Code Breaker", + }, + "terminal_secure": { + ObjectID: "terminal_secure", + Level: 40, + Minigame: func() HackingMinigame { return NewLiarsDiceGame() }, + BaseXPWin: 300, + BaseXPLose: 50, + Name: "Signal Bluff", + }, +} + +func (g *Game) handleHackingInput(sess *net.Session, input string) { + p, ok := sess.Player.(*player.Player) + if !ok { + sess.State = net.StateGame + return + } + + hs, ok := g.hackingStates[p.Name] + if !ok { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + input = strings.TrimSpace(input) + lower := strings.ToLower(input) + + if lower == "jack out" || lower == "quit" || lower == "disconnect" { + g.endHacking(sess, p, false, false) + return + } + + hs.Started = true + output, done, won := hs.Minigame.HandleInput(input) + + sess.WriteLine(output) + + if done { + g.endHacking(sess, p, true, won) + return + } + + sess.Write("\nhack> ") +} + +func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, won bool) { + hs, ok := g.hackingStates[p.Name] + if !ok { + sess.State = net.StateGame + g.writePrompt(sess) + return + } + + tDef := terminalDefs[hs.TerminalID] + var xp int + + if completed && won { + xp = hackingXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) + sess.WriteLine(fmt.Sprintf("\nConnection terminated. Contract complete.")) + } else if completed { + xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + sess.WriteLine(fmt.Sprintf("\nConnection lost.")) + } else if hs.Started { + xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + sess.WriteLine("\nYou jack out of the terminal.") + } else { + sess.WriteLine("\nYou jack out of the terminal.") + } + + if won { + if b, ok := hs.Minigame.(XPBonuser); ok { + bonus := b.BonusXP() + if bonus > 0 { + xp += hackingXP(bonus, hs.Level, hs.ReqLevel) + } + } + } + + if xp > 0 { + if newLevel := p.AddSkillXP(player.Hacking, xp); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", + fmt.Sprintf("*** You are now level %d hacking! ***", newLevel))) + } + if p.OptionBool("xp_drops") { + sess.WriteLine(g.colorize(sess, "xp", + fmt.Sprintf("(+%dxp %s)", xp, player.SkillAbbr[player.Hacking]))) + } + g.AccountStore.SaveCharacter(p) + } + + delete(g.hackingStates, p.Name) + p.ActionState = nil + sess.State = net.StateGame + g.writePrompt(sess) +} + +func hackingXP(baseXP int, playerLevel int, reqLevel int) int { + multiplier := 1.0 + 0.01*float64(playerLevel-reqLevel) + if multiplier < 1.0 { + multiplier = 1.0 + } + return int(float64(baseXP) * multiplier) +} diff --git a/internal/game/hacking_liars_dice.go b/internal/game/hacking_liars_dice.go new file mode 100644 index 0000000..12f76c1 --- /dev/null +++ b/internal/game/hacking_liars_dice.go @@ -0,0 +1,395 @@ +package game + +import ( + "fmt" + "math/rand" + "strconv" + "strings" +) + +type LiarsDiceGame struct { + playerDice []int + aiDice []int + currentBid LiarsBid + hasBid bool + playerTurn bool + round int + gameOver bool + won bool + playerLost int + completed bool +} + +type LiarsBid struct { + Quantity int + Face int +} + +func NewLiarsDiceGame() *LiarsDiceGame { + return &LiarsDiceGame{ + playerDice: make([]int, 5), + aiDice: make([]int, 5), + } +} + +func (l *LiarsDiceGame) Name() string { + return "Signal Bluff" +} + +func (l *LiarsDiceGame) Init(level int) string { + l.playerDice = make([]int, 5) + l.aiDice = make([]int, 5) + l.round = 0 + l.gameOver = false + l.playerTurn = true + l.hasBid = false + l.playerLost = 0 + l.completed = false + + var sb strings.Builder + sb.WriteString("=== SIGNAL BLUFF ===\n") + sb.WriteString("\n") + sb.WriteString("You've connected to a secure channel running a bluffing protocol.\n") + sb.WriteString("You and the system each have 5 signal fragments (dice). Each round,\n") + sb.WriteString("fragments are scrambled. You see only yours. Take turns making claims\n") + sb.WriteString("about the total count of a specific signal across ALL fragments.\n") + sb.WriteString("\n") + sb.WriteString("1s are wild -- they count as any signal.\n") + sb.WriteString("\n") + sb.WriteString("Commands:\n") + sb.WriteString(" bid - Claim at least N fragments show face F\n") + sb.WriteString(" (e.g., 'bid 3 4' = \"at least three 4s\")\n") + sb.WriteString(" call - Challenge the last bid (reveal all)\n") + sb.WriteString(" exact - Claim the bid is exactly right\n") + sb.WriteString(" status - Show your fragments and current bid\n") + sb.WriteString(" jack out - Disconnect (forfeit)\n") + sb.WriteString("\n") + sb.WriteString(l.startRound()) + return sb.String() +} + +func (l *LiarsDiceGame) HandleInput(input string) (string, bool, bool) { + input = strings.TrimSpace(strings.ToLower(input)) + parts := strings.Fields(input) + + if len(parts) == 0 { + return "Commands: bid , call, exact, status, jack out", false, false + } + + cmd := parts[0] + + switch cmd { + case "bid", "b": + if len(parts) < 3 { + return "Usage: bid (e.g., bid 3 4)", false, false + } + qty, err1 := strconv.Atoi(parts[1]) + face, err2 := strconv.Atoi(parts[2]) + if err1 != nil || err2 != nil { + return "Usage: bid (e.g., bid 3 4)", false, false + } + return l.doBid(qty, face) + case "call", "c", "intercept", "liar": + return l.doCall(false) + case "exact", "e": + return l.doExact() + case "status": + return l.doStatus(), false, false + default: + if len(parts) == 2 { + qty, err1 := strconv.Atoi(parts[0]) + face, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil { + return l.doBid(qty, face) + } + } + return "Commands: bid , call, exact, status, jack out", false, false + } +} + +func (l *LiarsDiceGame) doBid(qty, face int) (string, bool, bool) { + if face < 1 || face > 6 { + return "Face must be 1-6.", false, false + } + if qty < 1 { + return "Quantity must be at least 1.", false, false + } + if !l.playerTurn { + return "It's not your turn to bid. Use 'call' or 'exact'.", false, false + } + + if l.hasBid { + if qty < l.currentBid.Quantity || (qty == l.currentBid.Quantity && face <= l.currentBid.Face) { + return "You must bid higher (higher quantity or same quantity with higher face).", false, false + } + } + + l.currentBid = LiarsBid{Quantity: qty, Face: face} + l.hasBid = true + l.playerTurn = false + + return l.aiTurn() +} + +func (l *LiarsDiceGame) doCall(player bool) (string, bool, bool) { + if !l.hasBid { + return "No bid to call yet. Make a bid first.", false, false + } + + var sb strings.Builder + if player { + sb.WriteString("System intercepts!\n") + } else { + sb.WriteString("You intercept!\n") + } + + sb.WriteString(l.showAllDice()) + sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face))) + + total := l.countFace(l.currentBid.Face) + sb.WriteString(fmt.Sprintf("Actual count: %d (%d %ss + %d wild 1s)\n\n", + total, + l.countFaceNonWild(l.currentBid.Face), + faceName(l.currentBid.Face), + l.countFace(1)-l.countFaceNonWild(1))) + + if total >= l.currentBid.Quantity { + sb.WriteString("The bid holds! ") + if player { + sb.WriteString("System loses a fragment.") + l.aiDice = l.aiDice[:len(l.aiDice)-1] + } else { + sb.WriteString("You lose a fragment.") + l.playerDice = l.playerDice[:len(l.playerDice)-1] + l.playerLost++ + } + } else { + sb.WriteString("Bluff called! ") + if player { + sb.WriteString("You lose a fragment.") + l.playerDice = l.playerDice[:len(l.playerDice)-1] + l.playerLost++ + } else { + sb.WriteString("System loses a fragment.") + l.aiDice = l.aiDice[:len(l.aiDice)-1] + } + } + + return l.checkGameOver(sb) +} + +func (l *LiarsDiceGame) doExact() (string, bool, bool) { + if !l.hasBid { + return "No bid to claim exact on. Make a bid first.", false, false + } + + var sb strings.Builder + sb.WriteString("You claim exact match!\n") + + sb.WriteString(l.showAllDice()) + total := l.countFace(l.currentBid.Face) + sb.WriteString(fmt.Sprintf("\nBid was: %d %s\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face))) + sb.WriteString(fmt.Sprintf("Actual count: %d\n\n", total)) + + if total == l.currentBid.Quantity { + sb.WriteString("Exact match! You recover a fragment.\n") + if len(l.playerDice) < 5 { + l.playerDice = append(l.playerDice, rand.Intn(6)+1) + } + } else { + sb.WriteString("Not an exact match. You lose a fragment.\n") + l.playerDice = l.playerDice[:len(l.playerDice)-1] + l.playerLost++ + } + + return l.checkGameOver(sb) +} + +func (l *LiarsDiceGame) checkGameOver(sb strings.Builder) (string, bool, bool) { + if len(l.playerDice) == 0 { + sb.WriteString("\nYou've lost all your fragments. Connection terminated.") + l.gameOver = true + l.won = false + l.completed = true + return sb.String(), true, false + } + if len(l.aiDice) == 0 { + sb.WriteString("\nSystem has no fragments left. You win!") + l.gameOver = true + l.won = true + l.completed = true + return sb.String(), true, true + } + + sb.WriteString(l.startRound()) + return sb.String(), false, false +} + +func (l *LiarsDiceGame) showAllDice() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Your fragments: %s\n", l.diceStr(l.playerDice))) + sb.WriteString(fmt.Sprintf("System fragments: %s\n", l.diceStr(l.aiDice))) + return sb.String() +} + +func (l *LiarsDiceGame) diceStr(dice []int) string { + var parts []string + for _, d := range dice { + parts = append(parts, fmt.Sprintf("[%d]", d)) + } + return strings.Join(parts, " ") +} + +func (l *LiarsDiceGame) doStatus() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round)) + sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n", + l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice))) + if l.hasBid { + who := "System" + if !l.playerTurn { + who = "System" + } else { + who = "you" + } + sb.WriteString(fmt.Sprintf("Current bid: %d %s (by %s)\n", l.currentBid.Quantity, faceNamePlural(l.currentBid.Face), who)) + } + if l.playerTurn { + sb.WriteString("Your turn: make a bid.") + } else { + sb.WriteString("Your turn: bid higher, call, or exact.") + } + return sb.String() +} + +func (l *LiarsDiceGame) startRound() string { + l.round++ + for i := range l.playerDice { + l.playerDice[i] = rand.Intn(6) + 1 + } + for i := range l.aiDice { + l.aiDice[i] = rand.Intn(6) + 1 + } + l.hasBid = false + l.playerTurn = true + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- Cycle %d ---\n", l.round)) + sb.WriteString(fmt.Sprintf("Your fragments: %s (You: %d, System: %d)\n", + l.diceStr(l.playerDice), len(l.playerDice), len(l.aiDice))) + sb.WriteString("You go first. Make a bid.\n") + return sb.String() +} + +func (l *LiarsDiceGame) aiTurn() (string, bool, bool) { + totalDice := len(l.playerDice) + len(l.aiDice) + + aiCount := l.countFace(l.currentBid.Face) + unknownDice := len(l.playerDice) + estimated := float64(aiCount) + float64(unknownDice)/3.0 + + if float64(l.currentBid.Quantity) > estimated*1.5 { + return l.doCall(true) + } + + bestFace, bestCount := l.aiBestFace() + newQty := l.currentBid.Quantity + newFace := l.currentBid.Face + + if bestFace > newFace && bestCount >= newQty { + newFace = bestFace + } else { + newQty++ + } + + if newQty > totalDice { + return l.doCall(true) + } + + l.currentBid = LiarsBid{Quantity: newQty, Face: newFace} + l.playerTurn = true + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("System broadcasts: %d %s.\n", newQty, faceNamePlural(newFace))) + sb.WriteString("Your turn: bid higher, call, or exact.\n") + return sb.String(), false, false +} + +func (l *LiarsDiceGame) aiBestFace() (face int, count int) { + counts := make(map[int]int) + for _, d := range l.aiDice { + if d == 1 { + continue + } + counts[d]++ + } + wilds := 0 + for _, d := range l.aiDice { + if d == 1 { + wilds++ + } + } + best := 2 + bestN := counts[2] + wilds + for f := 3; f <= 6; f++ { + n := counts[f] + wilds + if n > bestN || (n == bestN && f > best) { + best = f + bestN = n + } + } + return best, bestN +} + +func (l *LiarsDiceGame) countFace(face int) int { + count := 0 + for _, d := range l.playerDice { + if d == face || d == 1 { + count++ + } + } + for _, d := range l.aiDice { + if d == face || d == 1 { + count++ + } + } + return count +} + +func (l *LiarsDiceGame) countFaceNonWild(face int) int { + count := 0 + for _, d := range l.playerDice { + if d == face { + count++ + } + } + for _, d := range l.aiDice { + if d == face { + count++ + } + } + return count +} + +func faceName(f int) string { + names := []string{"", "one", "two", "three", "four", "five", "six"} + if f >= 1 && f <= 6 { + return names[f] + } + return fmt.Sprint(f) +} + +func faceNamePlural(f int) string { + names := []string{"", "ones", "twos", "threes", "fours", "fives", "sixes"} + if f >= 1 && f <= 6 { + return names[f] + } + return fmt.Sprint(f) +} + +func (l *LiarsDiceGame) BonusXP() int { + if l.completed && l.won && l.playerLost == 0 { + return 150 + } + return 0 +} diff --git a/internal/game/hacking_mastermind.go b/internal/game/hacking_mastermind.go new file mode 100644 index 0000000..3ab7cca --- /dev/null +++ b/internal/game/hacking_mastermind.go @@ -0,0 +1,177 @@ +package game + +import ( + "fmt" + "math/rand" + "strings" +) + +type MastermindGame struct { + code [4]int + guesses []MastermindGuess + maxGuess int + gameOver bool + won bool +} + +type MastermindGuess struct { + Digits [4]int + Exact int + Partial int +} + +func NewMastermindGame() *MastermindGame { + return &MastermindGame{ + maxGuess: 10, + } +} + +func (m *MastermindGame) Name() string { + return "Code Breaker" +} + +func (m *MastermindGame) Init(level int) string { + pool := []int{1, 2, 3, 4, 5, 6} + rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] }) + copy(m.code[:], pool[:4]) + m.guesses = nil + m.gameOver = false + m.won = false + + var sb strings.Builder + sb.WriteString("=== CODE BREAKER ===\n") + sb.WriteString("\n") + sb.WriteString("The encryption module is running a 4-digit cipher using symbols 1-6.\n") + sb.WriteString("No symbol repeats. You have 10 attempts to crack the code.\n") + sb.WriteString("\n") + sb.WriteString("After each guess, you'll see:\n") + sb.WriteString(" {40}[X]{/} = correct symbol in correct position\n") + sb.WriteString(" {226}[O]{/} = correct symbol in wrong position\n") + sb.WriteString(" [ ] = symbol not in code\n") + sb.WriteString("\n") + sb.WriteString("Commands:\n") + sb.WriteString(" <4 digits> - Guess the code (e.g., 1234)\n") + sb.WriteString(" status - Show previous guesses\n") + sb.WriteString(" jack out - Disconnect (forfeit)\n") + return sb.String() +} + +func (m *MastermindGame) HandleInput(input string) (string, bool, bool) { + input = strings.TrimSpace(input) + lower := strings.ToLower(strings.TrimSpace(input)) + + if lower == "status" { + return m.doStatus(), false, false + } + + if len(input) != 4 { + return "Enter exactly 4 digits (1-6, no repeats).", false, false + } + + var guess [4]int + seen := make(map[int]bool) + for i, ch := range input { + if ch < '1' || ch > '6' { + return "Each digit must be 1-6.", false, false + } + d := int(ch - '0') + if seen[d] { + return "No repeating digits allowed.", false, false + } + seen[d] = true + guess[i] = d + } + + exact := 0 + partial := 0 + for i := 0; i < 4; i++ { + if guess[i] == m.code[i] { + exact++ + } else { + for j := 0; j < 4; j++ { + if guess[i] == m.code[j] { + partial++ + break + } + } + } + } + + m.guesses = append(m.guesses, MastermindGuess{ + Digits: guess, + Exact: exact, + Partial: partial, + }) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Attempt %d/%d: ", len(m.guesses), m.maxGuess)) + for i, d := range guess { + match := "[ ]" + if guess[i] == m.code[i] { + match = "{40}[X]{/}" + } else { + for j := 0; j < 4; j++ { + if guess[i] == m.code[j] { + match = "{226}[O]{/}" + break + } + } + } + sb.WriteString(fmt.Sprintf("%d%s ", d, match)) + } + sb.WriteString(fmt.Sprintf(" (%d locked, %d found)", exact, partial)) + + if exact == 4 { + m.gameOver = true + m.won = true + sb.WriteString("\n\nCode cracked! The encryption module yields.\nConnection terminated.") + return sb.String(), true, true + } + + if len(m.guesses) >= m.maxGuess { + m.gameOver = true + m.won = false + sb.WriteString(fmt.Sprintf("\n\nOut of attempts. The code was: %d %d %d %d.\nConnection terminated.", + m.code[0], m.code[1], m.code[2], m.code[3])) + return sb.String(), true, false + } + + return sb.String(), false, false +} + +func (m *MastermindGame) doStatus() string { + if len(m.guesses) == 0 { + return fmt.Sprintf("No guesses yet. Remaining: %d/%d", m.maxGuess, m.maxGuess) + } + var sb strings.Builder + sb.WriteString("=== Attempts ===\n") + for i, g := range m.guesses { + sb.WriteString(fmt.Sprintf(" %2d: %d %d %d %d -> ", i+1, g.Digits[0], g.Digits[1], g.Digits[2], g.Digits[3])) + for j, d := range g.Digits { + match := "[ ]" + if g.Digits[j] == m.code[j] { + match = "{40}[X]{/}" + } else { + found := false + for k := 0; k < 4; k++ { + if g.Digits[j] == m.code[k] { + found = true + break + } + } + if found { + match = "{226}[O]{/}" + } + } + _ = d + sb.WriteString(match) + } + sb.WriteString(fmt.Sprintf(" (%d locked, %d found)\n", g.Exact, g.Partial)) + } + sb.WriteString(fmt.Sprintf("Remaining: %d/%d\n", m.maxGuess-len(m.guesses), m.maxGuess)) + return sb.String() +} + +func (m *MastermindGame) BonusXP() int { + return (m.maxGuess - len(m.guesses)) * 15 +} diff --git a/internal/game/hacking_wumpus.go b/internal/game/hacking_wumpus.go new file mode 100644 index 0000000..600b1be --- /dev/null +++ b/internal/game/hacking_wumpus.go @@ -0,0 +1,286 @@ +package game + +import ( + "fmt" + "math/rand" + "strconv" + "strings" +) + +var dodecahedron = [20][3]int{ + {1, 4, 7}, + {0, 2, 9}, + {1, 3, 11}, + {2, 4, 13}, + {0, 3, 5}, + {4, 6, 14}, + {5, 7, 16}, + {0, 6, 8}, + {7, 9, 17}, + {1, 8, 10}, + {9, 11, 18}, + {2, 10, 12}, + {11, 13, 19}, + {3, 12, 14}, + {5, 13, 15}, + {14, 16, 19}, + {6, 15, 17}, + {8, 16, 18}, + {10, 17, 19}, + {12, 15, 18}, +} + +type WumpusGame struct { + player int + wumpus int + pits [2]int + ice [2]int + probes int + gameOver bool + won bool +} + +func NewWumpusGame() *WumpusGame { + return &WumpusGame{} +} + +func (w *WumpusGame) Name() string { + return "Hunt the Wumpus" +} + +func (w *WumpusGame) Init(level int) string { + taken := make(map[int]bool) + w.player = rand.Intn(20) + taken[w.player] = true + + w.wumpus = randFree(taken) + taken[w.wumpus] = true + + w.pits[0] = randFree(taken) + taken[w.pits[0]] = true + w.pits[1] = randFree(taken) + taken[w.pits[1]] = true + + w.ice[0] = randFree(taken) + taken[w.ice[0]] = true + w.ice[1] = randFree(taken) + + w.probes = 5 + w.gameOver = false + w.won = false + + var sb strings.Builder + sb.WriteString("=== HUNT THE ROGUE AI ===\n") + sb.WriteString("\n") + sb.WriteString("You've jacked into an abandoned network. Somewhere in this maze of\n") + sb.WriteString("20 nodes, a rogue AI lurks. You have 5 probes to find and neutralize it.\n") + sb.WriteString("\n") + sb.WriteString("Hazards:\n") + sb.WriteString(" - Rogue AI: Moves to an adjacent node if you enter its node. Kills you.\n") + sb.WriteString(" - Data Traps: Fall in and you're fried. 2 in the network.\n") + sb.WriteString(" - ICE: Grabs you and dumps you in a random node. 2 in the network.\n") + sb.WriteString("\n") + sb.WriteString("Commands:\n") + sb.WriteString(" move - Move to an adjacent node (1-20)\n") + sb.WriteString(" shoot - Launch a probe into an adjacent node (1-20)\n") + sb.WriteString(" status - Show your current status\n") + sb.WriteString(" map - Show network map\n") + sb.WriteString(" jack out - Disconnect (forfeit)\n") + sb.WriteString("\n") + sb.WriteString(w.roomDesc()) + return sb.String() +} + +func (w *WumpusGame) HandleInput(input string) (string, bool, bool) { + input = strings.TrimSpace(strings.ToLower(input)) + parts := strings.Fields(input) + + if len(parts) == 0 { + return "Unknown command.", false, false + } + + cmd := parts[0] + + switch cmd { + case "move", "m": + if len(parts) < 2 { + return "Move where? (e.g., move 3)", false, false + } + n, err := strconv.Atoi(parts[1]) + if err != nil || n < 1 || n > 20 { + return "Invalid node. Use 1-20.", false, false + } + return w.doMove(n - 1) + case "shoot", "probe", "launch", "s": + if len(parts) < 2 { + return "Shoot where? (e.g., shoot 3)", false, false + } + n, err := strconv.Atoi(parts[1]) + if err != nil || n < 1 || n > 20 { + return "Invalid node. Use 1-20.", false, false + } + return w.doShoot(n - 1) + case "status": + return w.doStatus(), false, false + case "map": + return w.doMap(), false, false + default: + n, err := strconv.Atoi(cmd) + if err == nil && n >= 1 && n <= 20 { + return w.doMove(n - 1) + } + return "Commands: move , shoot , status, map, jack out", false, false + } +} + +func (w *WumpusGame) doMove(target int) (string, bool, bool) { + if !w.hasAdjacent(w.player, target) { + adj := w.adjStrings(w.player) + return fmt.Sprintf("Can't move there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false + } + w.player = target + return w.checkRoom() +} + +func (w *WumpusGame) doShoot(target int) (string, bool, bool) { + if !w.hasAdjacent(w.player, target) { + adj := w.adjStrings(w.player) + return fmt.Sprintf("Can't shoot there. Adjacent nodes: %s", strings.Join(adj, ", ")), false, false + } + w.probes-- + if target == w.wumpus { + w.gameOver = true + w.won = true + return "Your probe hits the rogue AI! It destabilizes and crashes.\n\nConnection terminated.", true, true + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Your probe finds nothing in node %d.", target+1)) + if w.probes == 0 { + sb.WriteString("\nYou're out of probes. The rogue AI detects your presence and terminates your connection.") + w.gameOver = true + w.won = false + return sb.String(), true, false + } + if rand.Float64() < 0.75 { + w.wumpus = w.randomAdjacent(w.wumpus) + sb.WriteString("\nYou hear something shift in the network...") + } + sb.WriteString("\n") + sb.WriteString(w.roomDesc()) + return sb.String(), false, false +} + +func (w *WumpusGame) checkRoom() (string, bool, bool) { + var sb strings.Builder + cur := w.player + if cur == w.wumpus { + if rand.Float64() < 0.75 { + newRoom := w.randomAdjacent(w.wumpus) + w.wumpus = newRoom + sb.WriteString("--- Node " + fmt.Sprint(cur+1) + " ---\n") + sb.WriteString("The rogue AI stirs and relocates to a nearby node...\n") + sb.WriteString("\n") + sb.WriteString(w.roomDesc()) + return sb.String(), false, false + } + sb.WriteString("The rogue AI devours your connection!\n\nConnection terminated.") + w.gameOver = true + w.won = false + return sb.String(), true, false + } + for _, p := range w.pits { + if cur == p { + sb.WriteString("You've fallen into a data trap! Your connection is severed.") + w.gameOver = true + w.won = false + return sb.String(), true, false + } + } + for _, i := range w.ice { + if cur == i { + sb.WriteString("ICE detected! You're relocated to a random node...\n") + w.player = rand.Intn(20) + chained, _, _ := w.checkRoom() + sb.WriteString(chained) + return sb.String(), false, false + } + } + sb.WriteString(w.roomDesc()) + return sb.String(), false, false +} + +func (w *WumpusGame) roomDesc() string { + cur := w.player + var sb strings.Builder + sb.WriteString(fmt.Sprintf("--- Node %d ---\n", cur+1)) + adj := w.adjStrings(cur) + sb.WriteString(fmt.Sprintf("Tunnels lead to: %s\n", strings.Join(adj, ", "))) + + for _, a := range dodecahedron[cur] { + if a == w.wumpus { + sb.WriteString("{196}You detect corrupted data nearby...{/}\n") + } + for _, p := range w.pits { + if a == p { + sb.WriteString("{208}You sense a void in the network...{/}\n") + } + } + for _, i := range w.ice { + if a == i { + sb.WriteString("{226}You hear static crackling...{/}\n") + } + } + } + return sb.String() +} + +func (w *WumpusGame) doStatus() string { + return fmt.Sprintf("Node: %d | Probes: %d/5", w.player+1, w.probes) +} + +func (w *WumpusGame) doMap() string { + var sb strings.Builder + sb.WriteString("=== Network Map ===\n") + for i := 0; i < 20; i++ { + marker := " " + if i == w.player { + marker = "*" + } + adj := dodecahedron[i] + sb.WriteString(fmt.Sprintf(" [%s] Node %2d -> %2d, %2d, %2d\n", + marker, i+1, adj[0]+1, adj[1]+1, adj[2]+1)) + } + sb.WriteString(fmt.Sprintf("\nYou are at Node %d.\n", w.player+1)) + return sb.String() +} + +func (w *WumpusGame) hasAdjacent(from, to int) bool { + for _, a := range dodecahedron[from] { + if a == to { + return true + } + } + return false +} + +func (w *WumpusGame) randomAdjacent(r int) int { + adj := dodecahedron[r] + return adj[rand.Intn(len(adj))] +} + +func (w *WumpusGame) adjStrings(r int) []string { + var out []string + for _, a := range dodecahedron[r] { + out = append(out, fmt.Sprint(a+1)) + } + return out +} + +func randFree(taken map[int]bool) int { + for { + r := rand.Intn(20) + if !taken[r] { + return r + } + } +} diff --git a/internal/game/science.go b/internal/game/science.go index 38cb138..bf9a961 100644 --- a/internal/game/science.go +++ b/internal/game/science.go @@ -275,6 +275,9 @@ func init() { {ID: "superheat", Name: "Superheat Item", Level: 43, MaxHit: 0, BaseXP: 53.0, JunkCost: map[string]int{"naturejunk": 4, "solarjunk": 1, "scrap_metal": 1}, Category: ModUtility, Element: "", TargetType: "inventory"}, + {ID: "plank_make", Name: "Plank Make", Level: 86, MaxHit: 0, BaseXP: 90.0, + JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1}, + Category: ModUtility, Element: "", TargetType: "inventory"}, {ID: "transport_town", Name: "Transport: Town Square", Level: 25, MaxHit: 0, BaseXP: 27.0, JunkCost: map[string]int{"lawjunk": 1, "solarjunk": 1, "biojunk": 1, "scrap_metal": 1}, diff --git a/internal/net/server.go b/internal/net/server.go index 7c1b79a..7c7a18c 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -39,6 +39,8 @@ const ( StateProductChoice StateColorChoice StateShop + StateBank + StateHacking ) type Session struct { diff --git a/internal/object/item.go b/internal/object/item.go index 7aed6c2..4959a22 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -67,6 +67,9 @@ type ItemDef struct { FarmProduct string `yaml:"farm_product"` FarmMinYield int `yaml:"farm_min_yield"` FarmMaxYield int `yaml:"farm_max_yield"` + PotionEffect string `yaml:"potion_effect"` + PotionBonus int `yaml:"potion_bonus"` + PotionDuration float64 `yaml:"potion_duration"` } type MadeFromEntry struct { diff --git a/internal/player/player.go b/internal/player/player.go index a3ca4ce..88cf7f8 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -136,6 +136,7 @@ var OptionDefs = []OptionDef{ {"cook_all", OptBool, false, nil, "Auto-start cooking when only one product is possible"}, {"smelt_all", OptBool, false, nil, "Auto-start smelting when only one product is possible"}, {"mix_all", OptBool, false, nil, "Auto-start mixing when only one product is possible"}, + {"construct_all", OptBool, false, nil, "Auto-start constructing when only one product is possible"}, } var optionByName map[string]*OptionDef @@ -155,6 +156,7 @@ type Player struct { Name string `yaml:"name"` Skills map[SkillName]int `yaml:"skills"` Inventory map[int]*InventorySlot `yaml:"inventory"` + Bank map[int]*InventorySlot `yaml:"bank"` Equipment map[object.EquipSlot]string `yaml:"equipment"` EquipQuality map[string]int `yaml:"equip_quality,omitempty"` RoomID int `yaml:"room_id"` @@ -184,6 +186,13 @@ type Player struct { TechActivatedSinceTick map[string]bool `yaml:"-"` Sneaking bool `yaml:"-"` SneakNotified map[string]bool `yaml:"-"` + ActiveBuffs []PotionBuff `yaml:"-"` +} + +type PotionBuff struct { + Stat string + BonusPercent int + TicksLeft int } func (p *Player) ClearMoveState() { @@ -289,6 +298,7 @@ func New(name string) *Player { p := &Player{ Name: name, Skills: make(map[SkillName]int), + Bank: make(map[int]*InventorySlot), Equipment: make(map[object.EquipSlot]string), AttackStyle: Accurate, Prompt: "> ", @@ -421,6 +431,53 @@ func (p *Player) DeactivateAllTechs() { p.TechActivatedSinceTick = nil } +func (p *Player) BankSlot(i int) *InventorySlot { + if p.Bank == nil { + return nil + } + return p.Bank[i] +} + +func (p *Player) SetBankSlot(i int, slot *InventorySlot) { + if p.Bank == nil { + p.Bank = make(map[int]*InventorySlot) + } + if slot == nil { + delete(p.Bank, i) + } else { + p.Bank[i] = slot + } +} + +func (p *Player) NextBankSlot() int { + if p.Bank == nil { + return 0 + } + i := 0 + for { + if p.Bank[i] == nil { + return i + } + i++ + } +} + +func (p *Player) BankSlots() []int { + if p.Bank == nil { + return nil + } + keys := make([]int, 0, len(p.Bank)) + for k := range p.Bank { + keys = append(keys, k) + } + sort.Ints(keys) + return keys +} + +func (p *Player) BankCount() int { + return len(p.Bank) +} + func (p *Player) ActiveTechList() []string { var result []string for id := range p.ActiveTechs { diff --git a/internal/world/world.go b/internal/world/world.go index 9bed76e..f918293 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -307,7 +307,11 @@ func (w *World) LoadRoom(id int) (*Room, error) { path := filepath.Join(w.dataDir, "rooms", fmt.Sprintf("%d.yaml", id)) data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("read room %d: %w", id, err) + path = filepath.Join(w.dataDir, "rooms", "player_housing", fmt.Sprintf("%d.yaml", id)) + data, err = os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read room %d: %w", id, err) + } } var room Room if err := yaml.Unmarshal(data, &room); err != nil { diff --git a/skill_plans/construction.md b/skill_plans/construction.md index 85452ad..17a1533 100644 --- a/skill_plans/construction.md +++ b/skill_plans/construction.md @@ -1,1638 +1,84 @@ -# Construction Skill Implementation Plan +# Construction Skill Plan -## 1. Overview +## Overview -Construction adds player housing, a workshop for crafting planks and furniture, and a real estate broker NPC. Players buy a house from the broker for 10 credits, then visit their house (or other players' houses) through a Directory object in the Local Neighborhood room. Each house consists of two virtual rooms: a house entrance and a workshop. The workshop contains a workbench station where players slowly convert logs into planks (the `make`/`construct` command), then convert planks into sellable furniture items for Construction XP. +Construction is a production skill centered around processing logs into planks and assembling furniture. Players gain XP through repetitive crafting at a workbench, with higher-tier logs providing more XP and more valuable outputs. -**Key design decisions:** -- Player houses are virtual rooms generated in memory, NOT YAML files on disk -- The workshop has a "workbench" object that acts as a production station -- Construction products are items with credit value (not room decorations) -- The house is just 2 rooms (entrance + workshop) for now -- No Runescape-style dismantling — items are final products +## Core Loop -**Existing state:** -- Room 16 is "Construction Site" (`data/rooms/16.yaml`) with exits east:17, west:15 -- `Construction` skill already defined in `internal/player/player.go` (line 27) with abbreviation `"con"` (line 59) -- The production system in `action_production.go` is reusable for a new `"construction"` recipe type +1. **Gather logs** via Woodcutting +2. **Process logs into planks** at a workbench (with a saw) or at the sawmill (for credits) or via Plank Make (science mod) +3. **Assemble planks into furniture** at a workbench +4. **Sell furniture** for credits (furniture has higher value than input planks) -## 2. Architecture +## Architecture -### Virtual Room System +### Production System Integration -Player houses exist only in memory. The `World.LoadRoom()` function (at `internal/world/world.go:306`) currently reads exclusively from disk. We add a **virtual room registry** so `LoadRoom` checks memory first before disk. +Construction plugs into the existing unified production system. Recipes use `type: construction`, `station: [workbench]`, and optionally `tool: saw`. The `advanceProduction` function handles the full lifecycle without any construction-specific code. -Each player who buys a house gets two virtual room IDs allocated: -- **House entrance**: base ID -- **Workshop**: base ID + 1 +### Recipe Tiers -Room IDs for virtual rooms use the range **100000+** to avoid collision with YAML room files. A counter in `World` tracks the next available virtual room ID. +**Plank recipes** (4 tiers): +- Regular (Lv1): logs → planks (20 ticks, 5xp) +- Oak (Lv15): oak_logs → oak_planks (20 ticks, 10xp) +- Teak (Lv35): teak_logs → teak_planks (20 ticks, 20xp) +- Mahogany (Lv50): mahogany_logs → mahogany_planks (20 ticks, 30xp) -**Player flags used** (stored in character YAML `flags:` map): -- `has_house` (bool): Whether the player owns a house -- `house_room_id` (int): The virtual room ID of the player's house entrance -- `house_owner` (string): The player's character name (redundant but useful for Directory) +**Furniture recipes** (7 items across 4 tiers): +- Wooden shelf (Lv4), table (Lv6), chair (Lv8) +- Oak shelf (Lv18), table (Lv20) +- Teak table (Lv38) +- Mahogany table (Lv52) -### Data Flow +### Alternative Plank Sources -``` -Player buys house (talk broker) - → set player_flag has_house: true - → allocate virtual room IDs via housing.go - → store house_room_id in player flags - → save character +1. **Sawmill operator** (room 171): Pays credits per log to get planks instantly. Fees: 5/10/20/40 credits. Uses the `sawmill` node action — a custom talk node action that process all logs at once. -Player visits house (use directory) - → read target player's character file for house_room_id - → ensure virtual rooms exist in World registry - → teleport player to house entrance +2. **Plank Make mod** (Science Lv86): Converts logs to planks at 70% of sawmill cost (3/7/14/28 credits) plus junk cost. Awards both Science XP and Construction XP. -Player uses workshop - → findStation finds "workbench" object in virtual room - → production system handles make/construct recipes normally -``` +## Player Housing -## 3. Commands +Houses serve as a private space with a workbench and bank booth. Future expansions will allow adding rooms, furniture placement, and multi-city ownership. -### `make` / `construct` (Active command, production-style) +### House Purchase +- Talk to the estate broker at the Construction Site (room 16) +- Cost: 10 credits +- Sets player flag: `owns_house_local_neighborhood: "local_neighborhood"` -These are aliases for the same command. They work like `smith` — require a workbench station in the room, show a production table of available recipes, and use the unified production cycle. +### House Access +- Walk north from Construction Site to Local Neighborhood (room 170) +- Use or talk to the Estate Directory +- Enter your house via the directory -**Classification** — add to `classifyCommand()` in `internal/game/game.go:136`: -```go -case "get", "take", "grab", "pick", "drop", - "attack", "kill", - "north", "n", "south", "s", "east", "e", - "west", "w", "up", "u", "down", "d", - "quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "make", "construct": - return ClassActive -``` +### House Rooms +- Room 200: Entrance Hall (charging station) +- Room 201: Workshop (workbench + bank booth) -**Dispatch** — add to `executeCommand()` in `internal/game/game.go` (after the `craft` case around line 380): -```go -case "make", "construct": - g.doMake(sess, strings.Join(args, " ")) - return -``` +Rooms are real YAML files in `data/rooms/player_housing/`. The room loader checks this directory as fallback after the main rooms directory. -**AdvanceActions** — the production system already handles any action type registered in `productionActionTypes` (via the `productionTypes` map in `action_production.go:29`). Add the entry: -```go -var productionTypes = map[string]productionTypeInfo{ - // ... existing entries ... - "construction": {"make", "construction"}, -} -``` +### Future: Multi-House Support -This causes `advanceProduction` to be called for `make` action types automatically via the `default` case in `AdvanceActions` at `action.go:237`. +Each housing area gets a unique player flag: +- `owns_house_local_neighborhood: "local_neighborhood"` → rooms 200-201 +- `owns_house_industrial_district: "industrial_district"` → rooms 210-211 (future) +- etc. -## 4. New Files to Create +The `getOwnedHouseRoom` function maps flag values to room IDs. The directory scans for area-specific flags. -### Go Files +## Files | File | Purpose | |------|---------| -| `internal/game/cmd_make.go` | `doMake()` command handler for `make`/`construct` | -| `internal/game/housing.go` | Virtual room generation, Directory logic, homeowner scanning | - -### YAML Data Files - -| File | Purpose | -|------|---------| -| `data/rooms/150.yaml` | Local Neighborhood room (north of room 16) | -| `data/objects/directory.yaml` | Directory object definition | -| `data/objects/workbench.yaml` | Workbench station object definition | -| `data/mobs/real_estate_broker.yaml` | Real estate broker mob definition | -| `data/behaviors/broker_talk.yaml` | Broker talk behavior (buy house dialog) | -| `data/items/plank.yaml` | Regular plank item | -| `data/items/oak_plank.yaml` | Oak plank item | -| `data/items/teak_plank.yaml` | Teak plank item | -| `data/items/mahogany_plank.yaml` | Mahogany plank item | -| `data/items/wooden_shelf.yaml` | Wooden shelf (furniture item) | -| `data/items/wooden_table.yaml` | Wooden table (furniture item) | -| `data/items/wooden_chair.yaml` | Wooden chair (furniture item) | -| `data/items/wooden_bench.yaml` | Wooden bench (furniture item) | -| `data/items/oak_shelf.yaml` | Oak shelf (furniture item) | -| `data/items/oak_table.yaml` | Oak table (furniture item) | -| `data/items/oak_chair.yaml` | Oak chair (furniture item) | -| `data/items/oak_bench.yaml` | Oak bench (furniture item) | -| `data/items/teak_shelf.yaml` | Teak shelf (furniture item) | -| `data/items/teak_table.yaml` | Teak table (furniture item) | -| `data/items/teak_chair.yaml` | Teak chair (furniture item) | -| `data/items/teak_bench.yaml` | Teak bench (furniture item) | -| `data/items/mahogany_shelf.yaml` | Mahogany shelf (furniture item) | -| `data/items/mahogany_table.yaml` | Mahogany table (furniture item) | -| `data/items/mahogany_chair.yaml` | Mahogany chair (furniture item) | -| `data/items/mahogany_bench.yaml` | Mahogany bench (furniture item) | -| `data/recipes/construct_plank.yaml` | Logs → plank recipe | -| `data/recipes/construct_oak_plank.yaml` | Oak logs → oak plank recipe | -| `data/recipes/construct_teak_plank.yaml` | Teak logs → teak plank recipe | -| `data/recipes/construct_mahogany_plank.yaml` | Mahogany logs → mahogany plank recipe | -| `data/recipes/construct_wooden_shelf.yaml` | Plank → wooden shelf recipe | -| `data/recipes/construct_wooden_table.yaml` | Plank → wooden table recipe | -| `data/recipes/construct_wooden_chair.yaml` | Plank → wooden chair recipe | -| `data/recipes/construct_wooden_bench.yaml` | Plank → wooden bench recipe | -| `data/recipes/construct_oak_shelf.yaml` | Oak plank → oak shelf recipe | -| `data/recipes/construct_oak_table.yaml` | Oak plank → oak table recipe | -| `data/recipes/construct_oak_chair.yaml` | Oak plank → oak chair recipe | -| `data/recipes/construct_oak_bench.yaml` | Oak plank → oak bench recipe | -| `data/recipes/construct_teak_shelf.yaml` | Teak plank → teak shelf recipe | -| `data/recipes/construct_teak_table.yaml` | Teak plank → teak table recipe | -| `data/recipes/construct_teak_chair.yaml` | Teak plank → teak chair recipe | -| `data/recipes/construct_teak_bench.yaml` | Teak plank → teak bench recipe | -| `data/recipes/construct_mahogany_shelf.yaml` | Mahogany plank → mahogany shelf recipe | -| `data/recipes/construct_mahogany_table.yaml` | Mahogany plank → mahogany table recipe | -| `data/recipes/construct_mahogany_chair.yaml` | Mahogany plank → mahogany chair recipe | -| `data/recipes/construct_mahogany_bench.yaml` | Mahogany plank → mahogany bench recipe | -| `data/help/make.yaml` | Help topic for make/construct command | -| `data/help/construction.yaml` | Help topic for construction skill | - -## 5. Code Changes to Existing Files - -### `internal/game/game.go` - -**1. `classifyCommand()` (line 136)** — Add `"make"` and `"construct"` to the Active command case: - -```go -// Change this line (around line 150): -"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft": -// To: -"quit", "use", "burn", "stoke", "search", "walk", "cook", "smelt", "smith", "craft", "make", "construct": -``` - -**2. `executeCommand()` (around line 379)** — Add dispatch case after `craft`: - -```go -case "make", "construct": - g.doMake(sess, strings.Join(args, " ")) - return -``` - -### `internal/game/action_production.go` - -**3. `productionTypes` map (line 29)** — Add construction entry: - -```go -var productionTypes = map[string]productionTypeInfo{ - "cooking": {"cook", "cooking"}, - "smelting": {"smelt", "smelting"}, - "smithing": {"smith", "smithing"}, - "crafting": {"craft", "crafting"}, - "combine": {"combine", "combining"}, - "fletching": {"fletch", "fletching"}, - "construction": {"make", "construction"}, -} -``` - -This single addition causes the entire production cycle (`startProduction`, `advanceProduction`, `canContinueProduction`) to work automatically for `type: "construction"` recipes. The `init()` function at line 40 will register `"make"` in `productionActionTypes`, and the `default` case in `AdvanceActions` at `action.go:237` will route to `advanceProduction`. - -### `internal/world/world.go` - -**4. Add virtual room registry** — Add a `virtualRooms` map and a `nextVirtualID` counter to the `World` struct (line 39): - -```go -type World struct { - dataDir string - mu sync.Mutex - groundItems map[int][]*groundEntry - seeded map[int]bool - objStates map[string]*ObjState - objMoves []ObjMove - virtualRooms map[int]*Room // NEW: in-memory rooms - nextVirtualID int // NEW: next available virtual room ID -} -``` - -**5. Initialize in `New()` (line 297)**: - -```go -func New(dataDir string) *World { - return &World{ - dataDir: dataDir, - groundItems: make(map[int][]*groundEntry), - seeded: make(map[int]bool), - objStates: make(map[string]*ObjState), - virtualRooms: make(map[int]*Room), - nextVirtualID: 100000, - } -} -``` - -**6. Modify `LoadRoom()` (line 306)** — Check virtual rooms first: - -```go -func (w *World) LoadRoom(id int) (*Room, error) { - w.mu.Lock() - if vr, ok := w.virtualRooms[id]; ok { - w.mu.Unlock() - return vr, nil - } - w.mu.Unlock() - - // existing disk-loading code follows... - path := filepath.Join(w.dataDir, "rooms", fmt.Sprintf("%d.yaml", id)) - // ... -} -``` - -**7. Add virtual room management methods**: - -```go -func (w *World) RegisterVirtualRoom(room *Room) { - w.mu.Lock() - defer w.mu.Unlock() - w.virtualRooms[room.ID] = room -} - -func (w *World) AllocateVirtualID() int { - w.mu.Lock() - defer w.mu.Unlock() - id := w.nextVirtualID - w.nextVirtualID += 2 // reserve 2 IDs per house (entrance + workshop) - return id -} - -func (w *World) HasVirtualRoom(id int) bool { - w.mu.Lock() - defer w.mu.Unlock() - _, ok := w.virtualRooms[id] - return ok -} -``` - -### `internal/player/store.go` - -**8. Add `ListHomeowners()` method** — Scan all character files for `has_house` flag: - -```go -func (s *AccountStore) ListHomeowners() []string { - dir := filepath.Join(s.dataDir, "players", "characters") - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - var names []string - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { - continue - } - charName := strings.TrimSuffix(e.Name(), ".yaml") - p, err := s.LoadCharacter(charName) - if err != nil { - continue - } - if p.Flags != nil { - if hasHouse, ok := p.Flags["has_house"].(bool); ok && hasHouse { - names = append(names, p.Name) - } - } - } - sort.Strings(names) - return names -} -``` - -This requires adding `"sort"` to the imports in `store.go`. - -### `internal/player/player.go` - -**9. Add `make_all` option** — Add to `OptionDefs` slice (around line 134, after the existing `*_all` options): - -```go -{"make_all", OptBool, false, nil, "Auto-start construction when only one product is possible"}, -``` - -### `internal/game/game.go` (HandleSession) - -**10. Add `StateVisitHouse` handling** — In `HandleSession()` (line 92), add a case for the new session state that the Directory object will use: - -```go -case net.StateVisitHouse: - g.handleVisitHouseInput(sess, input) -``` - -### `internal/net/server.go` - -**11. Add `StateVisitHouse` constant** — Add to the session state constants (around line 39): - -```go -StateVisitHouse -``` - -## 6. Real Estate Broker - -The broker is a mob NPC placed in the Construction Site room (room 16). Uses the existing talk behavior system. - -### Mob Definition: `data/mobs/real_estate_broker.yaml` - -```yaml -id: real_estate_broker -name: Real Estate Broker -description: "A smartly dressed broker clutching a clipboard and a ring of keys. She looks eager to make a sale." -behavior: broker_talk -unique: true -protected: true -hp: 50 -attack: 1 -strength: 1 -defense: 1 -speed: 5 -aggressive: false -respawn_ticks: 30 -idle_descriptions: - - "flips through property listings on a clipboard" - - "jingles a ring of keys" - - "adjusts her collar and checks her watch" - - "polishes a small 'SOLD' stamp" -``` - -### Talk Behavior: `data/behaviors/broker_talk.yaml` - -```yaml -id: broker_talk -type: talk -nodes: - start: - message: "\"Welcome! I'm the local real estate broker. Looking for a place to call home?\"" - options: - - text: "\"I'd like to buy a house.\"" - goto: buy_offer - condition: - all_of: - - player_flag: has_house - not: true - - min_credits: 10 - - text: "\"I'd like to buy a house.\"" - goto: no_credits - condition: - all_of: - - player_flag: has_house - not: true - - min_credits: 10 - not: true - - text: "\"I already own a house.\"" - goto: already_own - condition: - player_flag: has_house - value: true - - text: "\"Just looking around.\"" - end: true - buy_offer: - message: "\"Excellent! I have a lovely starter home available in the Local Neighborhood — just north of here. It comes with a workshop, perfect for construction projects. The price is 10 credits. Shall I draw up the paperwork?\"" - options: - - text: "\"Yes, I'll take it!\"" - goto: purchase_complete - - text: "\"Let me think about it.\"" - end: true - purchase_complete: - message: "\"Congratulations! Here are your keys. Your new home is in the Local Neighborhood, just north of here. Use the Directory there to find your house. Happy building!\"" - action: - cost: 10 - set_player_flags: - has_house: true - options: - - text: "\"Thanks!\"" - end: true - no_credits: - message: "\"I'm afraid the starter home costs 10 credits. Come back when you've saved up!\"" - options: - - text: "\"I'll be back.\"" - end: true - already_own: - message: "\"You already own a home! Head to the Local Neighborhood north of here and use the Directory to visit it.\"" - options: - - text: "\"Thanks for the reminder.\"" - end: true -``` - -**Note on house room allocation:** The talk behavior sets `has_house: true` via `set_player_flags`. The actual virtual room ID allocation happens lazily the **first time** the player visits their house via the Directory (in `housing.go`). This avoids needing to extend `applyNodeAction` — the existing `set_player_flags` and `cost` handling in `action_talk.go:165-218` handles everything. - -### Update Room 16: `data/rooms/16.yaml` - -```yaml -id: 16 -name: "Construction Site" -description: "A half-built structure surrounded by planks, nails, and blueprints. A {130}real estate broker{/} stands near a model home display, eager to chat." -exits: - north: 150 - east: 17 - west: 15 -mobs: - - id: real_estate_broker -``` - -Changes from current: -- Added `north: 150` exit to Local Neighborhood -- Updated description to mention the broker -- Added broker mob - -## 7. Local Neighborhood Room - -### `data/rooms/150.yaml` - -```yaml -id: 150 -name: "Local Neighborhood" -description: "A quiet residential street lined with small houses. A large {45}directory board{/} stands at the entrance, listing all the homeowners in the area. The construction site lies to the south." -exits: - south: 16 -objects: - - id: directory -``` - -## 8. Directory Object - -### `data/objects/directory.yaml` - -```yaml -id: directory -name: directory -color: "45" -description: "A large board listing all homeowners in the neighborhood. Use it to visit someone's house." -inroom_description: "A large directory board lists the local homeowners." -``` - -The Directory has two interactions: -1. **`look directory`** — Shows a list of all homeowners (handled by custom logic in `cmd_look.go`) -2. **`use directory`** — Prompts "Visit whose house (enter for yours):" and teleports (handled by custom logic in `housing.go`) - -### Implementation in `internal/game/housing.go` - -This file contains all housing-related logic. - -```go -package game - -import ( - "fmt" - "sort" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -const neighborhoodRoomID = 150 - -func (g *Game) isDirectoryObject(defID string) bool { - return defID == "directory" -} - -func (g *Game) listHomeowners() []string { - return g.AccountStore.ListHomeowners() -} - -func (g *Game) lookDirectory(sess *net.Session) { - owners := g.listHomeowners() - if len(owners) == 0 { - sess.WriteLine("The directory is empty — no one has bought a house yet.") - return - } - sess.WriteLine("The directory lists the following homeowners:") - for _, name := range owners { - sess.WriteLine(fmt.Sprintf(" - %s", name)) - } -} - -func (g *Game) useDirectory(sess *net.Session) { - p := sess.Player.(*player.Player) - _ = p - sess.State = net.StateVisitHouse - sess.Write("Visit whose house (enter for yours): ") -} - -func (g *Game) handleVisitHouseInput(sess *net.Session, input string) { - sess.State = net.StateGame - p := sess.Player.(*player.Player) - input = strings.TrimSpace(input) - - var targetName string - if input == "" { - if p.Flags == nil { - sess.WriteLine("You don't own a house.") - g.reprompt(sess) - return - } - hasHouse, _ := p.Flags["has_house"].(bool) - if !hasHouse { - sess.WriteLine("You don't own a house. Talk to the Real Estate Broker at the Construction Site.") - g.reprompt(sess) - return - } - targetName = p.Name - } else { - found := false - for _, name := range g.listHomeowners() { - if strings.EqualFold(name, input) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(input)) { - targetName = name - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("%s doesn't own a house here.", input)) - g.reprompt(sess) - return - } - } - - roomID := g.ensurePlayerHouse(targetName) - if roomID == 0 { - sess.WriteLine("Something went wrong finding that house.") - g.reprompt(sess) - return - } - - p.RoomID = roomID - g.AccountStore.SaveCharacter(p) - g.World.SeedGroundItems(p.RoomID) - g.seedRoomObjects(p.RoomID) - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - g.doLook(sess) - g.writePrompt(sess) -} - -func (g *Game) ensurePlayerHouse(charName string) int { - target, err := g.AccountStore.LoadCharacter(charName) - if err != nil { - return 0 - } - if target.Flags == nil { - return 0 - } - - // Check if the character already has a room ID allocated - if rid, ok := target.Flags["house_room_id"]; ok { - var roomID int - switch v := rid.(type) { - case int: - roomID = v - case float64: - roomID = int(v) - } - if roomID > 0 && g.World.HasVirtualRoom(roomID) { - return roomID - } - // Room ID stored but virtual rooms not registered (server restart) — regenerate - if roomID > 0 { - g.registerHouseRooms(roomID, charName) - return roomID - } - } - - // Allocate new virtual room IDs - roomID := g.World.AllocateVirtualID() - target.Flags["house_room_id"] = roomID - g.AccountStore.SaveCharacter(target) - - // If this is the current player, also update their in-memory flags - // (the caller may have a different Player pointer) - - g.registerHouseRooms(roomID, charName) - return roomID -} - -func (g *Game) registerHouseRooms(baseID int, ownerName string) { - workshopID := baseID + 1 - - entrance := &world.Room{ - ID: baseID, - Name: fmt.Sprintf("%s's House", ownerName), - Description: fmt.Sprintf("A cozy starter home belonging to %s. The walls are bare but full of potential. A doorway to the west leads to a workshop.", ownerName), - Exits: map[world.ExitDir]world.ExitDef{ - world.South: {Room: neighborhoodRoomID}, - world.West: {Room: workshopID}, - }, - } - - workshop := &world.Room{ - ID: workshopID, - Name: "Workshop", - Description: "A dusty workshop with a sturdy workbench in the center. Sawdust covers the floor. Tools hang neatly on the walls.", - Exits: map[world.ExitDir]world.ExitDef{ - world.East: {Room: baseID}, - }, - Objects: []world.RoomObject{ - {ID: "workbench"}, - }, - } - - g.World.RegisterVirtualRoom(entrance) - g.World.RegisterVirtualRoom(workshop) -} -``` - -### Hooking `look directory` into `cmd_look.go` - -In `doLookTarget()` in `internal/game/cmd_look.go`, add a check: if the target matches a directory object in the current room, call `g.lookDirectory(sess)` instead of (or in addition to) showing the object description. - -Find the section in `doLookTarget` that handles looking at objects. After the object is found and its description is displayed, add: - -```go -// After displaying the object description for "directory" -if obj.ID == "directory" { - g.lookDirectory(sess) -} -``` - -This is an addition to the normal look flow — the object's `description` field text is shown first ("A large board listing all homeowners..."), then the dynamic homeowner list is appended. - -### Hooking `use directory` into `cmd_use.go` - -In `doUse()` in `internal/game/cmd_use.go`, the flow already routes through `StartAction` for object interactions. However, the Directory doesn't have a behavior YAML — it needs a custom handler. - -The cleanest approach: add a `use_interactions` entry on the directory object that triggers a custom action, OR handle it as a special case in `doUse`. Since the directory needs a prompt state (StateVisitHouse), handle it as a special case: - -In `cmd_use.go`, when `use directory` is entered and the directory object is found in the room, call `g.useDirectory(sess)` instead of routing through the behavior system. The simplest hook point is in `doUse` — after identifying the target is the `directory` object, short-circuit: - -```go -if target object's defID == "directory" { - g.useDirectory(sess) - return -} -``` - -Alternatively, add a check in `StartAction` (`action.go:45`) that intercepts `directory` before behavior lookup. The `doUse` approach is cleaner since `use` on its own already has custom item-on-object logic. - -## 9. Player House Rooms - -### Dynamic Room Generation - -Virtual rooms are `*world.Room` structs stored in `World.virtualRooms`. They are created on-demand by `ensurePlayerHouse()` in `housing.go`. - -**House Entrance Room:** -- Name: `"'s House"` -- Description: `"A cozy starter home belonging to . The walls are bare but full of potential. A doorway to the west leads to a workshop."` -- Exits: `south → 150` (Local Neighborhood), `west → workshopID` -- No objects, no mobs, no spawns - -**Workshop Room:** -- Name: `"Workshop"` -- Description: `"A dusty workshop with a sturdy workbench in the center. Sawdust covers the floor. Tools hang neatly on the walls."` -- Exits: `east → entranceID` -- Objects: `[{id: "workbench"}]` - -### Server Restart Handling - -Virtual rooms are lost on server restart (they live in memory). When a player visits a house after restart, `ensurePlayerHouse()` checks if the virtual rooms exist. If not, it re-registers them using the stored `house_room_id` from the player's flags. This is seamless — the first visit after restart recreates the rooms. - -The `AllocateVirtualID()` counter also resets on restart. To prevent ID collisions, on startup the system should scan all character files for existing `house_room_id` values and set `nextVirtualID` above the maximum found. Add this to `Game.New()` or a separate init function: - -```go -func (g *Game) initHousingIDs() { - dir := filepath.Join(g.dataDir, "players", "characters") - entries, _ := os.ReadDir(dir) - maxID := 100000 - for _, e := range entries { - if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { - continue - } - name := strings.TrimSuffix(e.Name(), ".yaml") - p, err := g.AccountStore.LoadCharacter(name) - if err != nil { - continue - } - if p.Flags != nil { - if rid, ok := p.Flags["house_room_id"]; ok { - var id int - switch v := rid.(type) { - case int: - id = v - case float64: - id = int(v) - } - if id+2 > maxID { - maxID = id + 2 - } - } - } - } - g.World.SetNextVirtualID(maxID) -} -``` - -Add `SetNextVirtualID` to `World`: -```go -func (w *World) SetNextVirtualID(id int) { - w.mu.Lock() - defer w.mu.Unlock() - if id > w.nextVirtualID { - w.nextVirtualID = id - } -} -``` - -Call `g.initHousingIDs()` after `Game.New()` in `cmd/mud/main.go`, before the server starts accepting connections. - -### Map Integration - -The BFS map builder in `map.go` calls `World.LoadRoom()` to traverse exits. Virtual rooms will automatically be included since `LoadRoom` checks the virtual registry. Player house rooms will appear on the map when the player is inside them. - -## 10. Workshop - -### Workbench Object: `data/objects/workbench.yaml` - -```yaml -id: workbench -name: workbench -color: "137" -description: "A sturdy wooden workbench with a built-in vice, saw guides, and tool racks. Perfect for construction projects." -inroom_description: "A sturdy workbench dominates the center of the room." -``` - -The workbench has no behavior — it serves purely as a station object. `findStation()` in `stations.go:3` searches for objects in the room by `defID`. Construction recipes list `station: [workbench]`, and `findStation` will match the workbench's `defID` of `"workbench"`. - -### How the Workshop Works - -1. Player enters workshop (east exit from house entrance) -2. `seedRoomObjects` is called, which calls `World.LoadRoom(workshopID)` — returns the virtual room — and then calls `EnsureObjectStates` for the `workbench` object -3. Player types `make` or `construct` -4. `doMake()` calls `findStation(p.RoomID, []string{"workbench"})` — finds the workbench -5. Recipes of `type: "construction"` with `station: [workbench]` are loaded and filtered -6. Player picks a recipe from the production table -7. Unified production cycle runs: consume materials, wait ticks, produce output, award XP - -## 11. Items - -### Plank Items - -#### `data/items/plank.yaml` -```yaml -id: plank -name: plank -color: "137" -description: "A carefully shaped wooden plank, ready for construction." -value: 5 -stackable: false -``` - -#### `data/items/oak_plank.yaml` -```yaml -id: oak_plank -name: oak plank -color: "143" -description: "A sturdy oak plank, suitable for quality furniture." -value: 15 -stackable: false -``` - -#### `data/items/teak_plank.yaml` -```yaml -id: teak_plank -name: teak plank -color: "179" -description: "A fine teak plank with a beautiful grain." -value: 40 -stackable: false -``` - -#### `data/items/mahogany_plank.yaml` -```yaml -id: mahogany_plank -name: mahogany plank -color: "124" -description: "A rich mahogany plank, the finest building material." -value: 100 -stackable: false -``` - -### Furniture Items (Wooden) - -#### `data/items/wooden_shelf.yaml` -```yaml -id: wooden_shelf -name: wooden shelf -color: "137" -description: "A simple wooden shelf. Could hold a few things." -value: 15 -stackable: false -``` - -#### `data/items/wooden_table.yaml` -```yaml -id: wooden_table -name: wooden table -color: "137" -description: "A basic wooden table with four legs." -value: 25 -stackable: false -``` - -#### `data/items/wooden_chair.yaml` -```yaml -id: wooden_chair -name: wooden chair -color: "137" -description: "A simple wooden chair. Not comfortable, but functional." -value: 20 -stackable: false -``` - -#### `data/items/wooden_bench.yaml` -```yaml -id: wooden_bench -name: wooden bench -color: "137" -description: "A long wooden bench for sitting." -value: 30 -stackable: false -``` - -### Furniture Items (Oak) - -#### `data/items/oak_shelf.yaml` -```yaml -id: oak_shelf -name: oak shelf -color: "143" -description: "A sturdy oak shelf with dovetail joints." -value: 50 -stackable: false -``` - -#### `data/items/oak_table.yaml` -```yaml -id: oak_table -name: oak table -color: "143" -description: "A solid oak table with a polished surface." -value: 80 -stackable: false -``` - -#### `data/items/oak_chair.yaml` -```yaml -id: oak_chair -name: oak chair -color: "143" -description: "A well-crafted oak chair with carved armrests." -value: 65 -stackable: false -``` - -#### `data/items/oak_bench.yaml` -```yaml -id: oak_bench -name: oak bench -color: "143" -description: "A heavy oak bench with smooth edges." -value: 95 -stackable: false -``` - -### Furniture Items (Teak) - -#### `data/items/teak_shelf.yaml` -```yaml -id: teak_shelf -name: teak shelf -color: "179" -description: "An elegant teak shelf with a warm finish." -value: 130 -stackable: false -``` - -#### `data/items/teak_table.yaml` -```yaml -id: teak_table -name: teak table -color: "179" -description: "A beautiful teak table with intricate leg carvings." -value: 200 -stackable: false -``` - -#### `data/items/teak_chair.yaml` -```yaml -id: teak_chair -name: teak chair -color: "179" -description: "A refined teak chair with a contoured seat." -value: 165 -stackable: false -``` - -#### `data/items/teak_bench.yaml` -```yaml -id: teak_bench -name: teak bench -color: "179" -description: "A gorgeous teak bench with a curved backrest." -value: 240 -stackable: false -``` - -### Furniture Items (Mahogany) - -#### `data/items/mahogany_shelf.yaml` -```yaml -id: mahogany_shelf -name: mahogany shelf -color: "124" -description: "A luxurious mahogany shelf with beveled edges." -value: 320 -stackable: false -``` - -#### `data/items/mahogany_table.yaml` -```yaml -id: mahogany_table -name: mahogany table -color: "124" -description: "A magnificent mahogany table with brass inlays." -value: 500 -stackable: false -``` - -#### `data/items/mahogany_chair.yaml` -```yaml -id: mahogany_chair -name: mahogany chair -color: "124" -description: "A grand mahogany chair fit for a captain." -value: 400 -stackable: false -``` - -#### `data/items/mahogany_bench.yaml` -```yaml -id: mahogany_bench -name: mahogany bench -color: "124" -description: "A stately mahogany bench with ornate scrollwork." -value: 600 -stackable: false -``` - -## 12. Recipes - -All construction recipes use `type: "construction"`, `station: [workbench]`, and have very slow wait times (20+ ticks for planks, 15+ ticks for furniture). - -### Plank Recipes - -#### `data/recipes/construct_plank.yaml` -```yaml -id: construct_plank -type: construction -level: 1 -xp: 30 -wait: 20 -station: [workbench] -consume: - - items: [logs] - qty: 1 -output: plank -message: "You carefully saw the logs into a plank." -``` - -#### `data/recipes/construct_oak_plank.yaml` -```yaml -id: construct_oak_plank -type: construction -level: 15 -xp: 60 -wait: 24 -station: [workbench] -consume: - - items: [oak_logs] - qty: 1 -output: oak_plank -message: "You saw the oak logs into a fine plank." -``` - -#### `data/recipes/construct_teak_plank.yaml` -```yaml -id: construct_teak_plank -type: construction -level: 35 -xp: 100 -wait: 28 -station: [workbench] -consume: - - items: [teak_logs] - qty: 1 -output: teak_plank -message: "You carefully work the teak logs into a smooth plank." -``` - -#### `data/recipes/construct_mahogany_plank.yaml` -```yaml -id: construct_mahogany_plank -type: construction -level: 50 -xp: 150 -wait: 32 -station: [workbench] -consume: - - items: [mahogany_logs] - qty: 1 -output: mahogany_plank -message: "You painstakingly shape the mahogany logs into a perfect plank." -``` - -### Wooden Furniture Recipes - -#### `data/recipes/construct_wooden_shelf.yaml` -```yaml -id: construct_wooden_shelf -type: construction -level: 1 -xp: 50 -wait: 15 -station: [workbench] -consume: - - items: [plank] - qty: 2 -output: wooden_shelf -message: "You assemble a simple wooden shelf." -``` - -#### `data/recipes/construct_wooden_table.yaml` -```yaml -id: construct_wooden_table -type: construction -level: 5 -xp: 80 -wait: 18 -station: [workbench] -consume: - - items: [plank] - qty: 3 -output: wooden_table -message: "You build a sturdy wooden table." -``` - -#### `data/recipes/construct_wooden_chair.yaml` -```yaml -id: construct_wooden_chair -type: construction -level: 3 -xp: 65 -wait: 16 -station: [workbench] -consume: - - items: [plank] - qty: 2 -output: wooden_chair -message: "You craft a simple wooden chair." -``` - -#### `data/recipes/construct_wooden_bench.yaml` -```yaml -id: construct_wooden_bench -type: construction -level: 8 -xp: 100 -wait: 20 -station: [workbench] -consume: - - items: [plank] - qty: 4 -output: wooden_bench -message: "You construct a long wooden bench." -``` - -### Oak Furniture Recipes - -#### `data/recipes/construct_oak_shelf.yaml` -```yaml -id: construct_oak_shelf -type: construction -level: 20 -xp: 120 -wait: 18 -station: [workbench] -consume: - - items: [oak_plank] - qty: 2 -output: oak_shelf -message: "You assemble a sturdy oak shelf." -``` - -#### `data/recipes/construct_oak_table.yaml` -```yaml -id: construct_oak_table -type: construction -level: 25 -xp: 180 -wait: 22 -station: [workbench] -consume: - - items: [oak_plank] - qty: 3 -output: oak_table -message: "You build a solid oak table." -``` - -#### `data/recipes/construct_oak_chair.yaml` -```yaml -id: construct_oak_chair -type: construction -level: 22 -xp: 150 -wait: 20 -station: [workbench] -consume: - - items: [oak_plank] - qty: 2 -output: oak_chair -message: "You craft a well-made oak chair." -``` - -#### `data/recipes/construct_oak_bench.yaml` -```yaml -id: construct_oak_bench -type: construction -level: 28 -xp: 220 -wait: 24 -station: [workbench] -consume: - - items: [oak_plank] - qty: 4 -output: oak_bench -message: "You construct a heavy oak bench." -``` - -### Teak Furniture Recipes - -#### `data/recipes/construct_teak_shelf.yaml` -```yaml -id: construct_teak_shelf -type: construction -level: 40 -xp: 200 -wait: 20 -station: [workbench] -consume: - - items: [teak_plank] - qty: 2 -output: teak_shelf -message: "You assemble an elegant teak shelf." -``` - -#### `data/recipes/construct_teak_table.yaml` -```yaml -id: construct_teak_table -type: construction -level: 45 -xp: 300 -wait: 25 -station: [workbench] -consume: - - items: [teak_plank] - qty: 3 -output: teak_table -message: "You build a beautiful teak table." -``` - -#### `data/recipes/construct_teak_chair.yaml` -```yaml -id: construct_teak_chair -type: construction -level: 42 -xp: 250 -wait: 22 -station: [workbench] -consume: - - items: [teak_plank] - qty: 2 -output: teak_chair -message: "You craft a refined teak chair." -``` - -#### `data/recipes/construct_teak_bench.yaml` -```yaml -id: construct_teak_bench -type: construction -level: 48 -xp: 360 -wait: 28 -station: [workbench] -consume: - - items: [teak_plank] - qty: 4 -output: teak_bench -message: "You construct a gorgeous teak bench." -``` - -### Mahogany Furniture Recipes - -#### `data/recipes/construct_mahogany_shelf.yaml` -```yaml -id: construct_mahogany_shelf -type: construction -level: 55 -xp: 350 -wait: 24 -station: [workbench] -consume: - - items: [mahogany_plank] - qty: 2 -output: mahogany_shelf -message: "You assemble a luxurious mahogany shelf." -``` - -#### `data/recipes/construct_mahogany_table.yaml` -```yaml -id: construct_mahogany_table -type: construction -level: 60 -xp: 500 -wait: 30 -station: [workbench] -consume: - - items: [mahogany_plank] - qty: 3 -output: mahogany_table -message: "You build a magnificent mahogany table." -``` - -#### `data/recipes/construct_mahogany_chair.yaml` -```yaml -id: construct_mahogany_chair -type: construction -level: 58 -xp: 420 -wait: 26 -station: [workbench] -consume: - - items: [mahogany_plank] - qty: 2 -output: mahogany_chair -message: "You craft a grand mahogany chair." -``` - -#### `data/recipes/construct_mahogany_bench.yaml` -```yaml -id: construct_mahogany_bench -type: construction -level: 65 -xp: 600 -wait: 32 -station: [workbench] -consume: - - items: [mahogany_plank] - qty: 4 -output: mahogany_bench -message: "You construct a stately mahogany bench." -``` - -## 13. Plank Making - -Plank making is the core "slow grind" of Construction. Converting logs to planks is intentionally very slow (20-32 ticks per plank, meaning 12-19 seconds at default 600ms ticks) to make it feel like real labor. - -| Log Type | Plank Output | Level | XP | Wait (ticks) | Wait (seconds at 600ms) | -|----------|-------------|-------|----|-------------|------------------------| -| logs | plank | 1 | 30 | 20 | 12.0 | -| oak_logs | oak_plank | 15 | 60 | 24 | 14.4 | -| teak_logs | teak_plank | 35 | 100 | 28 | 16.8 | -| mahogany_logs | mahogany_plank | 50 | 150 | 32 | 19.2 | - -Planks are non-stackable (like logs), so inventory management is part of the challenge — you can only carry 28 items, so a full inventory of logs becomes a full inventory of planks. - -No success/fail rolls — plank making always succeeds. This keeps it simple and avoids frustrating material loss on an already-slow process. - -## 14. XP Table - -### Plank Making XP - -| Recipe | Level | XP | Ticks | -|--------|-------|----|-------| -| Plank | 1 | 30 | 20 | -| Oak Plank | 15 | 60 | 24 | -| Teak Plank | 35 | 100 | 28 | -| Mahogany Plank | 50 | 150 | 32 | - -### Furniture XP - -| Recipe | Level | XP | Planks | Ticks | -|--------|-------|----|--------|-------| -| Wooden Shelf | 1 | 50 | 2 | 15 | -| Wooden Chair | 3 | 65 | 2 | 16 | -| Wooden Table | 5 | 80 | 3 | 18 | -| Wooden Bench | 8 | 100 | 4 | 20 | -| Oak Shelf | 20 | 120 | 2 | 18 | -| Oak Chair | 22 | 150 | 2 | 20 | -| Oak Table | 25 | 180 | 3 | 22 | -| Oak Bench | 28 | 220 | 4 | 24 | -| Teak Shelf | 40 | 200 | 2 | 20 | -| Teak Chair | 42 | 250 | 2 | 22 | -| Teak Table | 45 | 300 | 3 | 25 | -| Teak Bench | 48 | 360 | 4 | 28 | -| Mahogany Shelf | 55 | 350 | 2 | 24 | -| Mahogany Chair | 58 | 420 | 2 | 26 | -| Mahogany Table | 60 | 500 | 3 | 30 | -| Mahogany Bench | 65 | 600 | 4 | 32 | - -### XP/Hour Efficiency (approximate, at 600ms ticks) - -| Method | XP/tick | XP/hour | -|--------|---------|---------| -| Regular planks | 1.5 | 9,000 | -| Regular furniture (shelf) | 3.3 | 12,000 | -| Oak planks | 2.5 | 15,000 | -| Oak furniture (table) | 8.2 | 29,500 | -| Teak planks | 3.6 | 21,400 | -| Teak furniture (table) | 12.0 | 43,200 | -| Mahogany planks | 4.7 | 28,100 | -| Mahogany furniture (table) | 16.7 | 60,000 | - -## 15. Help Files - -### `data/help/make.yaml` - -```yaml -name: "make" -category: "Skills" -description: | - Construct items at a workbench using the Construction skill. - - Usage: make Build a specific item - make Show all items you can make right now - construct Same as make - - Construction requires a workbench, found in your house's - workshop. Buy a house from the Real Estate Broker at the - Construction Site, then visit it via the Directory in the - Local Neighborhood. - - Plank making (logs to planks) is slow but reliable — no chance - of failure. Furniture crafting uses planks to produce sellable - items for credits. - - At the prompt, type a product number or name to start making. - Partial names work if unambiguous. You can prefix with a count - to limit quantity (e.g., "3 shelf"). Press enter to repeat - the last product. - - See also: construction -``` - -### `data/help/construction.yaml` - -```yaml -name: "construction" -category: "Skills" -description: | - Construction is a production skill for building furniture and - other items from planks at a workbench. - - Getting started: - 1. Visit the Construction Site (south of the skill halls) - 2. Talk to the Real Estate Broker to buy a house (10 credits) - 3. Go north to the Local Neighborhood - 4. Use the Directory to visit your house - 5. Go west to the Workshop - 6. Use "make" or "construct" to start building - - Plank making: Convert logs into planks at the workbench. This - is very slow work. Different log types require different levels - and yield different plank types. - - Furniture: Use planks to build furniture items (shelves, tables, - chairs, benches). These items have credit value and can be sold. - - Log types: logs (level 1), oak (15), teak (35), mahogany (50) - - Use "make" at a workbench to see available recipes. Items you - can't yet make are shown dimmed. - - See also: make -``` - -## Implementation Order - -For an AI agent implementing this, the recommended order is: - -1. **YAML data files first** (no code changes needed to test creation): - - `data/objects/workbench.yaml` - - `data/objects/directory.yaml` - - `data/mobs/real_estate_broker.yaml` - - `data/behaviors/broker_talk.yaml` - - All `data/items/*.yaml` (planks + furniture) - - All `data/recipes/construct_*.yaml` - - `data/help/make.yaml` and `data/help/construction.yaml` - -2. **Room YAML**: - - `data/rooms/150.yaml` (Local Neighborhood) - - Update `data/rooms/16.yaml` (Construction Site — add north exit, broker mob, update description) - -3. **World virtual room system** (`internal/world/world.go`): - - Add `virtualRooms` map and `nextVirtualID` to `World` struct - - Update `New()` to initialize them - - Modify `LoadRoom()` to check virtual rooms first - - Add `RegisterVirtualRoom()`, `AllocateVirtualID()`, `HasVirtualRoom()`, `SetNextVirtualID()` - -4. **Player store** (`internal/player/store.go`): - - Add `ListHomeowners()` method - -5. **Housing logic** (`internal/game/housing.go` — new file): - - `isDirectoryObject()`, `listHomeowners()`, `lookDirectory()` - - `useDirectory()`, `handleVisitHouseInput()` - - `ensurePlayerHouse()`, `registerHouseRooms()` - - `initHousingIDs()` - -6. **Session state** (`internal/net/server.go`): - - Add `StateVisitHouse` constant - -7. **Production type** (`internal/game/action_production.go`): - - Add `"construction"` entry to `productionTypes` map - -8. **Make command** (`internal/game/cmd_make.go` — new file): - - `doMake()` handler following `cmd_cook.go` / `cmd_craft.go` pattern - -9. **Command dispatch** (`internal/game/game.go`): - - Add `"make"`, `"construct"` to `classifyCommand()` Active case - - Add `"make"`, `"construct"` case to `executeCommand()` - - Add `StateVisitHouse` case to `HandleSession()` - -10. **Option** (`internal/player/player.go`): - - Add `make_all` option - -11. **Look/Use hooks**: - - Hook `lookDirectory()` into object look flow in `cmd_look.go` - - Hook `useDirectory()` into `cmd_use.go` or `StartAction` for the directory object - -12. **Startup init** (`cmd/mud/main.go`): - - Call `g.initHousingIDs()` after game creation - -13. **Build and test**: - - `make vet` — verify no compilation errors - - `make test` — run test suite - - `make run` — manual testing - -## `cmd_make.go` Full Implementation - -```go -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -func (g *Game) doMake(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) - - stationDefID, _ := g.findStation(p.RoomID, []string{"workbench"}) - if stationDefID == "" { - sess.WriteLine("You need a workbench to make things. Visit your house's workshop.") - return - } - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - sess.WriteLine("Error loading recipes.") - return - } - - var makeRecipes []action.RecipeDef - for _, r := range allRecipes { - if r.Type != "construction" { - continue - } - if !stationMatch(stationDefID, r.Station) { - continue - } - makeRecipes = append(makeRecipes, r) - } - - if len(makeRecipes) == 0 { - sess.WriteLine("There's nothing to make here.") - return - } - - if input != "" { - g.doMakeWithInput(sess, p, makeRecipes, input) - return - } - - lastRecipeID, _ := p.Flags["last_make"].(string) - if lastRecipeID != "" { - for i := range makeRecipes { - if makeRecipes[i].ID == lastRecipeID { - r := &makeRecipes[i] - if g.canDoMakeRecipe(p, r) { - g.startMakeRecipe(sess, p, r, 0) - return - } - break - } - } - } - - available := g.availableMakeRecipes(p, makeRecipes) - if len(available) == 0 { - sess.WriteLine("You don't have any materials to make anything.") - return - } - - if p.OptionBool("make_all") && len(available) == 1 { - g.startMakeRecipe(sess, p, &available[0], 0) - return - } - - g.showProductionTable(sess, p, makeRecipes, "Construction", "construction", "last_make", "Make", false) -} - -func (g *Game) doMakeWithInput(sess *net.Session, p *player.Player, makeRecipes []action.RecipeDef, input string) { - qty, productName := parseQty(input) - productName = strings.TrimSpace(productName) - - var matched []action.RecipeDef - for _, r := range makeRecipes { - outDef, _ := g.ItemStore.Load(r.Output) - name := r.Output - if outDef != nil { - name = outDef.Name - } - if world.WordPrefixMatch(productName, name) { - matched = append(matched, r) - } - } - - if len(matched) == 0 { - sess.WriteLine("You can't make that.") - return - } - - var available []action.RecipeDef - for _, r := range matched { - if g.canDoMakeRecipe(p, &r) { - available = append(available, r) - } - } - - if len(available) == 0 { - sess.WriteLine("You don't have the materials or level for that.") - return - } - - if len(available) == 1 { - g.startMakeRecipe(sess, p, &available[0], qty) - return - } - - g.showProductionTable(sess, p, available, "Construction", "construction", "last_make", "Make", false) -} - -func (g *Game) canDoMakeRecipe(p *player.Player, r *action.RecipeDef) bool { - if p.Level(player.Construction) < r.Level { - return false - } - if !r.HasAllItemsQty(p.CountItem) { - return false - } - return true -} - -func (g *Game) availableMakeRecipes(p *player.Player, allMake []action.RecipeDef) []action.RecipeDef { - var result []action.RecipeDef - for _, r := range allMake { - if r.HasAllItemsQty(p.CountItem) { - result = append(result, r) - } - } - return result -} - -func (g *Game) startMakeRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags["last_make"] = recipe.ID - g.AccountStore.SaveCharacter(p) - g.startProductionFromRecipe(sess, p, recipe, count) -} -``` - -## Edge Cases to Handle - -1. **Player visits someone else's house** — The Directory allows visiting any homeowner's house. `ensurePlayerHouse(targetName)` loads the target's character file, not the visiting player's. - -2. **Server restart** — Virtual rooms are gone. `ensurePlayerHouse()` detects the missing virtual room and re-registers it. `initHousingIDs()` ensures the ID counter doesn't re-allocate existing IDs. - -3. **Player dies in their house** — Death teleports to room 1 (Town Square). Standard behavior, no special handling needed. - -4. **Player quits inside house** — `room_id` is saved to character YAML. On next login, `LoadRoom()` is called — if virtual rooms aren't registered yet, the first `look` or `move` will fail gracefully. The `completeMove` / `doLook` code calls `LoadRoom`; if it returns an error, the player gets "You can't move from here." To handle this: on login/character connection, if the player's `room_id` >= 100000, call `ensurePlayerHouse` to register the virtual rooms before showing the room. Add this check to the character connection flow in `login_char.go`. - -5. **Multiple players in the same house** — Works naturally. The Hub tracks sessions by room ID. Multiple players can be in the same virtual room. Chat (`say`) works normally. - -6. **YAML flag type mismatch** — When `house_room_id` is loaded from YAML, integers may deserialize as `float64` or `int` depending on the YAML parser. The `ensurePlayerHouse` function handles both via type switch. - -7. **Player doesn't have `has_house` but has `house_room_id`** — Shouldn't happen in normal flow, but `ensurePlayerHouse` checks both flags defensively. - -8. **Inventory full during construction** — The standard production system already handles this: "Your inventory is too full!" and cancels the action (`action_production.go:362`). - -9. **No logs/planks** — The `doMake` handler shows "You don't have any materials to make anything." Same pattern as `doCook`. - -10. **Using directory outside neighborhood room** — The directory object only exists in room 150. `use` on a non-existent object in other rooms won't match. No special guard needed. +| `internal/game/action_production.go` | Added `"construction"` to productionTypes | +| `internal/game/cmd_construct.go` | `doConstruct` command handler | +| `internal/game/cmd_estate_directory.go` | Directory handling + teleport | +| `internal/game/action_talk.go` | Added sawmill node action + `processSawmill` | +| `internal/game/action.go` | Added directory interception in StartAction | +| `internal/game/cmd_look.go` | Added directory handling in doLookTarget | +| `internal/game/science.go` | Added Plank Make mod definition | +| `internal/game/cmd_trigger.go` | Added `triggerPlankMake` handler | +| `internal/game/game.go` | Added `construct`/`make` to command dispatch | +| `internal/action/behavior.go` | Added `Sawmill` field to NodeAction | +| `internal/world/world.go` | Extended LoadRoom to support player_housing dir | +| `internal/player/player.go` | Added `construct_all` option | diff --git a/worldbuilding_guide/README.md b/worldbuilding_guide/README.md index b6f6de8..97cf43b 100644 --- a/worldbuilding_guide/README.md +++ b/worldbuilding_guide/README.md @@ -22,4 +22,6 @@ The House of Icarus is data-driven. Everything — rooms, items, mobs, objects, | [Toggles](toggles.md) | Player toggle options (tiny_map, xp_drops, automap, etc.) | | [Search](search.md) | The search command and searchable items | | [Hidden Objects](hidden_objects.md) | Hidden interactive objects | +| [Bank](bank.md) | Bank system — deposit, withdraw, browse, bank booth placement | +| [Construction](construction.md) | Construction skill — planks, furniture, houses, sawmill, Plank Make | | [Tips](tips.md) | Practical tips for building a world | diff --git a/worldbuilding_guide/bank.md b/worldbuilding_guide/bank.md new file mode 100644 index 0000000..0a5ec88 --- /dev/null +++ b/worldbuilding_guide/bank.md @@ -0,0 +1,68 @@ +# Bank System + +The bank system allows players to store items permanently, freeing up inventory space. + +## Objects + +### bank_booth + +A bank terminal that players can interact with. Has no special behavior — the `bank` command checks for any `bank_booth` object in the room. + +```yaml +id: bank_booth +name: bank booth +color: "226" +description: "..." +inroom_description: "A bank booth is set into the wall." +``` + +To place a bank booth in a room, add it to the room's `objects:` list: + +```yaml +objects: + - id: bank_booth +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `bank` | Opens bank interface (requires bank booth in room) | +| `use bank` | Same as `bank` | + +### Bank Interface Commands + +| Command | Description | +|---------|-------------| +| `deposit ` | Move item from inventory to bank | +| `deposit all` | Deposit entire inventory | +| `withdraw ` | Move item from bank to inventory | +| `browse` / `list` | Show bank contents | +| `leave` | Exit bank interface | + +## Implementation + +### Session State + +`StateBank` (defined in `internal/net/server.go`) is the session state for the bank interface. Input is routed through `handleBankInput` in `internal/game/cmd_bank.go`. + +### Player Storage + +Bank items are stored in the `Player.Bank` field (`map[int]*InventorySlot`), saved in character YAML. Bank slots are sequential and reuse freed slots. There is no slot limit. + +### Command Dispatch + +- `bank` is classified as `ClassActive` in `classifyCommand()` +- `bank` case in `executeCommand()` calls `doBank(sess)` +- `doUse()` checks for `"bank"` or `"bank booth"` targets and routes to `doBank()` +- `doBank()` checks for a `bank_booth` object in the current room +- `handleBankInput()` dispatches deposit/withdraw/browse/leave commands + +### Data Files + +| File | Purpose | +|------|---------| +| `data/objects/bank_booth.yaml` | Bank booth object definition | +| `data/help/bank.yaml` | Help topic for the bank system | + +Default bank booth locations: Town Square (room 1), Forge (room 12). diff --git a/worldbuilding_guide/construction.md b/worldbuilding_guide/construction.md new file mode 100644 index 0000000..799057a --- /dev/null +++ b/worldbuilding_guide/construction.md @@ -0,0 +1,208 @@ +# Construction Skill + +The Construction skill allows players to process logs into planks and assemble planks into furniture at a workbench. Higher-tier logs produce more valuable planks and furniture. + +## Skill + +- Skill name: `construction` +- Abbreviation: `con` +- Level range: 1-99 +- XP table: RSC standard + +## Commands + +| Command | Description | +|---------|-------------| +| `construct` / `make` | Open construction menu at a workbench | +| `construct ` | Build a specific item | +| `construct ` | Build multiple of an item | + +## Objects + +### workbench + +The primary construction station. Placed in rooms to enable construction. + +```yaml +id: workbench +name: workbench +color: "172" +description: "A sturdy wooden workbench..." +inroom_description: "A workbench stands against the wall..." +``` + +### estate_directory + +A terminal that shows registered homeowners and lets them enter their house. Handled entirely in Go code (`internal/game/cmd_estate_directory.go`), not via YAML behaviors. + +```yaml +id: estate_directory +name: Estate Directory +color: "39" +description: "A holographic terminal displaying property records..." +inroom_description: "An estate directory terminal..." +``` + +When a player looks at the directory, it scans `data/players/characters/*.yaml` for the `owns_house_local_neighborhood` player flag and lists all homeowners. When a player uses or talks to the directory and owns a house, they can teleport to their house entrance room. + +## Tools + +### saw + +Required tool for cutting logs into planks at the workbench. + +```yaml +id: saw +name: saw +tool_type: saw +value: 30 +``` + +## Items + +### Planks (4 tiers) + +| Item ID | Name | Level | Value | Made From | +|---------|------|-------|-------|-----------| +| `planks` | planks | 1 | 20 | logs | +| `oak_planks` | oak planks | 15 | 40 | oak_logs | +| `teak_planks` | teak planks | 35 | 80 | teak_logs | +| `mahogany_planks` | mahogany planks | 50 | 120 | mahogany_logs | + +### Furniture (sellables) + +| Item ID | Level | Planks Required | +|---------|-------|-----------------| +| `wooden_shelf` | 4 | 2 planks | +| `wooden_table` | 6 | 3 planks | +| `wooden_chair` | 8 | 2 planks | +| `oak_shelf` | 18 | 2 oak planks | +| `oak_table` | 20 | 3 oak planks | +| `teak_table` | 38 | 3 teak planks | +| `mahogany_table` | 52 | 3 mahogany planks | + +## Recipes + +All recipes use `type: construction` and `station: [workbench]`. Log-to-plank recipes additionally require `tool: saw`. + +Example plank recipe: +```yaml +id: construct_oak_planks +type: construction +level: 15 +xp: 10 +wait: 20 +station: [workbench] +tool: saw +consume: + - items: [oak_logs] + qty: 1 +output: oak_planks +message: "You saw the oak logs into sturdy oak planks." +``` + +Example furniture recipe: +```yaml +id: construct_wooden_table +type: construction +level: 6 +xp: 25 +wait: 6 +station: [workbench] +consume: + - items: [planks] + qty: 3 +output: wooden_table +message: "You build a sturdy wooden table." +``` + +## NPCs + +### estate_broker + +Located in room 16 (Construction Site). Talk behavior sells a house plot for 10 credits via `cost` + `set_player_flags`. Sets `owns_house_local_neighborhood: "local_neighborhood"` on purchase. + +```yaml +id: estate_broker +name: estate broker +behavior: estate_broker_talk +unique: true +``` + +### sawmill_operator + +Located in room 171 (Lumberyard). Talk behavior processes all logs in inventory into planks for a credit fee. Uses the `sawmill: true` node action which triggers `processSawmill()` in `internal/game/action_talk.go`. + +Fees: regular (5cr), oak (10cr), teak (20cr), mahogany (40cr). + +## Science Mod: Plank Make + +Level 86 Science utility mod that converts logs to planks at 70% of sawmill cost. Uses naturejunk, solarjunk, and scrap_metal. + +```go +{ID: "plank_make", Name: "Plank Make", Level: 86, BaseXP: 90.0, + JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1}, + Category: ModUtility, TargetType: "inventory"}, +``` + +Usage: `trigger plank make` (all logs) or `trigger plank make ` (specific). + +## Player Houses + +Player houses are real YAML rooms stored in `data/rooms/player_housing/`. Each house area (e.g. Local Neighborhood) has entrance + workshop rooms with unique room IDs in the 200+ range. + +House room files follow the same format as world rooms: +```yaml +id: 200 +name: "Player House: Entrance Hall" +description: "..." +exits: + south: 170 + north: 201 +objects: + - id: charging_station +``` + +The room loader (`World.LoadRoom`) checks `data/rooms/.yaml` first, then falls back to `data/rooms/player_housing/.yaml`. + +Ownership is tracked via player flags (e.g. `owns_house_local_neighborhood: "local_neighborhood"`). The Estate Directory scans character YAML files for these flags. + +## Implementation + +### Production Integration + +Construction is added to `productionTypes` in `action_production.go`: +```go +"construction": {"construct", "constructing"}, +``` + +This enables automatic support through the unified production system (`advanceProduction`, `startProductionFromRecipe`, etc.). + +### Command + +`cmd_construct.go` follows the same pattern as `cmd_craft.go`, using per-recipe `Station` and `Tool` checks. Station: `workbench`, Tool: `saw` (for plank recipes only). + +### Option + +`construct_all` (OptBool, default false): Auto-start construction when only one product is available. + +## Data File Summary + +| Path | Purpose | +|------|---------| +| `data/objects/workbench.yaml` | Workbench station object | +| `data/objects/estate_directory.yaml` | Directory terminal object | +| `data/items/saw.yaml` | Saw tool | +| `data/items/planks.yaml` - `mahogany_planks.yaml` | Plank items (4 tiers) | +| `data/items/wooden_shelf.yaml` - `mahogany_table.yaml` | Furniture items (7 items) | +| `data/recipes/construct_planks.yaml` - `construct_mahogany_table.yaml` | Construction recipes (11 recipes) | +| `data/mobs/estate_broker.yaml` | Estate broker mob | +| `data/mobs/sawmill_operator.yaml` | Sawmill operator mob | +| `data/behaviors/estate_broker_talk.yaml` | Broker talk behavior | +| `data/behaviors/sawmill_operator_talk.yaml` | Sawmill operator talk behavior | +| `data/rooms/16.yaml` | Construction Site (updated) | +| `data/rooms/170.yaml` | Local Neighborhood | +| `data/rooms/171.yaml` | Lumberyard | +| `data/rooms/player_housing/200.yaml` | Player house entrance | +| `data/rooms/player_housing/201.yaml` | Player house workshop | +| `data/help/construct.yaml` | Help topic | diff --git a/worldbuilding_guide/hacking.md b/worldbuilding_guide/hacking.md new file mode 100644 index 0000000..6f9bf9a --- /dev/null +++ b/worldbuilding_guide/hacking.md @@ -0,0 +1,71 @@ +## Hacking + +Hacking minigames are played by jacking into terminal objects. Terminals are YAML objects that are registered in Go's `terminalDefs` map. + +### Terminal Object YAML + +```yaml +id: terminal_basic +name: basic terminal +color: "40" +description: "A battered terminal with a cracked screen. {40}[Level 1 Hacking]{/}" +inroom_description: "A {40}basic terminal{/} hums quietly against the wall." +``` + +- `id` must match a key in `terminalDefs` (Go code in `internal/game/hacking.go`) +- `name` is used for matching in the `jack` command +- `color` controls the display color +- `description` is shown when examining the terminal +- `inroom_description` is shown in room listings +- No `behavior` field — terminals are registered in Go, not YAML behaviors + +### Adding a New Terminal + +1. Create an object YAML file in `data/objects/terminal_.yaml` +2. Add an entry to `terminalDefs` in `internal/game/hacking.go` +3. Optionally create a new minigame implementation in `internal/game/hacking_.go` +4. Place the terminal object in a room's `objects:` list + +### Adding a New Minigame + +New minigames implement the `HackingMinigame` interface: + +```go +type HackingMinigame interface { + Init(level int) string + HandleInput(input string) (output string, done bool, won bool) + Name() string +} +``` + +Optionally implement `XPBonuser` for bonus XP: + +```go +type XPBonuser interface { + BonusXP() int +} +``` + +### Agility Courses + +Agility courses are defined in `data/courses/.yaml`. Each course defines a sequence of obstacle rooms with specific verbs. + +```yaml +id: "vent_shaft" +name: "Ventilation Shaft Course" +required_level: 1 +start_room: 200 +completion_xp: 40 +obstacles: + - room_id: 201 + verb: scramble + ticks_per_phase: 2 + xp: 8 + fail_damage: [1, 2] + messages: + - "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!" +``` + +Each obstacle room should have an `on_enter` message telling the player which verb to use, and a `down` exit back to the course hub. diff --git a/worldbuilding_guide/index.md b/worldbuilding_guide/index.md index e19c207..9f721cb 100644 --- a/worldbuilding_guide/index.md +++ b/worldbuilding_guide/index.md @@ -8,3 +8,7 @@ | An interactive object (rock, lever, door) | `data/objects/.yaml` | | A behavior (mining, dialog, toggle) | `data/behaviors/.yaml` | | A shared drop table | `data/drops/.yaml` | +| A crafting recipe | `data/recipes/.yaml` | +| A help topic | `data/help/.yaml` | +| An agility course | `data/courses/.yaml` | +| A player house | `data/rooms/player_housing/.yaml` | -- cgit v1.2.3