From 0ea4eec5dd2122c6704216971eb1249276297867 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 19 Jun 2026 18:20:26 -0400 Subject: shop fixes, 'toggle' completely removed, movement speed increased --- AGENTS.md | 16 +- data/behaviors/chop_magic.yaml | 22 - data/behaviors/chop_mahogany.yaml | 22 - data/behaviors/chop_maple.yaml | 22 - data/behaviors/chop_oak.yaml | 22 - data/behaviors/chop_redwood.yaml | 22 - data/behaviors/chop_teak.yaml | 22 - data/behaviors/chop_tree.yaml | 22 - data/behaviors/chop_willow.yaml | 22 - data/behaviors/chop_yew.yaml | 22 - data/behaviors/client_talk.yaml | 217 -------- data/behaviors/estate_broker_talk.yaml | 49 -- data/behaviors/fish_angler.yaml | 19 - data/behaviors/fish_bait.yaml | 29 - data/behaviors/fish_cage.yaml | 18 - data/behaviors/fish_fly.yaml | 29 - data/behaviors/fish_harpoon.yaml | 28 - data/behaviors/fish_net.yaml | 39 -- data/behaviors/fish_sacred_eel.yaml | 19 - data/behaviors/fish_swamp.yaml | 24 - data/behaviors/general_store.yaml | 49 -- data/behaviors/guard_talk.yaml | 52 -- data/behaviors/iron_gate_toggle.yaml | 9 - data/behaviors/mine_adamantite.yaml | 20 - data/behaviors/mine_clay.yaml | 20 - data/behaviors/mine_coal.yaml | 20 - data/behaviors/mine_copper.yaml | 25 - data/behaviors/mine_gold.yaml | 20 - data/behaviors/mine_iron.yaml | 20 - data/behaviors/mine_mithril.yaml | 20 - data/behaviors/mine_runite.yaml | 20 - data/behaviors/mine_scrap.yaml | 20 - data/behaviors/mine_silver.yaml | 20 - data/behaviors/mine_tin.yaml | 20 - data/behaviors/netrunner_talk.yaml | 63 --- data/behaviors/sawmill_operator_talk.yaml | 37 -- data/behaviors/shear_sheep.yaml | 20 - data/behaviors/stall_guard_talk.yaml | 36 -- data/behaviors/tanner_talk.yaml | 46 -- data/behaviors/tool_shed_talk.yaml | 86 --- data/behaviors/weapons_shop.yaml | 43 -- data/help/autocast.yaml | 21 - data/help/autotrigger.yaml | 21 + data/help/mods.yaml | 17 +- data/help/movement.yaml | 12 +- data/help/science.yaml | 16 +- data/help/shop.yaml | 19 +- data/help/trigger.yaml | 3 +- data/help/use.yaml | 25 +- data/mobs/client.yaml | 231 +++++++- data/mobs/estate_broker.yaml | 55 +- data/mobs/general_store_clerk.yaml | 85 ++- data/mobs/guard.yaml | 92 +++- data/mobs/sawmill_operator.yaml | 50 +- data/mobs/tanner.yaml | 82 ++- data/mobs/weapons_smith.yaml | 79 ++- data/modules/bio_blast.yaml | 12 + data/modules/bio_bolt.yaml | 11 + data/modules/bio_strike.yaml | 10 + data/modules/bio_surge.yaml | 11 + data/modules/bio_wave.yaml | 12 + data/modules/bones_to_nutrients.yaml | 10 + data/modules/chip_diamond.yaml | 10 + data/modules/chip_emerald.yaml | 10 + data/modules/chip_ruby.yaml | 11 + data/modules/chip_sapphire.yaml | 10 + data/modules/eco_blast.yaml | 12 + data/modules/eco_bolt.yaml | 11 + data/modules/eco_strike.yaml | 11 + data/modules/eco_surge.yaml | 12 + data/modules/eco_wave.yaml | 12 + data/modules/em_grab.yaml | 10 + data/modules/enchant_1.yaml | 10 + data/modules/enchant_2.yaml | 10 + data/modules/enchant_3.yaml | 10 + data/modules/enchant_4.yaml | 10 + data/modules/high_process.yaml | 10 + data/modules/hydro_blast.yaml | 12 + data/modules/hydro_bolt.yaml | 11 + data/modules/hydro_strike.yaml | 11 + data/modules/hydro_surge.yaml | 12 + data/modules/hydro_wave.yaml | 12 + data/modules/low_process.yaml | 10 + data/modules/plank_make.yaml | 10 + data/modules/solar_blast.yaml | 12 + data/modules/solar_bolt.yaml | 11 + data/modules/solar_strike.yaml | 11 + data/modules/solar_surge.yaml | 12 + data/modules/solar_wave.yaml | 12 + data/modules/superheat.yaml | 10 + data/modules/transport_deep_mine.yaml | 11 + data/modules/transport_fishing.yaml | 12 + data/modules/transport_forest.yaml | 12 + data/modules/transport_forge.yaml | 11 + data/modules/transport_mine.yaml | 12 + data/modules/transport_scavenge.yaml | 11 + data/modules/transport_town.yaml | 12 + data/objects/adamantite_rock.yaml | 23 +- data/objects/allotment_patch.yaml | 1 - data/objects/anglerfish_spot.yaml | 24 +- data/objects/bait_fishing_spot.yaml | 34 +- data/objects/cage_fishing_spot.yaml | 23 +- data/objects/clay_rock.yaml | 25 +- data/objects/coal_rock.yaml | 23 +- data/objects/copper_rock.yaml | 28 +- data/objects/fire.yaml | 1 - data/objects/flower_patch.yaml | 1 - data/objects/fly_fishing_spot.yaml | 34 +- data/objects/gold_rock.yaml | 23 +- data/objects/harpoon_fishing_spot.yaml | 33 +- data/objects/herb_patch.yaml | 1 - data/objects/iron_gate.yaml | 16 +- data/objects/iron_rock.yaml | 23 +- data/objects/magic_tree.yaml | 25 +- data/objects/mahogany_tree.yaml | 25 +- data/objects/maple_tree.yaml | 25 +- data/objects/mithril_rock.yaml | 23 +- data/objects/net_fishing_spot.yaml | 45 +- data/objects/netrunner.yaml | 69 ++- data/objects/oak_tree.yaml | 25 +- data/objects/redwood_tree.yaml | 25 +- data/objects/runite_rock.yaml | 23 +- data/objects/sacred_eel_spot.yaml | 24 +- data/objects/scrap_pile.yaml | 19 +- data/objects/sheep.yaml | 27 +- data/objects/silver_rock.yaml | 23 +- data/objects/swamp_fishing_spot.yaml | 29 +- data/objects/teak_tree.yaml | 25 +- data/objects/tin_rock.yaml | 23 +- data/objects/tool_shed.yaml | 94 +++- data/objects/tree.yaml | 25 +- data/objects/willow_tree.yaml | 25 +- data/objects/yew_tree.yaml | 25 +- data/rooms/9.yaml | 1 + internal/action/action.go | 23 + internal/action/behavior.go | 6 - internal/action/recipe.go | 110 ++-- internal/action/store.go | 107 +--- internal/config/config.go | 1 + internal/game/action.go | 56 +- internal/game/action_agility.go | 18 +- internal/game/action_burn.go | 40 +- internal/game/action_clean.go | 11 +- internal/game/action_default_target.go | 14 +- internal/game/action_farm.go | 68 +-- internal/game/action_finishing_blow.go | 78 +++ internal/game/action_fletch.go | 22 +- internal/game/action_gather.go | 114 ++-- internal/game/action_identify.go | 11 +- internal/game/action_production.go | 798 ---------------------------- internal/game/action_room.go | 8 +- internal/game/action_search.go | 14 +- internal/game/action_state.go | 11 - internal/game/action_steal.go | 67 ++- internal/game/action_talk.go | 127 +---- internal/game/action_toggle.go | 48 -- internal/game/action_use.go | 25 +- internal/game/aggro.go | 3 +- internal/game/assassin.go | 112 +--- internal/game/buff.go | 2 +- internal/game/cmd_agility.go | 6 +- internal/game/cmd_attack.go | 394 ++++++-------- internal/game/cmd_autocast.go | 54 -- internal/game/cmd_autotrigger.go | 54 ++ internal/game/cmd_bank.go | 14 +- internal/game/cmd_clean.go | 4 +- internal/game/cmd_color.go | 3 +- internal/game/cmd_colortable.go | 3 +- internal/game/cmd_construct.go | 157 ------ internal/game/cmd_cook.go | 12 +- internal/game/cmd_craft.go | 78 +-- internal/game/cmd_description.go | 5 +- internal/game/cmd_drink.go | 4 +- internal/game/cmd_drop.go | 19 +- internal/game/cmd_eat.go | 4 +- internal/game/cmd_equipment.go | 3 +- internal/game/cmd_estate_directory.go | 27 +- internal/game/cmd_farm.go | 14 +- internal/game/cmd_fletch.go | 5 +- internal/game/cmd_get.go | 12 +- internal/game/cmd_handlers.go | 370 +++++++++++++ internal/game/cmd_id.go | 4 +- internal/game/cmd_inventory.go | 3 +- internal/game/cmd_jack.go | 11 +- internal/game/cmd_look.go | 43 +- internal/game/cmd_map.go | 3 +- internal/game/cmd_mix.go | 15 +- internal/game/cmd_mods.go | 109 ++-- internal/game/cmd_move.go | 24 +- internal/game/cmd_option.go | 2 +- internal/game/cmd_prompt.go | 3 +- internal/game/cmd_queued.go | 3 +- internal/game/cmd_quit.go | 5 +- internal/game/cmd_registry.go | 170 ++++++ internal/game/cmd_remove.go | 8 +- internal/game/cmd_say.go | 3 +- internal/game/cmd_score.go | 2 +- internal/game/cmd_search.go | 5 +- internal/game/cmd_shop.go | 276 +++++++--- internal/game/cmd_smelt.go | 12 +- internal/game/cmd_smith.go | 4 +- internal/game/cmd_sneak.go | 7 +- internal/game/cmd_stats.go | 2 +- internal/game/cmd_style.go | 2 +- internal/game/cmd_task.go | 3 +- internal/game/cmd_tech.go | 4 +- internal/game/cmd_trigger.go | 121 ++--- internal/game/cmd_use.go | 151 +++++- internal/game/cmd_walk.go | 6 +- internal/game/cmd_wear.go | 8 +- internal/game/color.go | 3 +- internal/game/construction.go | 74 +++ internal/game/death.go | 66 +++ internal/game/equip_stats.go | 10 + internal/game/game.go | 468 ++-------------- internal/game/hacking.go | 88 +-- internal/game/hacking/hacking.go | 63 +++ internal/game/hacking/hacking_liars_dice.go | 395 ++++++++++++++ internal/game/hacking/hacking_mastermind.go | 177 ++++++ internal/game/hacking/hacking_wumpus.go | 286 ++++++++++ internal/game/hacking_liars_dice.go | 395 -------------- internal/game/hacking_mastermind.go | 177 ------ internal/game/hacking_wumpus.go | 286 ---------- internal/game/help.go | 25 +- internal/game/login_account.go | 8 +- internal/game/messages.go | 13 + internal/game/player_flags.go | 55 ++ internal/game/production_core.go | 606 +++++++++++++++++++++ internal/game/production_menu.go | 209 ++++++++ internal/game/prompt.go | 6 +- internal/game/science.go | 194 ++----- internal/game/tick.go | 59 +- internal/game/utils.go | 7 + internal/net/server.go | 95 +--- internal/net/welcome.go | 82 +++ internal/object/item.go | 4 +- internal/object/object.go | 48 +- internal/player/player.go | 18 +- internal/world/ground.go | 189 +++++++ internal/world/mob.go | 41 +- internal/world/objects.go | 332 ++++++++++++ internal/world/world.go | 506 +----------------- worldbuilding_guide/README.md | 3 +- worldbuilding_guide/behaviors.md | 518 ++++++++++-------- worldbuilding_guide/conditions.md | 2 +- worldbuilding_guide/construction.md | 25 +- worldbuilding_guide/doors.md | 21 +- worldbuilding_guide/hidden_objects.md | 10 +- worldbuilding_guide/index.md | 5 +- worldbuilding_guide/mobs.md | 14 +- worldbuilding_guide/objects.md | 95 +++- worldbuilding_guide/quests.md | 88 ++- worldbuilding_guide/recipes.md | 4 +- worldbuilding_guide/tips.md | 2 +- worldbuilding_guide/wandering_objects.md | 18 +- 255 files changed, 7068 insertions(+), 6416 deletions(-) delete mode 100644 data/behaviors/chop_magic.yaml delete mode 100644 data/behaviors/chop_mahogany.yaml delete mode 100644 data/behaviors/chop_maple.yaml delete mode 100644 data/behaviors/chop_oak.yaml delete mode 100644 data/behaviors/chop_redwood.yaml delete mode 100644 data/behaviors/chop_teak.yaml delete mode 100644 data/behaviors/chop_tree.yaml delete mode 100644 data/behaviors/chop_willow.yaml delete mode 100644 data/behaviors/chop_yew.yaml delete mode 100644 data/behaviors/client_talk.yaml delete mode 100644 data/behaviors/estate_broker_talk.yaml delete mode 100644 data/behaviors/fish_angler.yaml delete mode 100644 data/behaviors/fish_bait.yaml delete mode 100644 data/behaviors/fish_cage.yaml delete mode 100644 data/behaviors/fish_fly.yaml delete mode 100644 data/behaviors/fish_harpoon.yaml delete mode 100644 data/behaviors/fish_net.yaml delete mode 100644 data/behaviors/fish_sacred_eel.yaml delete mode 100644 data/behaviors/fish_swamp.yaml delete mode 100644 data/behaviors/general_store.yaml delete mode 100644 data/behaviors/guard_talk.yaml delete mode 100644 data/behaviors/iron_gate_toggle.yaml delete mode 100644 data/behaviors/mine_adamantite.yaml delete mode 100644 data/behaviors/mine_clay.yaml delete mode 100644 data/behaviors/mine_coal.yaml delete mode 100644 data/behaviors/mine_copper.yaml delete mode 100644 data/behaviors/mine_gold.yaml delete mode 100644 data/behaviors/mine_iron.yaml delete mode 100644 data/behaviors/mine_mithril.yaml delete mode 100644 data/behaviors/mine_runite.yaml delete mode 100644 data/behaviors/mine_scrap.yaml delete mode 100644 data/behaviors/mine_silver.yaml delete mode 100644 data/behaviors/mine_tin.yaml delete mode 100644 data/behaviors/netrunner_talk.yaml delete mode 100644 data/behaviors/sawmill_operator_talk.yaml delete mode 100644 data/behaviors/shear_sheep.yaml delete mode 100644 data/behaviors/stall_guard_talk.yaml delete mode 100644 data/behaviors/tanner_talk.yaml delete mode 100644 data/behaviors/tool_shed_talk.yaml delete mode 100644 data/behaviors/weapons_shop.yaml delete mode 100644 data/help/autocast.yaml create mode 100644 data/help/autotrigger.yaml create mode 100644 data/modules/bio_blast.yaml create mode 100644 data/modules/bio_bolt.yaml create mode 100644 data/modules/bio_strike.yaml create mode 100644 data/modules/bio_surge.yaml create mode 100644 data/modules/bio_wave.yaml create mode 100644 data/modules/bones_to_nutrients.yaml create mode 100644 data/modules/chip_diamond.yaml create mode 100644 data/modules/chip_emerald.yaml create mode 100644 data/modules/chip_ruby.yaml create mode 100644 data/modules/chip_sapphire.yaml create mode 100644 data/modules/eco_blast.yaml create mode 100644 data/modules/eco_bolt.yaml create mode 100644 data/modules/eco_strike.yaml create mode 100644 data/modules/eco_surge.yaml create mode 100644 data/modules/eco_wave.yaml create mode 100644 data/modules/em_grab.yaml create mode 100644 data/modules/enchant_1.yaml create mode 100644 data/modules/enchant_2.yaml create mode 100644 data/modules/enchant_3.yaml create mode 100644 data/modules/enchant_4.yaml create mode 100644 data/modules/high_process.yaml create mode 100644 data/modules/hydro_blast.yaml create mode 100644 data/modules/hydro_bolt.yaml create mode 100644 data/modules/hydro_strike.yaml create mode 100644 data/modules/hydro_surge.yaml create mode 100644 data/modules/hydro_wave.yaml create mode 100644 data/modules/low_process.yaml create mode 100644 data/modules/plank_make.yaml create mode 100644 data/modules/solar_blast.yaml create mode 100644 data/modules/solar_bolt.yaml create mode 100644 data/modules/solar_strike.yaml create mode 100644 data/modules/solar_surge.yaml create mode 100644 data/modules/solar_wave.yaml create mode 100644 data/modules/superheat.yaml create mode 100644 data/modules/transport_deep_mine.yaml create mode 100644 data/modules/transport_fishing.yaml create mode 100644 data/modules/transport_forest.yaml create mode 100644 data/modules/transport_forge.yaml create mode 100644 data/modules/transport_mine.yaml create mode 100644 data/modules/transport_scavenge.yaml create mode 100644 data/modules/transport_town.yaml create mode 100644 internal/game/action_finishing_blow.go delete mode 100644 internal/game/action_production.go delete mode 100644 internal/game/action_toggle.go delete mode 100644 internal/game/cmd_autocast.go create mode 100644 internal/game/cmd_autotrigger.go delete mode 100644 internal/game/cmd_construct.go create mode 100644 internal/game/cmd_handlers.go create mode 100644 internal/game/cmd_registry.go create mode 100644 internal/game/construction.go create mode 100644 internal/game/death.go create mode 100644 internal/game/hacking/hacking.go create mode 100644 internal/game/hacking/hacking_liars_dice.go create mode 100644 internal/game/hacking/hacking_mastermind.go create mode 100644 internal/game/hacking/hacking_wumpus.go delete mode 100644 internal/game/hacking_liars_dice.go delete mode 100644 internal/game/hacking_mastermind.go delete mode 100644 internal/game/hacking_wumpus.go create mode 100644 internal/game/messages.go create mode 100644 internal/game/player_flags.go create mode 100644 internal/game/production_core.go create mode 100644 internal/game/production_menu.go create mode 100644 internal/net/welcome.go create mode 100644 internal/world/ground.go create mode 100644 internal/world/objects.go diff --git a/AGENTS.md b/AGENTS.md index 8903105..67b653e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ internal/ | `login_char.go` | Character creation, connection, rename, delete | | `cmd_*.go` | Command handlers for each command (see Commands section) | | `action.go` | StartAction, CancelAction, AdvanceActions, checkCondition, verbAliases, verbSkill | -| `action_*.go` | Action lifecycle per type (gather, talk, use, toggle, production) | +| `action_*.go` | Action lifecycle per type (gather, talk, use, production) | | `action_burn.go` | Burn/stoke firemaking (standalone, not behavior-YAML-driven) | | `action_search.go` | Search bird's nests for loot | | `action_room.go` | RunEnterSteps (on_enter scripts), BroadcastRespawns | @@ -123,7 +123,7 @@ data/rooms/.yaml Room definitions (exits, objects, mobs, spawns, on_ent data/items/.yaml Item definitions (stats, equip_slot, tool_type, burn_ticks, search_table, etc.) data/mobs/.yaml Mob definitions (combat stats, drops, behavior — NO wander config) data/objects/.yaml Object definitions (name, behavior, hidden, inroom_description, removal_item, props) -data/behaviors/.yaml Behaviors (gather, talk, use, toggle) +data/behaviors/.yaml Behaviors (gather, talk, use) data/drops/.yaml Shared drop tables (weighted item lists) data/help/.yaml Help topics data/recipes/.yaml Recipe definitions (cooking, crafting, smithing, etc.) @@ -204,7 +204,7 @@ Item and object YAML `color` fields also support gradient syntax: `color: "g:196 | `smith [item]` | Smith bars into items at an anvil with a hammer (Smithing skill) | | `mix [item]` | Mix potions from unfinished potions and reagents (Pharmacy skill) | | `talk` / `speak` / `ask ` | NPC conversations (objects or mobs with behavior) | -| `pull` / `push ` | Toggle interactions (levers, gates) | +| `pull` / `push ` | Interact with objects (shortcut for use) | | `search ` | Search bird's nests / searchable items (rolls on search_table) | | `burn [item]` | Start a fire (smart default: auto-select single log type) | | `stoke [item]` | Add logs to existing fire for XP | @@ -242,12 +242,12 @@ Queue feedback controlled by `queue_silently` option (default on). ## Action System Actions that interact with objects/mobs route through `StartAction()` in `action.go`: -1. Normalizes verb via `verbAliases` map (mine/chop/fish/cut → gather, talk/speak/ask → talk, pull/push → toggle) +1. Normalizes verb via `verbAliases` map (mine/chop/fish/cut → gather, talk/speak/ask → talk) 2. Multiple object types → "That's ambiguous, which one?" 3. Searches object instances in room (numbered targeting via `N.name`) 4. Falls back to mob instances if no object matches 5. Loads the behavior from YAML -6. Routes to type-specific handler (startGather, startTalk, startUse, startToggle) +6. Routes to type-specific handler (startGather, startTalk, startUse) The `verbSkill` map maps verb → skill name for default target resolution: - mine → mining, chop/cut → woodcutting, fish → fishing @@ -260,7 +260,6 @@ The `verbSkill` map maps verb → skill name for default target resolution: | `combating` | attack/kill | Combat with attack/defense rolls | | `using` | use | Crafting — consume items, produce output | | `talking` | talk/speak/ask | NPC conversation trees with choices/conditions/actions | -| `toggling` | pull/push | Levers/switches — set world flags | | `burning` | burn | Firemaking startup (3 phases) | | `stoking` | stoke | Adding logs to existing fire | | `searching` | search | Search bird's nests / items with search_table | @@ -283,7 +282,6 @@ The `verbSkill` map maps verb → skill name for default target resolution: | `gather` | mine, chop, cut, fish | Resource gathering with skill checks, tool requirements, depletion/respawn | | `use` | use | Crafting stations — consume items, produce output, optional skill checks | | `talk` | talk, speak, ask | NPC conversation trees with choices, conditions, actions | -| `toggle` | pull, push | Levers, switches, gates — set world flags, conditional on existing values | ### Behaviors Not in YAML (hardcoded) @@ -341,7 +339,7 @@ clamped to [0, cfg.Cap] ### Condition System -Used by exits, talk options, on-enter scripts, and toggle checks: +Used by exits, talk options, on-enter scripts, and use_interactions checks: | Field | Scope | Example | |---|---|---| @@ -592,7 +590,7 @@ Implemented: Mining, Fishing, Woodcutting, Firemaking, Cooking, Smithing (smelti - `StartAction` searches objects first, then mobs. A mob must have `behavior:` set on its YAML def to be interactable. - Exits changed from `map[ExitDir]int` to `map[ExitDir]ExitDef` — old `north: 2` still works via custom `UnmarshalYAML`. Same pattern for `RoomMob`. -- `checkCondition` is the single condition evaluator — used by exits, talk options, on-enter scripts, and toggle checks. +- `checkCondition` is the single condition evaluator — used by exits, talk options, on-enter scripts, and use_interactions checks. - Object `Hidden: true` means the object doesn't appear in room listings but is still interactable. - Alias expansion happens before command dispatch and can shadow built-in commands. - `say` preserves case (alias expansion preserves case, dispatch extracts original input text). diff --git a/data/behaviors/chop_magic.yaml b/data/behaviors/chop_magic.yaml deleted file mode 100644 index 3d55658..0000000 --- a/data/behaviors/chop_magic.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_magic -type: gather -skill: woodcutting -level: 75 -xp: 250 -base_wait: 12 -tools: [axe] -success: - base: 0.15 - per_level: 0.01 - cap: 0.65 -gather_message: "You swing your axe at the magic tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: magic_logs - weight: 100 - depletes: false - message: "You get some {135}magic logs{/}." -respawn_timer: 199 -deplete_timer: 390 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_mahogany.yaml b/data/behaviors/chop_mahogany.yaml deleted file mode 100644 index dd6c4c5..0000000 --- a/data/behaviors/chop_mahogany.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_mahogany -type: gather -skill: woodcutting -level: 50 -xp: 125 -base_wait: 8 -tools: [axe] -success: - base: 0.25 - per_level: 0.01 - cap: 0.75 -gather_message: "You swing your axe at the mahogany tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: mahogany_logs - weight: 100 - depletes: false - message: "You get some {131}mahogany logs{/}." -respawn_timer: 14 -deplete_timer: 100 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_maple.yaml b/data/behaviors/chop_maple.yaml deleted file mode 100644 index 8505217..0000000 --- a/data/behaviors/chop_maple.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_maple -type: gather -skill: woodcutting -level: 45 -xp: 100 -base_wait: 8 -tools: [axe] -success: - base: 0.28 - per_level: 0.01 - cap: 0.78 -gather_message: "You swing your axe at the maple tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: maple_logs - weight: 100 - depletes: false - message: "You get some {173}maple logs{/}." -respawn_timer: 59 -deplete_timer: 100 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_oak.yaml b/data/behaviors/chop_oak.yaml deleted file mode 100644 index c9cfb48..0000000 --- a/data/behaviors/chop_oak.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_oak -type: gather -skill: woodcutting -level: 15 -xp: 37 -base_wait: 6 -tools: [axe] -success: - base: 0.40 - per_level: 0.01 - cap: 0.90 -gather_message: "You swing your axe at the oak tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: oak_logs - weight: 100 - depletes: false - message: "You get some {113}oak logs{/}." -respawn_timer: 14 -deplete_timer: 45 -nest_chance: 256 -respawn_broadcast: "An {name} grows back." diff --git a/data/behaviors/chop_redwood.yaml b/data/behaviors/chop_redwood.yaml deleted file mode 100644 index 9652f7a..0000000 --- a/data/behaviors/chop_redwood.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_redwood -type: gather -skill: woodcutting -level: 90 -xp: 380 -base_wait: 14 -tools: [axe] -success: - base: 0.10 - per_level: 0.01 - cap: 0.60 -gather_message: "You swing your axe at the redwood tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: redwood_logs - weight: 100 - depletes: false - message: "You get some {131}redwood logs{/}." -respawn_timer: 199 -deplete_timer: 440 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_teak.yaml b/data/behaviors/chop_teak.yaml deleted file mode 100644 index f1ed770..0000000 --- a/data/behaviors/chop_teak.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_teak -type: gather -skill: woodcutting -level: 35 -xp: 85 -base_wait: 6 -tools: [axe] -success: - base: 0.32 - per_level: 0.01 - cap: 0.82 -gather_message: "You swing your axe at the teak tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: teak_logs - weight: 100 - depletes: false - message: "You get some {179}teak logs{/}." -respawn_timer: 15 -deplete_timer: 50 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_tree.yaml b/data/behaviors/chop_tree.yaml deleted file mode 100644 index 4a12509..0000000 --- a/data/behaviors/chop_tree.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_tree -type: gather -skill: woodcutting -level: 1 -xp: 25 -base_wait: 4 -tools: [axe] -success: - base: 0.50 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your axe at the tree..." -depleted_message: "You don't see any usable wood in this tree." -fail_message: "You swing but get no logs." -drops: - - item_id: logs - weight: 100 - depletes: true - message: "You get some {107}logs{/}." -respawn_timer: 80 -exhausted_message: "The tree comes crashing down!" -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_willow.yaml b/data/behaviors/chop_willow.yaml deleted file mode 100644 index 597072e..0000000 --- a/data/behaviors/chop_willow.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_willow -type: gather -skill: woodcutting -level: 30 -xp: 67 -base_wait: 6 -tools: [axe] -success: - base: 0.35 - per_level: 0.01 - cap: 0.85 -gather_message: "You swing your axe at the willow tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: willow_logs - weight: 100 - depletes: false - message: "You get some {114}willow logs{/}." -respawn_timer: 14 -deplete_timer: 50 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/chop_yew.yaml b/data/behaviors/chop_yew.yaml deleted file mode 100644 index 1586b4b..0000000 --- a/data/behaviors/chop_yew.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: chop_yew -type: gather -skill: woodcutting -level: 60 -xp: 175 -base_wait: 10 -tools: [axe] -success: - base: 0.20 - per_level: 0.01 - cap: 0.70 -gather_message: "You swing your axe at the yew tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: yew_logs - weight: 100 - depletes: false - message: "You get some {108}yew logs{/}." -respawn_timer: 99 -deplete_timer: 190 -nest_chance: 256 -respawn_broadcast: "A {name} grows back." diff --git a/data/behaviors/client_talk.yaml b/data/behaviors/client_talk.yaml deleted file mode 100644 index 84e4f6a..0000000 --- a/data/behaviors/client_talk.yaml +++ /dev/null @@ -1,217 +0,0 @@ -id: client_talk -type: talk -nodes: - start: - message: "The Client looks up from their data pad. \"What do you need?\"" - options: - - text: "\"I need a job.\"" - goto: assign_task - condition: - player_flag: assassin_task_mob - not: true - - text: "\"What's my current task?\"" - goto: current_task - condition: - player_flag: assassin_task_mob - not: false - - text: "\"I want to skip my task.\"" - goto: skip_confirm - condition: - player_flag: assassin_task_mob - not: false - - text: "\"I want to extend my task.\"" - goto: extend_confirm - condition: - all_of: - - player_flag: assassin_task_mob - not: false - - player_flag: assassin_unlocked_extend - value: true - - text: "\"I'd like to browse the Reputation Shop.\"" - goto: rep_shop - - text: "\"I need supplies.\"" - goto: supplies - - text: "\"Goodbye.\"" - end: true - - assign_task: - message: "\"Let me check what's available...\" The Client scrolls through their data pad." - action: - assign_task: true - options: - - text: "\"Understood.\"" - end: true - - text: "\"What else do you have?\"" - goto: start - - current_task: - message: "\"Let me check your file.\" The Client pulls up your record." - options: - - text: "\"Okay.\"" - end: true - - skip_confirm: - message: "\"Skipping a task costs 30 Reputation and resets your streak. Are you sure?\"" - options: - - text: "\"Yes, skip it.\"" - goto: skip_done - - text: "\"Never mind.\"" - goto: start - - skip_done: - message: "\"Task cancelled. Your streak has been reset.\"" - action: - skip_task: true - options: - - text: "\"Give me a new one.\"" - goto: assign_task - - text: "\"Goodbye.\"" - end: true - - extend_confirm: - message: "\"Extending your task costs 30 Reputation and adds more kills. Want to proceed?\"" - options: - - text: "\"Yes, extend it.\"" - goto: extend_done - - text: "\"Never mind.\"" - goto: start - - extend_done: - message: "\"Done. I've added more targets to your contract.\"" - action: - extend_task: true - options: - - text: "\"Thanks.\"" - end: true - - rep_shop: - message: "\"Here's what I've got. All purchases are permanent.\"" - options: - - text: "\"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows.\"" - goto: buy_auto_salt - condition: - player_flag: assassin_unlocked_auto_salt - not: true - - text: "\"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows.\"" - goto: buy_auto_acid - condition: - player_flag: assassin_unlocked_auto_acid_vial - not: true - - text: "\"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn.\"" - goto: buy_superiors - condition: - player_flag: assassin_unlocked_superiors - not: true - - text: "\"Unlock Extended Tasks (100 Rep) - Allows extending tasks.\"" - goto: buy_extend_unlock - condition: - player_flag: assassin_unlocked_extend - not: true - - text: "\"Back.\"" - goto: start - - buy_auto_salt: - message: "\"Auto-salt purchased. You'll no longer consume salt on finishing blows.\"" - action: - reputation_cost: 200 - set_player_flags: - assassin_unlocked_auto_salt: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - buy_auto_acid: - message: "\"Auto-acid purchased. Acid vials will no longer be consumed.\"" - action: - reputation_cost: 200 - set_player_flags: - assassin_unlocked_auto_acid_vial: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - buy_superiors: - message: "\"Superior encounters unlocked. Watch yourself out there.\"" - action: - reputation_cost: 300 - set_player_flags: - assassin_unlocked_superiors: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - buy_extend_unlock: - message: "\"You can now extend tasks via our conversation.\"" - action: - reputation_cost: 100 - set_player_flags: - assassin_unlocked_extend: true - options: - - text: "\"Thanks.\"" - goto: rep_shop - - supplies: - message: "\"I stock everything you need for the job. Cheap, too.\"" - options: - - text: "\"Buy salt (5 credits each).\"" - goto: buy_salt - condition: - min_credits: 5 - - text: "\"Buy acid vial (10 credits each).\"" - goto: buy_acid - condition: - min_credits: 10 - - text: "\"Buy insulated gloves (50 credits).\"" - goto: buy_gloves - condition: - min_credits: 50 - - text: "\"Buy spectral visor (75 credits).\"" - goto: buy_visor - condition: - min_credits: 75 - - text: "\"Back.\"" - goto: start - - buy_salt: - message: "\"Here you go.\" The Client slides a packet of salt across the table." - action: - give_item: salt - cost: 5 - options: - - text: "\"Buy more.\"" - goto: buy_salt - condition: - min_credits: 5 - - text: "\"Thanks.\"" - goto: supplies - - buy_acid: - message: "\"Handle with care.\" The Client passes you a vial of corrosive acid." - action: - give_item: acid_vial - cost: 10 - options: - - text: "\"Buy more.\"" - goto: buy_acid - condition: - min_credits: 10 - - text: "\"Thanks.\"" - goto: supplies - - buy_gloves: - message: "\"These'll keep the current from frying your hands.\" The Client tosses you a pair of thick rubber gloves." - action: - give_item: insulated_gloves - cost: 50 - options: - - text: "\"Thanks.\"" - goto: supplies - - buy_visor: - message: "\"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain.\"" - action: - give_item: spectral_visor - cost: 75 - options: - - text: "\"Thanks.\"" - goto: supplies diff --git a/data/behaviors/estate_broker_talk.yaml b/data/behaviors/estate_broker_talk.yaml deleted file mode 100644 index b14323d..0000000 --- a/data/behaviors/estate_broker_talk.yaml +++ /dev/null @@ -1,49 +0,0 @@ -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/fish_angler.yaml b/data/behaviors/fish_angler.yaml deleted file mode 100644 index 95e5873..0000000 --- a/data/behaviors/fish_angler.yaml +++ /dev/null @@ -1,19 +0,0 @@ -id: fish_angler -type: gather -skill: fishing -level: 1 -base_wait: 6 -tools: [fishing_rod] -bait: fishing_bait -success: - base: 0.30 - per_level: 0.005 - cap: 0.80 -gather_message: "You cast your line into the deep water..." -fail_message: "Nothing seems to bite." -drops: - - item_id: raw_anglerfish - level: 82 - xp: 120 - weight: 100 - message: "You catch a {80}raw anglerfish{/}." diff --git a/data/behaviors/fish_bait.yaml b/data/behaviors/fish_bait.yaml deleted file mode 100644 index 0d6bc4e..0000000 --- a/data/behaviors/fish_bait.yaml +++ /dev/null @@ -1,29 +0,0 @@ -id: fish_bait -type: gather -skill: fishing -level: 1 -base_wait: 5 -tools: [fishing_rod] -bait: fishing_bait -success: - base: 0.50 - per_level: 0.005 - cap: 0.90 -gather_message: "You cast your line into the water..." -fail_message: "Nothing seems to bite." -drops: - - item_id: raw_sardine - level: 5 - xp: 20 - weight: 60 - message: "You catch a {75}raw sardine{/}." - - item_id: raw_herring - level: 10 - xp: 30 - weight: 40 - message: "You catch a {75}raw herring{/}." - - item_id: raw_pike - level: 25 - xp: 60 - weight: 30 - message: "You catch a {69}raw pike{/}." diff --git a/data/behaviors/fish_cage.yaml b/data/behaviors/fish_cage.yaml deleted file mode 100644 index a5d0241..0000000 --- a/data/behaviors/fish_cage.yaml +++ /dev/null @@ -1,18 +0,0 @@ -id: fish_cage -type: gather -skill: fishing -level: 1 -base_wait: 6 -tools: [lobster_pot] -success: - base: 0.45 - per_level: 0.005 - cap: 0.85 -gather_message: "You lower your lobster pot into the water..." -fail_message: "The pot comes up empty." -drops: - - item_id: raw_lobster - level: 40 - xp: 90 - weight: 100 - message: "You catch a {33}raw lobster{/}." diff --git a/data/behaviors/fish_fly.yaml b/data/behaviors/fish_fly.yaml deleted file mode 100644 index 1a86b64..0000000 --- a/data/behaviors/fish_fly.yaml +++ /dev/null @@ -1,29 +0,0 @@ -id: fish_fly -type: gather -skill: fishing -level: 1 -base_wait: 5 -tools: [fly_rod] -bait: feather -success: - base: 0.50 - per_level: 0.005 - cap: 0.90 -gather_message: "You cast your fly into the water..." -fail_message: "Nothing seems to bite." -drops: - - item_id: raw_trout - level: 20 - xp: 50 - weight: 50 - message: "You catch a {69}raw trout{/}." - - item_id: raw_salmon - level: 30 - xp: 70 - weight: 50 - message: "You catch a {69}raw salmon{/}." - - item_id: raw_rainbow_fish - level: 38 - xp: 80 - weight: 30 - message: "You catch a {177}raw rainbow fish{/}." diff --git a/data/behaviors/fish_harpoon.yaml b/data/behaviors/fish_harpoon.yaml deleted file mode 100644 index 3775573..0000000 --- a/data/behaviors/fish_harpoon.yaml +++ /dev/null @@ -1,28 +0,0 @@ -id: fish_harpoon -type: gather -skill: fishing -level: 1 -base_wait: 6 -tools: [harpoon] -success: - base: 0.40 - per_level: 0.005 - cap: 0.80 -gather_message: "You ready your harpoon..." -fail_message: "You fail to spear anything." -drops: - - item_id: raw_tuna - level: 35 - xp: 80 - weight: 60 - message: "You harpoon a {33}raw tuna{/}." - - item_id: raw_swordfish - level: 50 - xp: 100 - weight: 50 - message: "You harpoon a {33}raw swordfish{/}." - - item_id: raw_shark - level: 76 - xp: 110 - weight: 30 - message: "You harpoon a {27}raw shark{/}." diff --git a/data/behaviors/fish_net.yaml b/data/behaviors/fish_net.yaml deleted file mode 100644 index 46c0008..0000000 --- a/data/behaviors/fish_net.yaml +++ /dev/null @@ -1,39 +0,0 @@ -id: fish_net -type: gather -skill: fishing -level: 1 -base_wait: 5 -tools: [small_net, big_net] -success: - base: 0.50 - per_level: 0.005 - cap: 0.90 -gather_message: "You cast your net into the water..." -depleted_message: "You don't see any fish swimming here." -fail_message: "You catch nothing." -drops: - - item_id: raw_shrimps - level: 1 - xp: 10 - weight: 50 - message: "You catch some {75}raw shrimps{/}." - - item_id: raw_anchovies - level: 1 - xp: 10 - weight: 50 - message: "You catch some {75}raw anchovies{/}." - - item_id: raw_mackerel - level: 16 - xp: 20 - weight: 50 - message: "You catch a {69}raw mackerel{/}." - - item_id: raw_cod - level: 23 - xp: 30 - weight: 50 - message: "You catch a {69}raw cod{/}." - - item_id: raw_bass - level: 46 - xp: 50 - weight: 50 - message: "You catch a {69}raw bass{/}." diff --git a/data/behaviors/fish_sacred_eel.yaml b/data/behaviors/fish_sacred_eel.yaml deleted file mode 100644 index f9712d1..0000000 --- a/data/behaviors/fish_sacred_eel.yaml +++ /dev/null @@ -1,19 +0,0 @@ -id: fish_sacred_eel -type: gather -skill: fishing -level: 1 -base_wait: 6 -tools: [fishing_rod] -bait: fishing_bait -success: - base: 0.25 - per_level: 0.005 - cap: 0.75 -gather_message: "You cast your line near the sacred waters..." -fail_message: "Nothing seems to bite." -drops: - - item_id: sacred_eel - level: 87 - xp: 125 - weight: 100 - message: "You catch a {177}sacred eel{/}." diff --git a/data/behaviors/fish_swamp.yaml b/data/behaviors/fish_swamp.yaml deleted file mode 100644 index 019e979..0000000 --- a/data/behaviors/fish_swamp.yaml +++ /dev/null @@ -1,24 +0,0 @@ -id: fish_swamp -type: gather -skill: fishing -level: 1 -base_wait: 5 -tools: [fishing_rod] -bait: fishing_bait -success: - base: 0.45 - per_level: 0.005 - cap: 0.85 -gather_message: "You cast your line into the murky swamp..." -fail_message: "Nothing seems to bite." -drops: - - item_id: raw_slimy_eel - level: 28 - xp: 80 - weight: 60 - message: "You catch a {108}raw slimy eel{/}." - - item_id: raw_cave_eel - level: 38 - xp: 80 - weight: 40 - message: "You catch a {80}raw cave eel{/}." diff --git a/data/behaviors/general_store.yaml b/data/behaviors/general_store.yaml deleted file mode 100644 index 657e842..0000000 --- a/data/behaviors/general_store.yaml +++ /dev/null @@ -1,49 +0,0 @@ -id: general_store -type: talk -nodes: - start: - message: "\"Welcome to the General Store! Need any supplies?\"" - options: - - text: "\"I'd like to browse.\"" - goto: shop - - text: "\"Goodbye.\"" - end: true - shop: - message: "\"Take your time. What can I get you?\"" - action: - shop: - message: "What would you like to buy or sell?" - items: - - item_id: fishing_rod - buy_price: 10 - sell_price: 2 - - item_id: fishing_bait - buy_price: 2 - sell_price: 0 - - item_id: matches - buy_price: 10 - sell_price: 2 - - item_id: firesteel - buy_price: 40 - sell_price: 10 - - item_id: lighter - buy_price: 30 - sell_price: 7 - - item_id: hammer - buy_price: 20 - sell_price: 5 - - item_id: knife - buy_price: 10 - sell_price: 2 - - item_id: chisel - buy_price: 10 - sell_price: 2 - - item_id: bread - buy_price: 10 - sell_price: 2 - - item_id: bowstring - buy_price: 20 - sell_price: 5 - options: - - text: "\"I'm done.\"" - goto: start diff --git a/data/behaviors/guard_talk.yaml b/data/behaviors/guard_talk.yaml deleted file mode 100644 index 4adde0b..0000000 --- a/data/behaviors/guard_talk.yaml +++ /dev/null @@ -1,52 +0,0 @@ -id: guard_talk -type: talk -nodes: - start: - message: "\"Halt! This area is restricted.\" The guard eyes you suspiciously." - options: - - text: "\"What's behind that gate?\"" - goto: about_gate - - text: "\"I have copper ore.\"" - goto: trade_ore - condition: - has_item: copper_ore - - text: "\"I have a pass.\"" - goto: has_pass - condition: - player_flag: got_pass - value: true - - text: "\"Goodbye.\"" - end: true - about_gate: - message: "\"Supplies. Weapons. Things you don't need to worry about.\" He shifts his weight. \"Tell you what — bring me some copper ore and I'll stamp you a pass.\"" - action: - set_player_flags: - talked_to_guard: true - options: - - text: "\"I'll be back.\"" - end: true - - text: "\"I have some right here.\"" - goto: trade_ore - condition: - has_item: copper_ore - - text: "\"Goodbye.\"" - end: true - trade_ore: - message: "\"Good quality ore. This'll do.\" He stamps a slip of paper and hands it to you. \"That's your pass. Don't lose it.\"" - action: - take_item: copper_ore - give_item: pass_stub - set_player_flags: - got_pass: true - talked_to_guard: true - options: - - text: "\"Thanks.\"" - end: true - has_pass: - message: "\"Let me see that...\" He examines your pass and nods. \"Alright, I'll open the gate for you.\"" - action: - set_flags: - gate_open: true - options: - - text: "\"Thanks.\"" - end: true diff --git a/data/behaviors/iron_gate_toggle.yaml b/data/behaviors/iron_gate_toggle.yaml deleted file mode 100644 index e009ca7..0000000 --- a/data/behaviors/iron_gate_toggle.yaml +++ /dev/null @@ -1,9 +0,0 @@ -id: iron_gate_toggle -type: toggle -message: "You push the heavy iron gate." -set_flags: - gate_open: true -check: - flag: gate_open - value: true - not: true diff --git a/data/behaviors/mine_adamantite.yaml b/data/behaviors/mine_adamantite.yaml deleted file mode 100644 index 0ba62db..0000000 --- a/data/behaviors/mine_adamantite.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_adamantite -type: gather -skill: mining -level: 70 -xp: 95 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.15 - per_level: 0.01 - cap: 0.85 -gather_message: "You swing your pickaxe at the adamantite rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: adamantite_ore - weight: 100 - depletes: true - message: "You manage to mine some {120}adamantite ore{/}." -respawn_timer: 140 -respawn_broadcast: "A green glint of adamantite catches your eye from some {name}." diff --git a/data/behaviors/mine_clay.yaml b/data/behaviors/mine_clay.yaml deleted file mode 100644 index 69e1ccf..0000000 --- a/data/behaviors/mine_clay.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_clay -type: gather -skill: mining -level: 1 -xp: 5 -base_wait: 4 -tools: [pickaxe] -success: - base: 0.60 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the clay rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: clay - weight: 100 - depletes: true - message: "You manage to mine some clay." -respawn_timer: 10 -respawn_broadcast: "Some clay fills in a nearby rock." diff --git a/data/behaviors/mine_coal.yaml b/data/behaviors/mine_coal.yaml deleted file mode 100644 index f2fb01d..0000000 --- a/data/behaviors/mine_coal.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_coal -type: gather -skill: mining -level: 30 -xp: 50 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.30 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the coal rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: coal - weight: 100 - depletes: true - message: "You manage to mine some {240}coal{/}." -respawn_timer: 80 -respawn_broadcast: "A dark vein of coal is visible in some {name}." diff --git a/data/behaviors/mine_copper.yaml b/data/behaviors/mine_copper.yaml deleted file mode 100644 index e805f29..0000000 --- a/data/behaviors/mine_copper.yaml +++ /dev/null @@ -1,25 +0,0 @@ -id: mine_copper -type: gather -skill: mining -level: 1 -xp: 17 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.50 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the rock..." -depleted_message: "You don't see any ore in this rock." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: copper_ore - weight: 90 - depletes: true - message: "You manage to mine some {178}copper ore{/}." - - table: gem_table - weight: 10 - depletes: false - message: "You spot a glint of something valuable!" -respawn_timer: 50 -respawn_broadcast: "A glint of copper catches your eye from some {name}." diff --git a/data/behaviors/mine_gold.yaml b/data/behaviors/mine_gold.yaml deleted file mode 100644 index 0257ee6..0000000 --- a/data/behaviors/mine_gold.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_gold -type: gather -skill: mining -level: 40 -xp: 65 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.25 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the gold rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: gold_ore - weight: 100 - depletes: true - message: "You manage to mine some {220}gold ore{/}." -respawn_timer: 90 -respawn_broadcast: "A golden glint catches your eye from some {name}." diff --git a/data/behaviors/mine_iron.yaml b/data/behaviors/mine_iron.yaml deleted file mode 100644 index 290d99a..0000000 --- a/data/behaviors/mine_iron.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_iron -type: gather -skill: mining -level: 15 -xp: 35 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.40 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the iron rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: iron_ore - weight: 100 - depletes: true - message: "You manage to mine some {250}iron ore{/}." -respawn_timer: 60 -respawn_broadcast: "A glint of iron catches your eye from some {name}." diff --git a/data/behaviors/mine_mithril.yaml b/data/behaviors/mine_mithril.yaml deleted file mode 100644 index f1d0b43..0000000 --- a/data/behaviors/mine_mithril.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_mithril -type: gather -skill: mining -level: 55 -xp: 80 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.20 - per_level: 0.01 - cap: 0.90 -gather_message: "You swing your pickaxe at the mithril rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: mithril_ore - weight: 100 - depletes: true - message: "You manage to mine some {75}mithril ore{/}." -respawn_timer: 110 -respawn_broadcast: "A blue glint of mithril catches your eye from some {name}." diff --git a/data/behaviors/mine_runite.yaml b/data/behaviors/mine_runite.yaml deleted file mode 100644 index 534a78a..0000000 --- a/data/behaviors/mine_runite.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_runite -type: gather -skill: mining -level: 85 -xp: 125 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.08 - per_level: 0.01 - cap: 0.80 -gather_message: "You swing your pickaxe at the runite rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: runite_ore - weight: 100 - depletes: true - message: "You manage to mine some {87}runite ore{/}." -respawn_timer: 180 -respawn_broadcast: "A pale blue glint of runite catches your eye from some {name}." diff --git a/data/behaviors/mine_scrap.yaml b/data/behaviors/mine_scrap.yaml deleted file mode 100644 index c381b1c..0000000 --- a/data/behaviors/mine_scrap.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_scrap -type: gather -skill: mining -level: 1 -xp: 5 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.55 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the scrap pile..." -fail_message: "You find nothing useful." -drops: - - item_id: scrap_metal - weight: 100 - depletes: true - message: "You salvage some {243}scrap metal{/}." -respawn_timer: 50 -respawn_broadcast: "The scrap pile looks worth mining again." diff --git a/data/behaviors/mine_silver.yaml b/data/behaviors/mine_silver.yaml deleted file mode 100644 index 259ab72..0000000 --- a/data/behaviors/mine_silver.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_silver -type: gather -skill: mining -level: 20 -xp: 40 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.35 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the silver rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: silver_ore - weight: 100 - depletes: true - message: "You manage to mine some {253}silver ore{/}." -respawn_timer: 70 -respawn_broadcast: "A silvery glint catches your eye from some {name}." diff --git a/data/behaviors/mine_tin.yaml b/data/behaviors/mine_tin.yaml deleted file mode 100644 index ebc130a..0000000 --- a/data/behaviors/mine_tin.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: mine_tin -type: gather -skill: mining -level: 1 -xp: 17 -base_wait: 10 -tools: [pickaxe] -success: - base: 0.50 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your pickaxe at the rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: tin_ore - weight: 100 - depletes: true - message: "You manage to mine some {253}tin ore{/}." -respawn_timer: 50 -respawn_broadcast: "A glint of tin catches your eye from some {name}." diff --git a/data/behaviors/netrunner_talk.yaml b/data/behaviors/netrunner_talk.yaml deleted file mode 100644 index e20f12f..0000000 --- a/data/behaviors/netrunner_talk.yaml +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 338ee61..0000000 --- a/data/behaviors/sawmill_operator_talk.yaml +++ /dev/null @@ -1,37 +0,0 @@ -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/behaviors/shear_sheep.yaml b/data/behaviors/shear_sheep.yaml deleted file mode 100644 index a7061c3..0000000 --- a/data/behaviors/shear_sheep.yaml +++ /dev/null @@ -1,20 +0,0 @@ -id: shear_sheep -type: gather -skill: crafting -level: 1 -xp: 0 -base_wait: 3 -tools: [shears] -success: - base: 0.95 - per_level: 0 - cap: 0.95 -gather_message: "You hold the sheep still and begin shearing..." -fail_message: "The sheep wriggles free!" -drops: - - item_id: wool - weight: 100 - depletes: true - message: "You shear some wool from the sheep." -respawn_timer: 100 -respawn_broadcast: "A sheep grows a fresh coat of wool." diff --git a/data/behaviors/stall_guard_talk.yaml b/data/behaviors/stall_guard_talk.yaml deleted file mode 100644 index c9434af..0000000 --- a/data/behaviors/stall_guard_talk.yaml +++ /dev/null @@ -1,36 +0,0 @@ -id: stall_guard_talk -type: talk -nodes: - start: - message: "Caught you red-handed! You have three options, thief." - options: - - text: "\"I'll pay a fine. (500 credits)\"" - goto: bribe - condition: - min_credits: 500 - - text: "\"Take me to jail.\"" - goto: jail - - text: "\"You'll have to catch me first!\"" - goto: fight - bribe: - message: "Smart choice. Hand over 500 credits and we'll forget this happened." - action: - cost: 500 - options: - - text: "\"Fine, take it.\"" - end: true - jail: - message: "Off to the detention cell with you!" - action: - teleport: 162 - options: - - text: "(You are dragged away)" - end: true - fight: - message: "Then defend yourself!" - action: - set_flags: - guard_hostile: true - options: - - text: "(The guard attacks!)" - end: true \ No newline at end of file diff --git a/data/behaviors/tanner_talk.yaml b/data/behaviors/tanner_talk.yaml deleted file mode 100644 index edc0387..0000000 --- a/data/behaviors/tanner_talk.yaml +++ /dev/null @@ -1,46 +0,0 @@ -id: tanner_talk -type: talk -nodes: - start: - message: "\"Bring me some cowhides and I'll tan them for you. Leather is 10 credits, hard leather is 50.\"" - options: - - text: "\"Tan a cowhide into leather (10 credits).\"" - goto: tan_leather - condition: - all_of: - - has_item: cowhide - - min_credits: 10 - - text: "\"Tan a cowhide into hard leather (50 credits).\"" - goto: tan_hard_leather - condition: - all_of: - - has_item: cowhide - - min_credits: 50 - - text: "\"Never mind.\"" - end: true - tan_leather: - message: "The tanner takes your cowhide and works it skillfully. \"Here's your leather.\"" - action: - take_item: cowhide - give_item: leather - cost: 10 - options: - - text: "\"Tan another.\"" - goto: start - condition: - has_item: cowhide - - text: "\"Thanks.\"" - end: true - tan_hard_leather: - message: "The tanner takes your cowhide and subjects it to an intense curing process. \"Here's your hard leather.\"" - action: - take_item: cowhide - give_item: hard_leather - cost: 50 - options: - - text: "\"Tan another.\"" - goto: start - condition: - has_item: cowhide - - text: "\"Thanks.\"" - end: true diff --git a/data/behaviors/tool_shed_talk.yaml b/data/behaviors/tool_shed_talk.yaml deleted file mode 100644 index 87db9fa..0000000 --- a/data/behaviors/tool_shed_talk.yaml +++ /dev/null @@ -1,86 +0,0 @@ -id: tool_shed_talk -type: talk -nodes: - start: - message: "The tool shed is open. What would you like to do?" - options: - - text: "Store rake" - goto: store_rake - condition: - has_item: rake - - text: "Store spade" - goto: store_spade - condition: - has_item: spade - - text: "Store watering can" - goto: store_watering_can - condition: - has_item: watering_can - - text: "Retrieve rake" - goto: retrieve_rake - condition: - player_flag: tool_shed_rake - - text: "Retrieve spade" - goto: retrieve_spade - condition: - player_flag: tool_shed_spade - - text: "Retrieve watering can" - goto: retrieve_watering_can - condition: - player_flag: tool_shed_watering_can - - text: "Never mind" - end: true - store_rake: - message: "You store the rake in the tool shed." - action: - take_item: rake - set_player_flags: - tool_shed_rake: true - options: - - text: "Continue" - goto: start - store_spade: - message: "You store the spade in the tool shed." - action: - take_item: spade - set_player_flags: - tool_shed_spade: true - options: - - text: "Continue" - goto: start - store_watering_can: - message: "You store the watering can in the tool shed." - action: - take_item: watering_can - set_player_flags: - tool_shed_watering_can: true - options: - - text: "Continue" - goto: start - retrieve_rake: - message: "You retrieve the rake from the tool shed." - action: - give_item: rake - set_player_flags: - tool_shed_rake: false - options: - - text: "Continue" - goto: start - retrieve_spade: - message: "You retrieve the spade from the tool shed." - action: - give_item: spade - set_player_flags: - tool_shed_spade: false - options: - - text: "Continue" - goto: start - retrieve_watering_can: - message: "You retrieve the watering can from the tool shed." - action: - give_item: watering_can - set_player_flags: - tool_shed_watering_can: false - options: - - text: "Continue" - goto: start diff --git a/data/behaviors/weapons_shop.yaml b/data/behaviors/weapons_shop.yaml deleted file mode 100644 index d12a4e6..0000000 --- a/data/behaviors/weapons_shop.yaml +++ /dev/null @@ -1,43 +0,0 @@ -id: weapons_shop -type: talk -nodes: - start: - message: "\"Welcome to the Smithy! Finest weapons and armor on the asteroid.\"" - options: - - text: "\"Show me what you've got.\"" - goto: shop - - text: "\"Nothing right now.\"" - end: true - shop: - message: "\"Here's the current inventory. All quality guaranteed.\"" - action: - shop: - message: "The Smithy — buy or sell weapons and armor." - items: - - item_id: bronze_sword - buy_price: 25 - sell_price: 6 - - item_id: iron_sword - buy_price: 60 - sell_price: 15 - - item_id: bronze_axe - buy_price: 20 - sell_price: 5 - - item_id: iron_axe - buy_price: 50 - sell_price: 12 - - item_id: bronze_pickaxe - buy_price: 20 - sell_price: 5 - - item_id: iron_pickaxe - buy_price: 50 - sell_price: 12 - - item_id: bronze_med_helm - buy_price: 16 - sell_price: 4 - - item_id: iron_med_helm - buy_price: 40 - sell_price: 10 - options: - - text: "\"That's all for now.\"" - goto: start \ No newline at end of file diff --git a/data/help/autocast.yaml b/data/help/autocast.yaml deleted file mode 100644 index 9b4fc40..0000000 --- a/data/help/autocast.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: "autocast" -category: "Commands" -description: | - Set a mod to autocast during combat. - - Usage: autocast - autocast off - autocast - - Set a combat mod to automatically trigger each attack tick - during combat. When autocast is active, attacking a mob will - use science combat instead of melee, consuming junk each tick. - - If you run out of junk, autocast disables and you switch to - melee attacks. - - autocast - Show current autocast setting - autocast solar bolt - Set autocast to Solar Bolt - autocast off - Disable autocast - - See also: trigger, mods, science diff --git a/data/help/autotrigger.yaml b/data/help/autotrigger.yaml new file mode 100644 index 0000000..604d42d --- /dev/null +++ b/data/help/autotrigger.yaml @@ -0,0 +1,21 @@ +name: "autotrigger" +category: "Commands" +description: | + Set a mod to autotrigger during combat. + + Usage: autotrigger + autotrigger off + autotrigger + + Set a combat mod to automatically trigger each attack tick + during combat. When autotrigger is active, attacking a mob will + use science combat instead of melee, consuming junk each tick. + + If you run out of junk, autotrigger disables and you switch to + melee attacks. + + autotrigger - Show current autotrigger setting + autotrigger solar bolt - Set autotrigger to Solar Bolt + autotrigger off - Disable autotrigger + + See also: trigger, mods, science diff --git a/data/help/mods.yaml b/data/help/mods.yaml index ed9dbcb..42873da 100644 --- a/data/help/mods.yaml +++ b/data/help/mods.yaml @@ -1,16 +1,19 @@ name: "mods" category: "Commands" description: | - Display available science mods. + Display available science modules. - Usage: mods - modlist + Usage: mods [all] + modlist [all] - Display all science mods you have the level to use, organized + Display science modules you have the level to use, organized by category (Combat, Processing, Utility, Transport, Enchantment). - Shows the junk cost for each mod (adjusted for your equipped - deck). Also shows your current autocast setting. + Use 'mods all' to see all modules, including ones above your + current Science level (locked modules are dimmed). - See also: trigger, autocast, science + Shows the junk cost for each module (adjusted for your equipped + deck). Also shows your current autotrigger setting. + + See also: trigger, autotrigger, science diff --git a/data/help/movement.yaml b/data/help/movement.yaml index 19a09b9..0623510 100644 --- a/data/help/movement.yaml +++ b/data/help/movement.yaml @@ -6,16 +6,20 @@ description: | Usage: north, south, east, west, up, down Aliases: n, s, e, w, u, d - Movement takes 4 game ticks by default. Certain equipment reduces + Movement takes 2 game ticks by default. Certain equipment reduces this delay: Cape of Agility Instant movement (1 tick) - Graceful set Each piece reduces movement by 0.25 ticks. - Full set (6 pieces) gives an extra 0.5 bonus, - for 2 ticks total. Uses fractional ticks. + Graceful set Each piece reduces movement by 0.15 ticks. + Full set (6 pieces) gives an extra 0.1 bonus, + for 1 tick total. Uses fractional ticks. The Cape of Agility overrides all Graceful bonuses. + The "walk" command (BFSPathfinding or direction sequences) takes + twice as long as normal movement per step (4 ticks default, 2 ticks + with the full Graceful set). + Moving interrupts any active gathering or crafting action. During combat, movement becomes a flee attempt. The mob continues diff --git a/data/help/science.yaml b/data/help/science.yaml index 7681310..e34aa6e 100644 --- a/data/help/science.yaml +++ b/data/help/science.yaml @@ -1,14 +1,14 @@ name: "science" category: "Skills" description: | - Science is the skill that powers mods — powerful modules that + Science is the skill that powers modules — powerful programs that can be triggered for combat, teleportation, item processing, and enchanting. Key concepts: - - Mods are triggered with 'trigger ' or 'cast ' - - Every mod costs a combination of junk items - - All mods also cost 1 scrap metal, UNLESS you have a deck + - Modules are triggered with 'trigger ' + - Every module costs a combination of junk items + - All modules also cost 1 scrap metal, UNLESS you have a deck equipped - Wielding a deck (science weapon) removes the scrap requirement @@ -17,16 +17,16 @@ description: | Junk types: solarjunk, hydrojunk, ecojunk, biojunk (from scavenging) - chaosjunk, deathjunk, bloodjunk (combat mod components) + chaosjunk, deathjunk, bloodjunk (combat module components) lawjunk (transport), cosmicjunk (enchantment) naturejunk (processing) Decks: basic deck, solar deck, hydro deck, eco deck, bio deck Advanced versions of each elemental deck also exist. - Combat: Use 'trigger ' or set 'autocast ' + Combat: Use 'trigger ' or set 'autotrigger ' then 'attack ' - Type 'mods' to see all mods you can currently use. + Type 'mods' to see all modules you can currently use. - See also: trigger, autocast, mods, scavenging + See also: trigger, autotrigger, mods, scavenging diff --git a/data/help/shop.yaml b/data/help/shop.yaml index a0fd187..cc20420 100644 --- a/data/help/shop.yaml +++ b/data/help/shop.yaml @@ -6,15 +6,22 @@ description: | Find a shopkeeper NPC and talk to them to open their shop. While browsing a shop, these commands are available: - browse List all items for sale with buy/sell prices - list Same as browse - buy Purchase an item with credits (use # to buy by number) - sell Sell an item from your inventory for credits - leave Exit the shop and return to conversation + browse List all items for sale with buy/sell prices + list Same as browse + buy Buy an item by name (e.g. "buy fishing bait") + buy Buy a quantity of an item (e.g. "buy 5 bait") + buy all Buy as many as you can afford and carry + sell Sell an item by name (e.g. "sell bronze sword") + sell Sell a quantity of an item (e.g. "sell 3 logs") + sell all Sell all of that item from your inventory + leave Exit the shop and return to conversation + + Partial names work: "buy bait" matches "fishing bait". If a name + matches multiple items, a list is shown to disambiguate. Shops buy items at a lower price than they sell them. Some items cannot be sold back. Shopkeepers can be found throughout the world: - General Store: Town Square (sells tools and supplies) - - Smithy: Forge (sells weapons, armor, and tools) \ No newline at end of file + - Smithy: Forge (sells weapons, armor, and tools) diff --git a/data/help/trigger.yaml b/data/help/trigger.yaml index 0e6823d..176108e 100644 --- a/data/help/trigger.yaml +++ b/data/help/trigger.yaml @@ -4,7 +4,6 @@ description: | Trigger a science mod. Usage: trigger [target] - cast [target] Trigger a science mod. Combat mods target a mob and initiate science-based combat. Utility mods act on items in your @@ -21,4 +20,4 @@ description: | All mods cost junk (and 1 scrap metal unless you have a deck equipped). Type 'mods' to see your available mods and costs. - See also: autocast, mods, science + See also: autotrigger, mods, science diff --git a/data/help/use.yaml b/data/help/use.yaml index 92c717f..8139f8e 100644 --- a/data/help/use.yaml +++ b/data/help/use.yaml @@ -4,28 +4,35 @@ description: | Use an item on a target — the universal interaction command. Usage: - use on Use item on a specific target - use with Combine two inventory items + use Interact with a station or object + use on Use item on a specific target + use with Combine two inventory items The "use" command routes to different systems based on the target: - Station object (furnace, anvil, fire, range) - If recipes match the item and station, opens a production menu. + Station object (furnace, anvil, fire, range, workbench, etc.) + Shows a combined production menu of all recipes you can make. Object with use_interactions Shows a message or triggers an action (flags, teleport, etc.). + Some interactions need a specific item; bare interactions run + immediately. Object with behavior - Dispatches to the object's behavior (gather, talk, toggle). + Dispatches to the object's behavior (gather, talk, use). Inventory item If a MadeFrom recipe matches both items, starts a combine production action. You are prompted "How many?" before starting. Examples: - use trout on fire Cook trout on a campfire - use copper ore on furnace Smelt copper ore - use bronze bar on anvil Smith bronze items - use flour on water Combine into dough + use range Cook using a cooking range + use furnace Smelt ore or craft jewelry + use anvil Smith bars into equipment + use trout on fire Cook trout on a campfire + use copper ore on furnace Smelt copper ore + use bronze bar on anvil Smith bronze items + use flour on water Combine into dough + push gate Push a gate open (shortcut for use) See also: cook, smelt, smith, recipe diff --git a/data/mobs/client.yaml b/data/mobs/client.yaml index 9bcae4e..5e03cde 100644 --- a/data/mobs/client.yaml +++ b/data/mobs/client.yaml @@ -1,20 +1,219 @@ -id: client -name: The Client -description: "A shadowy figure in a dark coat. They speak in clipped, measured tones and seem to know everything about every creature in the sector." -unique: true -protected: true -behavior: client_talk +aggressive: false attack: 1 -strength: 1 defense: 1 +description: A shadowy figure in a dark coat. They speak in clipped, measured tones and seem to know everything about every creature in the sector. hp: 100 -speed: 5 -aggressive: false -respawn_ticks: 30 +id: client idle_descriptions: - - "reviews a holographic dossier" - - "marks a location on a worn star chart" - - "flips a credit chit between their fingers" - - "mutters coordinates under their breath" - - "glances at you appraisingly" - - "taps a data pad with a stylus" + - reviews a holographic dossier + - marks a location on a worn star chart + - flips a credit chit between their fingers + - mutters coordinates under their breath + - glances at you appraisingly + - taps a data pad with a stylus +name: The Client +protected: true +respawn_ticks: 30 +speed: 5 +strength: 1 +talk: + nodes: + assign_task: + action: + assign_task: true + message: '"Let me check what''s available..." The Client scrolls through their data pad.' + options: + - end: true + text: '"Understood."' + - goto: start + text: '"What else do you have?"' + buy_acid: + action: + cost: 10 + give_item: acid_vial + message: '"Handle with care." The Client passes you a vial of corrosive acid.' + options: + - condition: + min_credits: 10 + goto: buy_acid + text: '"Buy more."' + - goto: supplies + text: '"Thanks."' + buy_auto_acid: + action: + reputation_cost: 200 + set_player_flags: + assassin_unlocked_auto_acid_vial: true + message: '"Auto-acid purchased. Acid vials will no longer be consumed."' + options: + - goto: rep_shop + text: '"Thanks."' + buy_auto_salt: + action: + reputation_cost: 200 + set_player_flags: + assassin_unlocked_auto_salt: true + message: '"Auto-salt purchased. You''ll no longer consume salt on finishing blows."' + options: + - goto: rep_shop + text: '"Thanks."' + buy_extend_unlock: + action: + reputation_cost: 100 + set_player_flags: + assassin_unlocked_extend: true + message: '"You can now extend tasks via our conversation."' + options: + - goto: rep_shop + text: '"Thanks."' + buy_gloves: + action: + cost: 50 + give_item: insulated_gloves + message: '"These''ll keep the current from frying your hands." The Client tosses you a pair of thick rubber gloves.' + options: + - goto: supplies + text: '"Thanks."' + buy_salt: + action: + cost: 5 + give_item: salt + message: '"Here you go." The Client slides a packet of salt across the table.' + options: + - condition: + min_credits: 5 + goto: buy_salt + text: '"Buy more."' + - goto: supplies + text: '"Thanks."' + buy_superiors: + action: + reputation_cost: 300 + set_player_flags: + assassin_unlocked_superiors: true + message: '"Superior encounters unlocked. Watch yourself out there."' + options: + - goto: rep_shop + text: '"Thanks."' + buy_visor: + action: + cost: 75 + give_item: spectral_visor + message: '"Spectral frequency filter. Makes the invisible visible — and keeps their attacks from scrambling your brain."' + options: + - goto: supplies + text: '"Thanks."' + current_task: + message: '"Let me check your file." The Client pulls up your record.' + options: + - end: true + text: '"Okay."' + extend_confirm: + message: '"Extending your task costs 30 Reputation and adds more kills. Want to proceed?"' + options: + - goto: extend_done + text: '"Yes, extend it."' + - goto: start + text: '"Never mind."' + extend_done: + action: + extend_task: true + message: '"Done. I''ve added more targets to your contract."' + options: + - end: true + text: '"Thanks."' + rep_shop: + message: '"Here''s what I''ve got. All purchases are permanent."' + options: + - condition: + not: true + player_flag: assassin_unlocked_auto_salt + goto: buy_auto_salt + text: '"Auto-finish: Salt (200 Rep) - Never consume salt on finishing blows."' + - condition: + not: true + player_flag: assassin_unlocked_auto_acid_vial + goto: buy_auto_acid + text: '"Auto-finish: Acid Vial (200 Rep) - Never consume acid vials on finishing blows."' + - condition: + not: true + player_flag: assassin_unlocked_superiors + goto: buy_superiors + text: '"Unlock Superior Mobs (300 Rep) - Rare superior variants may spawn."' + - condition: + not: true + player_flag: assassin_unlocked_extend + goto: buy_extend_unlock + text: '"Unlock Extended Tasks (100 Rep) - Allows extending tasks."' + - goto: start + text: '"Back."' + skip_confirm: + message: '"Skipping a task costs 30 Reputation and resets your streak. Are you sure?"' + options: + - goto: skip_done + text: '"Yes, skip it."' + - goto: start + text: '"Never mind."' + skip_done: + action: + skip_task: true + message: '"Task cancelled. Your streak has been reset."' + options: + - goto: assign_task + text: '"Give me a new one."' + - end: true + text: '"Goodbye."' + start: + message: The Client looks up from their data pad. "What do you need?" + options: + - condition: + not: true + player_flag: assassin_task_mob + goto: assign_task + text: '"I need a job."' + - condition: + not: false + player_flag: assassin_task_mob + goto: current_task + text: '"What''s my current task?"' + - condition: + not: false + player_flag: assassin_task_mob + goto: skip_confirm + text: '"I want to skip my task."' + - condition: + all_of: + - not: false + player_flag: assassin_task_mob + - player_flag: assassin_unlocked_extend + value: true + goto: extend_confirm + text: '"I want to extend my task."' + - goto: rep_shop + text: '"I''d like to browse the Reputation Shop."' + - goto: supplies + text: '"I need supplies."' + - end: true + text: '"Goodbye."' + supplies: + message: '"I stock everything you need for the job. Cheap, too."' + options: + - condition: + min_credits: 5 + goto: buy_salt + text: '"Buy salt (5 credits each)."' + - condition: + min_credits: 10 + goto: buy_acid + text: '"Buy acid vial (10 credits each)."' + - condition: + min_credits: 50 + goto: buy_gloves + text: '"Buy insulated gloves (50 credits)."' + - condition: + min_credits: 75 + goto: buy_visor + text: '"Buy spectral visor (75 credits)."' + - goto: start + text: '"Back."' +unique: true diff --git a/data/mobs/estate_broker.yaml b/data/mobs/estate_broker.yaml index 0b56c4c..6698730 100644 --- a/data/mobs/estate_broker.yaml +++ b/data/mobs/estate_broker.yaml @@ -1,7 +1,54 @@ +color: "227" +description: A sharply-dressed real estate agent with a datapad and a practiced smile. id: estate_broker +idle: The estate broker flashes a professional smile. name: estate broker -behavior: estate_broker_talk +talk: + nodes: + 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: + - next: start + text: Good to know. + goodbye: + message: Come back when you're ready to invest in your future! + 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: + - action: + cost: 10 + set_player_flags: + owns_house_local_neighborhood: local_neighborhood + condition: + not: + player_flag: owns_house_local_neighborhood + next: purchased + text: Sounds great! I'll buy one. + - next: goodbye + text: Maybe later. + 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: + - next: start + text: Thanks! + start: + message: Welcome to the Local Neighborhood development! I'm selling premium housing plots for only 10 credits. Interested in buying a house? + options: + - action: + cost: 10 + set_player_flags: + owns_house_local_neighborhood: local_neighborhood + condition: + not: + player_flag: owns_house_local_neighborhood + next: purchased + text: Yes, I'd like to buy a house. + - condition: + player_flag: owns_house_local_neighborhood + next: already_own + text: I already own a house here. + - next: goodbye + text: No thanks. + - next: info + text: What do I get with a house? 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/general_store_clerk.yaml b/data/mobs/general_store_clerk.yaml index 842a98b..cc6d014 100644 --- a/data/mobs/general_store_clerk.yaml +++ b/data/mobs/general_store_clerk.yaml @@ -1,24 +1,71 @@ +aggressive: false +attack: 1 +attack_type: crush +crush_defense: 0 +defense: 1 +description: A friendly merchant with a tidy apron, standing behind a well-stocked counter. +hp: 50 id: general_store_clerk +idle_descriptions: + - arranges items on the shelves + - polishes the counter + - counts credits in the register + - greets a passerby with a nod name: Shopkeeper -description: "A friendly merchant with a tidy apron, standing behind a well-stocked counter." -behavior: general_store -unique: true protected: true -hp: 50 -attack: 1 -strength: 1 -defense: 1 -speed: 5 -aggressive: false +ranged_defense: 0 respawn_ticks: 30 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "arranges items on the shelves" - - "polishes the counter" - - "counts credits in the register" - - "greets a passerby with a nod" \ No newline at end of file +slash_defense: 0 +speed: 5 +stab_defense: 0 +strength: 1 +talk: + nodes: + shop: + action: + shop: + items: + - buy_price: 10 + item_id: fishing_rod + sell_price: 2 + - buy_price: 2 + item_id: fishing_bait + sell_price: 0 + - buy_price: 10 + item_id: matches + sell_price: 2 + - buy_price: 40 + item_id: firesteel + sell_price: 10 + - buy_price: 30 + item_id: lighter + sell_price: 7 + - buy_price: 20 + item_id: hammer + sell_price: 5 + - buy_price: 10 + item_id: knife + sell_price: 2 + - buy_price: 10 + item_id: chisel + sell_price: 2 + - buy_price: 10 + item_id: bread + sell_price: 2 + - buy_price: 20 + item_id: bowstring + sell_price: 5 + message: What would you like to buy or sell? + message: '"Take your time. What can I get you?"' + options: + - goto: start + text: '"I''m done."' + start: + message: '"Welcome to the General Store! Need any supplies?"' + options: + - goto: shop + text: '"I''d like to browse."' + - end: true + text: '"Goodbye."' +unique: true diff --git a/data/mobs/guard.yaml b/data/mobs/guard.yaml index 32617ce..04aacf5 100644 --- a/data/mobs/guard.yaml +++ b/data/mobs/guard.yaml @@ -1,26 +1,76 @@ -id: guard -name: Guard -description: "A stern-looking guard in weathered armor." -behavior: guard_talk -unique: true -protected: true +aggressive: false attack: 5 -strength: 5 +attack_bonus: 8 +attack_type: slash +crush_defense: 10 defense: 5 +description: A stern-looking guard in weathered armor. hp: 30 -speed: 5 -aggressive: false +id: guard +idle_descriptions: + - scans the area with a watchful eye + - adjusts the grip on his weapon + - nods curtly at passersby + - leans against the gate, arms folded +name: Guard +protected: true +ranged_defense: 12 respawn_ticks: 60 -attack_type: slash -attack_bonus: 8 -strength_bonus: 6 -stab_defense: 12 -slash_defense: 15 -crush_defense: 10 science_defense: -5 -ranged_defense: 12 -idle_descriptions: - - "scans the area with a watchful eye" - - "adjusts the grip on his weapon" - - "nods curtly at passersby" - - "leans against the gate, arms folded" +slash_defense: 15 +speed: 5 +stab_defense: 12 +strength: 5 +strength_bonus: 6 +talk: + nodes: + about_gate: + action: + set_player_flags: + talked_to_guard: true + message: '"Supplies. Weapons. Things you don''t need to worry about." He shifts his weight. "Tell you what — bring me some copper ore and I''ll stamp you a pass."' + options: + - end: true + text: '"I''ll be back."' + - condition: + has_item: copper_ore + goto: trade_ore + text: '"I have some right here."' + - end: true + text: '"Goodbye."' + has_pass: + action: + set_flags: + gate_open: true + message: '"Let me see that..." He examines your pass and nods. "Alright, I''ll open the gate for you."' + options: + - end: true + text: '"Thanks."' + start: + message: '"Halt! This area is restricted." The guard eyes you suspiciously.' + options: + - goto: about_gate + text: '"What''s behind that gate?"' + - condition: + has_item: copper_ore + goto: trade_ore + text: '"I have copper ore."' + - condition: + player_flag: got_pass + value: true + goto: has_pass + text: '"I have a pass."' + - end: true + text: '"Goodbye."' + trade_ore: + action: + give_item: pass_stub + set_player_flags: + got_pass: true + talked_to_guard: true + take_item: copper_ore + message: '"Good quality ore. This''ll do." He stamps a slip of paper and hands it to you. "That''s your pass. Don''t lose it."' + options: + - end: true + text: '"Thanks."' +unique: true diff --git a/data/mobs/sawmill_operator.yaml b/data/mobs/sawmill_operator.yaml index 64f6e45..99da8cf 100644 --- a/data/mobs/sawmill_operator.yaml +++ b/data/mobs/sawmill_operator.yaml @@ -1,7 +1,49 @@ +color: "186" +description: A burly worker in a thick apron, covered in sawdust. He operates the industrial sawmill. id: sawmill_operator +idle: The sawmill operator wipes sawdust from his goggles. name: sawmill operator -behavior: sawmill_operator_talk +talk: + nodes: + goodbye: + message: Come back anytime you need more planks! + prices: + message: |- + Here's what I charge per log: + Regular logs: 5 credits + Oak logs: 10 credits + Teak logs: 20 credits + Mahogany logs: 40 credits + + Want me to process your logs? + options: + - condition: + any_of: + - has_item: logs + - has_item: oak_logs + - has_item: teak_logs + - has_item: mahogany_logs + next: process + text: Yes, process all my logs. + - next: goodbye + text: No thanks. + process: + action: + sawmill: true + message: "" + 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: + - condition: + any_of: + - has_item: logs + - has_item: oak_logs + - has_item: teak_logs + - has_item: mahogany_logs + next: prices + text: Turn all my logs into planks. + - next: prices + text: What are your prices? + - next: goodbye + text: Nothing for now. 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/tanner.yaml b/data/mobs/tanner.yaml index 0a90998..a00f497 100644 --- a/data/mobs/tanner.yaml +++ b/data/mobs/tanner.yaml @@ -1,24 +1,68 @@ +aggressive: false +attack: 1 +attack_type: crush +crush_defense: 0 +defense: 1 +description: A weathered craftsman with stained hands and a leather apron. +hp: 50 id: tanner +idle_descriptions: + - scrapes at a piece of hide + - examines a stack of leathers + - sharpens a tanning knife + - wipes sweat from his brow name: Tanner -description: "A weathered craftsman with stained hands and a leather apron." -behavior: tanner_talk -unique: true protected: true -hp: 50 -attack: 1 -strength: 1 -defense: 1 -speed: 5 -aggressive: false +ranged_defense: 0 respawn_ticks: 30 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "scrapes at a piece of hide" - - "examines a stack of leathers" - - "sharpens a tanning knife" - - "wipes sweat from his brow" +slash_defense: 0 +speed: 5 +stab_defense: 0 +strength: 1 +talk: + nodes: + start: + message: '"Bring me some cowhides and I''ll tan them for you. Leather is 10 credits, hard leather is 50."' + options: + - condition: + all_of: + - has_item: cowhide + - min_credits: 10 + goto: tan_leather + text: '"Tan a cowhide into leather (10 credits)."' + - condition: + all_of: + - has_item: cowhide + - min_credits: 50 + goto: tan_hard_leather + text: '"Tan a cowhide into hard leather (50 credits)."' + - end: true + text: '"Never mind."' + tan_hard_leather: + action: + cost: 50 + give_item: hard_leather + take_item: cowhide + message: The tanner takes your cowhide and subjects it to an intense curing process. "Here's your hard leather." + options: + - condition: + has_item: cowhide + goto: start + text: '"Tan another."' + - end: true + text: '"Thanks."' + tan_leather: + action: + cost: 10 + give_item: leather + take_item: cowhide + message: The tanner takes your cowhide and works it skillfully. "Here's your leather." + options: + - condition: + has_item: cowhide + goto: start + text: '"Tan another."' + - end: true + text: '"Thanks."' +unique: true diff --git a/data/mobs/weapons_smith.yaml b/data/mobs/weapons_smith.yaml index da6ef3b..4138a1b 100644 --- a/data/mobs/weapons_smith.yaml +++ b/data/mobs/weapons_smith.yaml @@ -1,24 +1,65 @@ +aggressive: false +attack: 1 +attack_type: crush +crush_defense: 0 +defense: 1 +description: A burly smith with soot-stained hands and a heavy leather apron, standing proudly by the forge. +hp: 80 id: weapons_smith +idle_descriptions: + - hammers at a glowing blade on the anvil + - wipes soot from his brow + - inspects a finished sword + - tends to the furnace fire name: Weaponsmith -description: "A burly smith with soot-stained hands and a heavy leather apron, standing proudly by the forge." -behavior: weapons_shop -unique: true protected: true -hp: 80 -attack: 1 -strength: 1 -defense: 1 -speed: 5 -aggressive: false +ranged_defense: 0 respawn_ticks: 30 -attack_type: crush -stab_defense: 0 -slash_defense: 0 -crush_defense: 0 science_defense: 0 -ranged_defense: 0 -idle_descriptions: - - "hammers at a glowing blade on the anvil" - - "wipes soot from his brow" - - "inspects a finished sword" - - "tends to the furnace fire" \ No newline at end of file +slash_defense: 0 +speed: 5 +stab_defense: 0 +strength: 1 +talk: + nodes: + shop: + action: + shop: + items: + - buy_price: 25 + item_id: bronze_sword + sell_price: 6 + - buy_price: 60 + item_id: iron_sword + sell_price: 15 + - buy_price: 20 + item_id: bronze_axe + sell_price: 5 + - buy_price: 50 + item_id: iron_axe + sell_price: 12 + - buy_price: 20 + item_id: bronze_pickaxe + sell_price: 5 + - buy_price: 50 + item_id: iron_pickaxe + sell_price: 12 + - buy_price: 16 + item_id: bronze_med_helm + sell_price: 4 + - buy_price: 40 + item_id: iron_med_helm + sell_price: 10 + message: The Smithy — buy or sell weapons and armor. + message: '"Here''s the current inventory. All quality guaranteed."' + options: + - goto: start + text: '"That''s all for now."' + start: + message: '"Welcome to the Smithy! Finest weapons and armor on the asteroid."' + options: + - goto: shop + text: '"Show me what you''ve got."' + - end: true + text: '"Nothing right now."' +unique: true diff --git a/data/modules/bio_blast.yaml b/data/modules/bio_blast.yaml new file mode 100644 index 0000000..216b96c --- /dev/null +++ b/data/modules/bio_blast.yaml @@ -0,0 +1,12 @@ +id: bio_blast +name: Bio Blast +level: 41 +max_hit: 13 +base_xp: 25 +category: combat +element: bio +junk_cost: + biojunk: 3 + chaosjunk: 1 + deathjunk: 1 + scrap_metal: 1 diff --git a/data/modules/bio_bolt.yaml b/data/modules/bio_bolt.yaml new file mode 100644 index 0000000..848c562 --- /dev/null +++ b/data/modules/bio_bolt.yaml @@ -0,0 +1,11 @@ +id: bio_bolt +name: Bio Bolt +level: 17 +max_hit: 9 +base_xp: 13 +category: combat +element: bio +junk_cost: + biojunk: 2 + chaosjunk: 1 + scrap_metal: 1 diff --git a/data/modules/bio_strike.yaml b/data/modules/bio_strike.yaml new file mode 100644 index 0000000..25a08a6 --- /dev/null +++ b/data/modules/bio_strike.yaml @@ -0,0 +1,10 @@ +id: bio_strike +name: Bio Strike +level: 1 +max_hit: 4 +base_xp: 5 +category: combat +element: bio +junk_cost: + biojunk: 2 + scrap_metal: 1 diff --git a/data/modules/bio_surge.yaml b/data/modules/bio_surge.yaml new file mode 100644 index 0000000..2cb813a --- /dev/null +++ b/data/modules/bio_surge.yaml @@ -0,0 +1,11 @@ +id: bio_surge +name: Bio Surge +level: 81 +max_hit: 21 +base_xp: 44 +category: combat +element: bio +junk_cost: + biojunk: 7 + bloodjunk: 1 + scrap_metal: 1 diff --git a/data/modules/bio_wave.yaml b/data/modules/bio_wave.yaml new file mode 100644 index 0000000..835f27c --- /dev/null +++ b/data/modules/bio_wave.yaml @@ -0,0 +1,12 @@ +id: bio_wave +name: Bio Wave +level: 62 +max_hit: 17 +base_xp: 36 +category: combat +element: bio +junk_cost: + biojunk: 5 + deathjunk: 1 + bloodjunk: 1 + scrap_metal: 1 diff --git a/data/modules/bones_to_nutrients.yaml b/data/modules/bones_to_nutrients.yaml new file mode 100644 index 0000000..10f23fd --- /dev/null +++ b/data/modules/bones_to_nutrients.yaml @@ -0,0 +1,10 @@ +id: bones_to_nutrients +name: Bones to Nutrients +level: 15 +max_hit: 0 +base_xp: 25 +category: utility +junk_cost: + naturejunk: 2 + ecojunk: 2 + scrap_metal: 1 diff --git a/data/modules/chip_diamond.yaml b/data/modules/chip_diamond.yaml new file mode 100644 index 0000000..6622558 --- /dev/null +++ b/data/modules/chip_diamond.yaml @@ -0,0 +1,10 @@ +id: chip_diamond +name: Chip Diamond Bolts +level: 57 +max_hit: 0 +base_xp: 67 +category: enchant +junk_cost: + cosmicjunk: 1 + ecojunk: 10 + scrap_metal: 1 diff --git a/data/modules/chip_emerald.yaml b/data/modules/chip_emerald.yaml new file mode 100644 index 0000000..4db73f5 --- /dev/null +++ b/data/modules/chip_emerald.yaml @@ -0,0 +1,10 @@ +id: chip_emerald +name: Chip Emerald Bolts +level: 27 +max_hit: 0 +base_xp: 37 +category: enchant +junk_cost: + cosmicjunk: 1 + biojunk: 3 + scrap_metal: 1 diff --git a/data/modules/chip_ruby.yaml b/data/modules/chip_ruby.yaml new file mode 100644 index 0000000..13e825c --- /dev/null +++ b/data/modules/chip_ruby.yaml @@ -0,0 +1,11 @@ +id: chip_ruby +name: Chip Ruby Bolts +level: 49 +max_hit: 0 +base_xp: 59 +category: enchant +junk_cost: + cosmicjunk: 1 + solarjunk: 5 + bloodjunk: 1 + scrap_metal: 1 diff --git a/data/modules/chip_sapphire.yaml b/data/modules/chip_sapphire.yaml new file mode 100644 index 0000000..ec747fd --- /dev/null +++ b/data/modules/chip_sapphire.yaml @@ -0,0 +1,10 @@ +id: chip_sapphire +name: Chip Sapphire Bolts +level: 4 +max_hit: 0 +base_xp: 9 +category: enchant +junk_cost: + cosmicjunk: 1 + hydrojunk: 1 + scrap_metal: 1 diff --git a/data/modules/eco_blast.yaml b/data/modules/eco_blast.yaml new file mode 100644 index 0000000..4f8f8ff --- /dev/null +++ b/data/modules/eco_blast.yaml @@ -0,0 +1,12 @@ +id: eco_blast +name: Eco Blast +level: 53 +max_hit: 15 +base_xp: 31 +category: combat +element: eco +junk_cost: + ecojunk: 4 + biojunk: 3 + chaosjunk: 1 + scrap_metal: 1 diff --git a/data/modules/eco_bolt.yaml b/data/modules/eco_bolt.yaml new file mode 100644 index 0000000..33ea0f6 --- /dev/null +++ b/data/modules/eco_bolt.yaml @@ -0,0 +1,11 @@ +id: eco_bolt +name: Eco Bolt +level: 29 +max_hit: 11 +base_xp: 19 +category: combat +element: eco +junk_cost: + ecojunk: 3 + biojunk: 2 + scrap_metal: 1 diff --git a/data/modules/eco_strike.yaml b/data/modules/eco_strike.yaml new file mode 100644 index 0000000..585db00 --- /dev/null +++ b/data/modules/eco_strike.yaml @@ -0,0 +1,11 @@ +id: eco_strike +name: Eco Strike +level: 9 +max_hit: 7 +base_xp: 9 +category: combat +element: eco +junk_cost: + ecojunk: 2 + biojunk: 2 + scrap_metal: 1 diff --git a/data/modules/eco_surge.yaml b/data/modules/eco_surge.yaml new file mode 100644 index 0000000..6bca172 --- /dev/null +++ b/data/modules/eco_surge.yaml @@ -0,0 +1,12 @@ +id: eco_surge +name: Eco Surge +level: 90 +max_hit: 23 +base_xp: 48 +category: combat +element: eco +junk_cost: + ecojunk: 10 + biojunk: 7 + bloodjunk: 1 + scrap_metal: 1 diff --git a/data/modules/eco_wave.yaml b/data/modules/eco_wave.yaml new file mode 100644 index 0000000..4fa2a23 --- /dev/null +++ b/data/modules/eco_wave.yaml @@ -0,0 +1,12 @@ +id: eco_wave +name: Eco Wave +level: 70 +max_hit: 19 +base_xp: 40 +category: combat +element: eco +junk_cost: + ecojunk: 7 + biojunk: 5 + deathjunk: 1 + scrap_metal: 1 diff --git a/data/modules/em_grab.yaml b/data/modules/em_grab.yaml new file mode 100644 index 0000000..4ec20d3 --- /dev/null +++ b/data/modules/em_grab.yaml @@ -0,0 +1,10 @@ +id: em_grab +name: Electromagnetic Grab +level: 33 +max_hit: 0 +base_xp: 43 +category: utility +junk_cost: + lawjunk: 1 + biojunk: 1 + scrap_metal: 1 diff --git a/data/modules/enchant_1.yaml b/data/modules/enchant_1.yaml new file mode 100644 index 0000000..6c074e0 --- /dev/null +++ b/data/modules/enchant_1.yaml @@ -0,0 +1,10 @@ +id: enchant_1 +name: Enchant Level 1 +level: 7 +max_hit: 0 +base_xp: 17 +category: enchant +junk_cost: + cosmicjunk: 1 + hydrojunk: 1 + scrap_metal: 1 diff --git a/data/modules/enchant_2.yaml b/data/modules/enchant_2.yaml new file mode 100644 index 0000000..22c19f9 --- /dev/null +++ b/data/modules/enchant_2.yaml @@ -0,0 +1,10 @@ +id: enchant_2 +name: Enchant Level 2 +level: 27 +max_hit: 0 +base_xp: 37 +category: enchant +junk_cost: + cosmicjunk: 1 + biojunk: 3 + scrap_metal: 1 diff --git a/data/modules/enchant_3.yaml b/data/modules/enchant_3.yaml new file mode 100644 index 0000000..99f8745 --- /dev/null +++ b/data/modules/enchant_3.yaml @@ -0,0 +1,10 @@ +id: enchant_3 +name: Enchant Level 3 +level: 49 +max_hit: 0 +base_xp: 59 +category: enchant +junk_cost: + cosmicjunk: 1 + solarjunk: 5 + scrap_metal: 1 diff --git a/data/modules/enchant_4.yaml b/data/modules/enchant_4.yaml new file mode 100644 index 0000000..e296ce6 --- /dev/null +++ b/data/modules/enchant_4.yaml @@ -0,0 +1,10 @@ +id: enchant_4 +name: Enchant Level 4 +level: 57 +max_hit: 0 +base_xp: 67 +category: enchant +junk_cost: + cosmicjunk: 1 + ecojunk: 10 + scrap_metal: 1 diff --git a/data/modules/high_process.yaml b/data/modules/high_process.yaml new file mode 100644 index 0000000..55e4c38 --- /dev/null +++ b/data/modules/high_process.yaml @@ -0,0 +1,10 @@ +id: high_process +name: High Level Processing +level: 55 +max_hit: 0 +base_xp: 65 +category: utility +junk_cost: + naturejunk: 5 + solarjunk: 1 + scrap_metal: 1 diff --git a/data/modules/hydro_blast.yaml b/data/modules/hydro_blast.yaml new file mode 100644 index 0000000..c55f1b4 --- /dev/null +++ b/data/modules/hydro_blast.yaml @@ -0,0 +1,12 @@ +id: hydro_blast +name: Hydro Blast +level: 47 +max_hit: 14 +base_xp: 28 +category: combat +element: hydro +junk_cost: + hydrojunk: 5 + ecojunk: 3 + chaosjunk: 1 + scrap_metal: 1 diff --git a/data/modules/hydro_bolt.yaml b/data/modules/hydro_bolt.yaml new file mode 100644 index 0000000..6487191 --- /dev/null +++ b/data/modules/hydro_bolt.yaml @@ -0,0 +1,11 @@ +id: hydro_bolt +name: Hydro Bolt +level: 23 +max_hit: 10 +base_xp: 16 +category: combat +element: hydro +junk_cost: + hydrojunk: 3 + ecojunk: 2 + scrap_metal: 1 diff --git a/data/modules/hydro_strike.yaml b/data/modules/hydro_strike.yaml new file mode 100644 index 0000000..657e8e1 --- /dev/null +++ b/data/modules/hydro_strike.yaml @@ -0,0 +1,11 @@ +id: hydro_strike +name: Hydro Strike +level: 5 +max_hit: 6 +base_xp: 7 +category: combat +element: hydro +junk_cost: + hydrojunk: 3 + ecojunk: 1 + scrap_metal: 1 diff --git a/data/modules/hydro_surge.yaml b/data/modules/hydro_surge.yaml new file mode 100644 index 0000000..61fe3d2 --- /dev/null +++ b/data/modules/hydro_surge.yaml @@ -0,0 +1,12 @@ +id: hydro_surge +name: Hydro Surge +level: 85 +max_hit: 22 +base_xp: 46 +category: combat +element: hydro +junk_cost: + hydrojunk: 10 + ecojunk: 7 + bloodjunk: 1 + scrap_metal: 1 diff --git a/data/modules/hydro_wave.yaml b/data/modules/hydro_wave.yaml new file mode 100644 index 0000000..92da933 --- /dev/null +++ b/data/modules/hydro_wave.yaml @@ -0,0 +1,12 @@ +id: hydro_wave +name: Hydro Wave +level: 65 +max_hit: 18 +base_xp: 37 +category: combat +element: hydro +junk_cost: + hydrojunk: 7 + ecojunk: 5 + deathjunk: 1 + scrap_metal: 1 diff --git a/data/modules/low_process.yaml b/data/modules/low_process.yaml new file mode 100644 index 0000000..f3457d6 --- /dev/null +++ b/data/modules/low_process.yaml @@ -0,0 +1,10 @@ +id: low_process +name: Low Level Processing +level: 21 +max_hit: 0 +base_xp: 31 +category: utility +junk_cost: + naturejunk: 3 + solarjunk: 1 + scrap_metal: 1 diff --git a/data/modules/plank_make.yaml b/data/modules/plank_make.yaml new file mode 100644 index 0000000..6aa2469 --- /dev/null +++ b/data/modules/plank_make.yaml @@ -0,0 +1,10 @@ +id: plank_make +name: Plank Make +level: 86 +max_hit: 0 +base_xp: 90 +category: utility +junk_cost: + naturejunk: 5 + solarjunk: 1 + scrap_metal: 1 diff --git a/data/modules/solar_blast.yaml b/data/modules/solar_blast.yaml new file mode 100644 index 0000000..1af5170 --- /dev/null +++ b/data/modules/solar_blast.yaml @@ -0,0 +1,12 @@ +id: solar_blast +name: Solar Blast +level: 59 +max_hit: 16 +base_xp: 34 +category: combat +element: solar +junk_cost: + solarjunk: 5 + ecojunk: 4 + chaosjunk: 1 + scrap_metal: 1 diff --git a/data/modules/solar_bolt.yaml b/data/modules/solar_bolt.yaml new file mode 100644 index 0000000..ca4c1b5 --- /dev/null +++ b/data/modules/solar_bolt.yaml @@ -0,0 +1,11 @@ +id: solar_bolt +name: Solar Bolt +level: 35 +max_hit: 12 +base_xp: 22 +category: combat +element: solar +junk_cost: + solarjunk: 4 + ecojunk: 3 + scrap_metal: 1 diff --git a/data/modules/solar_strike.yaml b/data/modules/solar_strike.yaml new file mode 100644 index 0000000..b9eae32 --- /dev/null +++ b/data/modules/solar_strike.yaml @@ -0,0 +1,11 @@ +id: solar_strike +name: Solar Strike +level: 13 +max_hit: 8 +base_xp: 11 +category: combat +element: solar +junk_cost: + solarjunk: 3 + ecojunk: 2 + scrap_metal: 1 diff --git a/data/modules/solar_surge.yaml b/data/modules/solar_surge.yaml new file mode 100644 index 0000000..abfedd1 --- /dev/null +++ b/data/modules/solar_surge.yaml @@ -0,0 +1,12 @@ +id: solar_surge +name: Solar Surge +level: 95 +max_hit: 24 +base_xp: 51 +category: combat +element: solar +junk_cost: + solarjunk: 10 + ecojunk: 7 + bloodjunk: 1 + scrap_metal: 1 diff --git a/data/modules/solar_wave.yaml b/data/modules/solar_wave.yaml new file mode 100644 index 0000000..9f67203 --- /dev/null +++ b/data/modules/solar_wave.yaml @@ -0,0 +1,12 @@ +id: solar_wave +name: Solar Wave +level: 75 +max_hit: 20 +base_xp: 42 +category: combat +element: solar +junk_cost: + solarjunk: 7 + ecojunk: 5 + deathjunk: 1 + scrap_metal: 1 diff --git a/data/modules/superheat.yaml b/data/modules/superheat.yaml new file mode 100644 index 0000000..27b5931 --- /dev/null +++ b/data/modules/superheat.yaml @@ -0,0 +1,10 @@ +id: superheat +name: Superheat Item +level: 43 +max_hit: 0 +base_xp: 53 +category: utility +junk_cost: + naturejunk: 4 + solarjunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_deep_mine.yaml b/data/modules/transport_deep_mine.yaml new file mode 100644 index 0000000..2322253 --- /dev/null +++ b/data/modules/transport_deep_mine.yaml @@ -0,0 +1,11 @@ +id: transport_deep_mine +name: "Transport: Deep Mine" +level: 61 +max_hit: 0 +base_xp: 60 +category: transport +destination: 7 +junk_cost: + lawjunk: 2 + ecojunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_fishing.yaml b/data/modules/transport_fishing.yaml new file mode 100644 index 0000000..8c0ba0b --- /dev/null +++ b/data/modules/transport_fishing.yaml @@ -0,0 +1,12 @@ +id: transport_fishing +name: "Transport: Fishing Dock" +level: 55 +max_hit: 0 +base_xp: 56 +category: transport +destination: 10 +junk_cost: + lawjunk: 1 + hydrojunk: 1 + biojunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_forest.yaml b/data/modules/transport_forest.yaml new file mode 100644 index 0000000..1f555f4 --- /dev/null +++ b/data/modules/transport_forest.yaml @@ -0,0 +1,12 @@ +id: transport_forest +name: "Transport: Forest" +level: 45 +max_hit: 0 +base_xp: 48 +category: transport +destination: 22 +junk_cost: + lawjunk: 1 + ecojunk: 1 + biojunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_forge.yaml b/data/modules/transport_forge.yaml new file mode 100644 index 0000000..f54b34c --- /dev/null +++ b/data/modules/transport_forge.yaml @@ -0,0 +1,11 @@ +id: transport_forge +name: "Transport: Forge" +level: 31 +max_hit: 0 +base_xp: 35 +category: transport +destination: 12 +junk_cost: + lawjunk: 1 + ecojunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_mine.yaml b/data/modules/transport_mine.yaml new file mode 100644 index 0000000..9c70463 --- /dev/null +++ b/data/modules/transport_mine.yaml @@ -0,0 +1,12 @@ +id: transport_mine +name: "Transport: Mining Pit" +level: 37 +max_hit: 0 +base_xp: 40 +category: transport +destination: 6 +junk_cost: + lawjunk: 1 + ecojunk: 1 + solarjunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_scavenge.yaml b/data/modules/transport_scavenge.yaml new file mode 100644 index 0000000..4774007 --- /dev/null +++ b/data/modules/transport_scavenge.yaml @@ -0,0 +1,11 @@ +id: transport_scavenge +name: "Transport: Scavenging Post" +level: 51 +max_hit: 0 +base_xp: 52 +category: transport +destination: 9 +junk_cost: + lawjunk: 1 + naturejunk: 1 + scrap_metal: 1 diff --git a/data/modules/transport_town.yaml b/data/modules/transport_town.yaml new file mode 100644 index 0000000..81b6921 --- /dev/null +++ b/data/modules/transport_town.yaml @@ -0,0 +1,12 @@ +id: transport_town +name: "Transport: Town Square" +level: 25 +max_hit: 0 +base_xp: 27 +category: transport +destination: 1 +junk_cost: + lawjunk: 1 + solarjunk: 1 + biojunk: 1 + scrap_metal: 1 diff --git a/data/objects/adamantite_rock.yaml b/data/objects/adamantite_rock.yaml index 04f2f0c..4869a77 100644 --- a/data/objects/adamantite_rock.yaml +++ b/data/objects/adamantite_rock.yaml @@ -1,4 +1,23 @@ +color: "120" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: adamantite_ore + message: You manage to mine some {120}adamantite ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the adamantite rock... + level: 70 + respawn_broadcast: A green glint of adamantite catches your eye from some {name}. + respawn_timer: 140 + skill: mining + success: + base: 0.15 + cap: 0.85 + per_level: 0.01 + tools: + - pickaxe + xp: 95 id: adamantite_rock name: adamantite rock -color: "120" -behavior: mine_adamantite diff --git a/data/objects/allotment_patch.yaml b/data/objects/allotment_patch.yaml index 9d905b0..1074abe 100644 --- a/data/objects/allotment_patch.yaml +++ b/data/objects/allotment_patch.yaml @@ -1,7 +1,6 @@ id: allotment_patch name: allotment patch color: "94" -behavior: "" hidden: false inroom_description: "An allotment patch is marked out in the ground." description: "A large patch of soil for growing vegetables. Use 'inspect' to check its status." diff --git a/data/objects/anglerfish_spot.yaml b/data/objects/anglerfish_spot.yaml index 3385ac4..4829307 100644 --- a/data/objects/anglerfish_spot.yaml +++ b/data/objects/anglerfish_spot.yaml @@ -1,5 +1,23 @@ +color: "81" +description: A faint glow emanates from the depths. Use a fishing rod with bait here. +gather: + bait: fishing_bait + base_wait: 6 + drops: + - item_id: raw_anglerfish + level: 82 + message: You catch a {80}raw anglerfish{/}. + weight: 100 + xp: 120 + fail_message: Nothing seems to bite. + gather_message: You cast your line into the deep water... + level: 1 + skill: fishing + success: + base: 0.3 + cap: 0.8 + per_level: 0.005 + tools: + - fishing_rod id: anglerfish_spot name: anglerfish spot -color: "81" -behavior: fish_angler -description: "A faint glow emanates from the depths. Use a fishing rod with bait here." diff --git a/data/objects/bait_fishing_spot.yaml b/data/objects/bait_fishing_spot.yaml index 9ca3fba..8e506a8 100644 --- a/data/objects/bait_fishing_spot.yaml +++ b/data/objects/bait_fishing_spot.yaml @@ -1,5 +1,33 @@ +color: "75" +description: Small fish ripple the surface. Use a fishing rod with bait here. +gather: + bait: fishing_bait + base_wait: 5 + drops: + - item_id: raw_sardine + level: 5 + message: You catch a {75}raw sardine{/}. + weight: 60 + xp: 20 + - item_id: raw_herring + level: 10 + message: You catch a {75}raw herring{/}. + weight: 40 + xp: 30 + - item_id: raw_pike + level: 25 + message: You catch a {69}raw pike{/}. + weight: 30 + xp: 60 + fail_message: Nothing seems to bite. + gather_message: You cast your line into the water... + level: 1 + skill: fishing + success: + base: 0.5 + cap: 0.9 + per_level: 0.005 + tools: + - fishing_rod id: bait_fishing_spot name: bait fishing spot -color: "75" -behavior: fish_bait -description: "Small fish ripple the surface. Use a fishing rod with bait here." diff --git a/data/objects/cage_fishing_spot.yaml b/data/objects/cage_fishing_spot.yaml index 05e3e0e..691bb54 100644 --- a/data/objects/cage_fishing_spot.yaml +++ b/data/objects/cage_fishing_spot.yaml @@ -1,5 +1,22 @@ +color: "75" +description: A rocky underwater shelf. Use a lobster pot here. +gather: + base_wait: 6 + drops: + - item_id: raw_lobster + level: 40 + message: You catch a {33}raw lobster{/}. + weight: 100 + xp: 90 + fail_message: The pot comes up empty. + gather_message: You lower your lobster pot into the water... + level: 1 + skill: fishing + success: + base: 0.45 + cap: 0.85 + per_level: 0.005 + tools: + - lobster_pot id: cage_fishing_spot name: cage fishing spot -color: "75" -behavior: fish_cage -description: "A rocky underwater shelf. Use a lobster pot here." diff --git a/data/objects/clay_rock.yaml b/data/objects/clay_rock.yaml index 8a69d3b..70cfc4e 100644 --- a/data/objects/clay_rock.yaml +++ b/data/objects/clay_rock.yaml @@ -1,5 +1,24 @@ +color: "173" +description: A soft rock of clay. +gather: + base_wait: 4 + drops: + - depletes: true + item_id: clay + message: You manage to mine some clay. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the clay rock... + level: 1 + respawn_broadcast: Some clay fills in a nearby rock. + respawn_timer: 10 + skill: mining + success: + base: 0.6 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 5 id: clay_rock name: clay rock -color: "173" -behavior: mine_clay -description: "A soft rock of clay." diff --git a/data/objects/coal_rock.yaml b/data/objects/coal_rock.yaml index 5bccc0e..f033965 100644 --- a/data/objects/coal_rock.yaml +++ b/data/objects/coal_rock.yaml @@ -1,4 +1,23 @@ +color: "240" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: coal + message: You manage to mine some {240}coal{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the coal rock... + level: 30 + respawn_broadcast: A dark vein of coal is visible in some {name}. + respawn_timer: 80 + skill: mining + success: + base: 0.3 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 50 id: coal_rock name: coal rock -color: "240" -behavior: mine_coal diff --git a/data/objects/copper_rock.yaml b/data/objects/copper_rock.yaml index 0162181..aa2a5cc 100644 --- a/data/objects/copper_rock.yaml +++ b/data/objects/copper_rock.yaml @@ -1,4 +1,28 @@ +color: "178" +gather: + base_wait: 10 + depleted_message: You don't see any ore in this rock. + drops: + - depletes: true + item_id: copper_ore + message: You manage to mine some {178}copper ore{/}. + weight: 90 + - depletes: false + message: You spot a glint of something valuable! + table: gem_table + weight: 10 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the rock... + level: 1 + respawn_broadcast: A glint of copper catches your eye from some {name}. + respawn_timer: 50 + skill: mining + success: + base: 0.5 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 17 id: copper_rock name: copper rock -color: "178" -behavior: mine_copper diff --git a/data/objects/fire.yaml b/data/objects/fire.yaml index a214dc7..864b7a8 100644 --- a/data/objects/fire.yaml +++ b/data/objects/fire.yaml @@ -1,7 +1,6 @@ id: fire name: fire color: "208" -behavior: "" hidden: false inroom_description: "A fire is burning warmly." removal_item: ashes diff --git a/data/objects/flower_patch.yaml b/data/objects/flower_patch.yaml index c5f6d42..6d4ee70 100644 --- a/data/objects/flower_patch.yaml +++ b/data/objects/flower_patch.yaml @@ -1,7 +1,6 @@ id: flower_patch name: flower patch color: "213" -behavior: "" hidden: false inroom_description: "A flower patch is outlined with small stones." description: "A small decorative patch for growing flowers. Use 'inspect' to check its status." diff --git a/data/objects/fly_fishing_spot.yaml b/data/objects/fly_fishing_spot.yaml index 1634ab5..88fc61b 100644 --- a/data/objects/fly_fishing_spot.yaml +++ b/data/objects/fly_fishing_spot.yaml @@ -1,5 +1,33 @@ +color: "75" +description: Insects dance above the water. Use a fly fishing rod with feathers here. +gather: + bait: feather + base_wait: 5 + drops: + - item_id: raw_trout + level: 20 + message: You catch a {69}raw trout{/}. + weight: 50 + xp: 50 + - item_id: raw_salmon + level: 30 + message: You catch a {69}raw salmon{/}. + weight: 50 + xp: 70 + - item_id: raw_rainbow_fish + level: 38 + message: You catch a {177}raw rainbow fish{/}. + weight: 30 + xp: 80 + fail_message: Nothing seems to bite. + gather_message: You cast your fly into the water... + level: 1 + skill: fishing + success: + base: 0.5 + cap: 0.9 + per_level: 0.005 + tools: + - fly_rod id: fly_fishing_spot name: fly fishing spot -color: "75" -behavior: fish_fly -description: "Insects dance above the water. Use a fly fishing rod with feathers here." diff --git a/data/objects/gold_rock.yaml b/data/objects/gold_rock.yaml index d57be8f..cc3c816 100644 --- a/data/objects/gold_rock.yaml +++ b/data/objects/gold_rock.yaml @@ -1,4 +1,23 @@ +color: "220" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: gold_ore + message: You manage to mine some {220}gold ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the gold rock... + level: 40 + respawn_broadcast: A golden glint catches your eye from some {name}. + respawn_timer: 90 + skill: mining + success: + base: 0.25 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 65 id: gold_rock name: gold rock -color: "220" -behavior: mine_gold diff --git a/data/objects/harpoon_fishing_spot.yaml b/data/objects/harpoon_fishing_spot.yaml index 4df56b9..2a059e9 100644 --- a/data/objects/harpoon_fishing_spot.yaml +++ b/data/objects/harpoon_fishing_spot.yaml @@ -1,5 +1,32 @@ +color: "75" +description: Large shadows move beneath the waves. Use a harpoon here. +gather: + base_wait: 6 + drops: + - item_id: raw_tuna + level: 35 + message: You harpoon a {33}raw tuna{/}. + weight: 60 + xp: 80 + - item_id: raw_swordfish + level: 50 + message: You harpoon a {33}raw swordfish{/}. + weight: 50 + xp: 100 + - item_id: raw_shark + level: 76 + message: You harpoon a {27}raw shark{/}. + weight: 30 + xp: 110 + fail_message: You fail to spear anything. + gather_message: You ready your harpoon... + level: 1 + skill: fishing + success: + base: 0.4 + cap: 0.8 + per_level: 0.005 + tools: + - harpoon id: harpoon_fishing_spot name: harpoon fishing spot -color: "75" -behavior: fish_harpoon -description: "Large shadows move beneath the waves. Use a harpoon here." diff --git a/data/objects/herb_patch.yaml b/data/objects/herb_patch.yaml index cca6e27..81acaf3 100644 --- a/data/objects/herb_patch.yaml +++ b/data/objects/herb_patch.yaml @@ -1,7 +1,6 @@ id: herb_patch name: herb patch color: "28" -behavior: "" hidden: false inroom_description: "A prepared herb patch sits in the soil." description: "A small patch of tilled soil suitable for growing herbs. Use 'inspect' to check its status." diff --git a/data/objects/iron_gate.yaml b/data/objects/iron_gate.yaml index 91f59c0..8193f0a 100644 --- a/data/objects/iron_gate.yaml +++ b/data/objects/iron_gate.yaml @@ -1,6 +1,14 @@ -id: iron_gate -name: iron gate color: "250" -behavior: iron_gate_toggle +description: A heavy iron gate. It looks like it can be pushed open. hidden: true -description: "A heavy iron gate. It looks like it can be pushed open." +id: iron_gate +name: iron gate +use_interactions: + - condition: + flag: gate_open + not: true + value: true + message: You push the heavy iron gate open. + action: + set_flags: + gate_open: true diff --git a/data/objects/iron_rock.yaml b/data/objects/iron_rock.yaml index 3019895..b66bd03 100644 --- a/data/objects/iron_rock.yaml +++ b/data/objects/iron_rock.yaml @@ -1,4 +1,23 @@ +color: "250" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: iron_ore + message: You manage to mine some {250}iron ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the iron rock... + level: 15 + respawn_broadcast: A glint of iron catches your eye from some {name}. + respawn_timer: 60 + skill: mining + success: + base: 0.4 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 35 id: iron_rock name: iron rock -color: "250" -behavior: mine_iron diff --git a/data/objects/magic_tree.yaml b/data/objects/magic_tree.yaml index 6caaf49..7e1a37b 100644 --- a/data/objects/magic_tree.yaml +++ b/data/objects/magic_tree.yaml @@ -1,4 +1,25 @@ +color: "135" +gather: + base_wait: 12 + deplete_timer: 390 + drops: + - depletes: false + item_id: magic_logs + message: You get some {135}magic logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the magic tree... + level: 75 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 199 + skill: woodcutting + success: + base: 0.15 + cap: 0.65 + per_level: 0.01 + tools: + - axe + xp: 250 id: magic_tree name: magic tree -color: "135" -behavior: chop_magic diff --git a/data/objects/mahogany_tree.yaml b/data/objects/mahogany_tree.yaml index 9813b5c..5e84b2b 100644 --- a/data/objects/mahogany_tree.yaml +++ b/data/objects/mahogany_tree.yaml @@ -1,4 +1,25 @@ +color: "131" +gather: + base_wait: 8 + deplete_timer: 100 + drops: + - depletes: false + item_id: mahogany_logs + message: You get some {131}mahogany logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the mahogany tree... + level: 50 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 14 + skill: woodcutting + success: + base: 0.25 + cap: 0.75 + per_level: 0.01 + tools: + - axe + xp: 125 id: mahogany_tree name: mahogany tree -color: "131" -behavior: chop_mahogany diff --git a/data/objects/maple_tree.yaml b/data/objects/maple_tree.yaml index a2ac67a..1c11e82 100644 --- a/data/objects/maple_tree.yaml +++ b/data/objects/maple_tree.yaml @@ -1,4 +1,25 @@ +color: "173" +gather: + base_wait: 8 + deplete_timer: 100 + drops: + - depletes: false + item_id: maple_logs + message: You get some {173}maple logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the maple tree... + level: 45 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 59 + skill: woodcutting + success: + base: 0.28 + cap: 0.78 + per_level: 0.01 + tools: + - axe + xp: 100 id: maple_tree name: maple tree -color: "173" -behavior: chop_maple diff --git a/data/objects/mithril_rock.yaml b/data/objects/mithril_rock.yaml index d4f4858..ffd2a74 100644 --- a/data/objects/mithril_rock.yaml +++ b/data/objects/mithril_rock.yaml @@ -1,4 +1,23 @@ +color: "75" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: mithril_ore + message: You manage to mine some {75}mithril ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the mithril rock... + level: 55 + respawn_broadcast: A blue glint of mithril catches your eye from some {name}. + respawn_timer: 110 + skill: mining + success: + base: 0.2 + cap: 0.9 + per_level: 0.01 + tools: + - pickaxe + xp: 80 id: mithril_rock name: mithril rock -color: "75" -behavior: mine_mithril diff --git a/data/objects/net_fishing_spot.yaml b/data/objects/net_fishing_spot.yaml index 10824bc..11263a9 100644 --- a/data/objects/net_fishing_spot.yaml +++ b/data/objects/net_fishing_spot.yaml @@ -1,5 +1,44 @@ +color: "75" +description: Bubbles break the surface. Use a small or big fishing net here. +gather: + base_wait: 5 + depleted_message: You don't see any fish swimming here. + drops: + - item_id: raw_shrimps + level: 1 + message: You catch some {75}raw shrimps{/}. + weight: 50 + xp: 10 + - item_id: raw_anchovies + level: 1 + message: You catch some {75}raw anchovies{/}. + weight: 50 + xp: 10 + - item_id: raw_mackerel + level: 16 + message: You catch a {69}raw mackerel{/}. + weight: 50 + xp: 20 + - item_id: raw_cod + level: 23 + message: You catch a {69}raw cod{/}. + weight: 50 + xp: 30 + - item_id: raw_bass + level: 46 + message: You catch a {69}raw bass{/}. + weight: 50 + xp: 50 + fail_message: You catch nothing. + gather_message: You cast your net into the water... + level: 1 + skill: fishing + success: + base: 0.5 + cap: 0.9 + per_level: 0.005 + tools: + - small_net + - big_net id: net_fishing_spot name: net fishing spot -color: "75" -behavior: fish_net -description: "Bubbles break the surface. Use a small or big fishing net here." diff --git a/data/objects/netrunner.yaml b/data/objects/netrunner.yaml index f81f7c5..dcb7d40 100644 --- a/data/objects/netrunner.yaml +++ b/data/objects/netrunner.yaml @@ -1,6 +1,67 @@ +color: "39" +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. id: netrunner +inroom_description: A {39}Netrunner{/} sits cross-legged on the floor, wired into the network. 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." +talk: + nodes: + start: + message: The Netrunner looks up. "You want in on the network? I can give you some pointers." + options: + - goto: what_is + text: '"What is hacking?"' + - goto: terminals + text: '"Where can I find terminals?"' + - goto: tips_basic + text: '"Any tips for the basic terminal?"' + - condition: + player_flag: hacking_asked_basic + value: true + goto: tips_advanced + text: '"What about the advanced terminal?"' + - condition: + player_flag: hacking_asked_advanced + value: true + goto: tips_secure + text: '"Tell me about the secure terminal."' + - end: true + text: '"Goodbye."' + 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: + - goto: tips_basic + text: '"Any tips?"' + - end: true + text: '"Thanks."' + tips_advanced: + action: + set_player_flags: + hacking_asked_advanced: true + 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."' + options: + - goto: tips_secure + text: '"Tell me about the secure terminal."' + - end: true + text: '"Thanks."' + tips_basic: + action: + set_player_flags: + hacking_asked_basic: true + 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."' + options: + - goto: tips_advanced + text: '"What about the advanced terminal?"' + - end: true + text: '"Thanks."' + 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: + - end: true + text: '"Thanks."' + 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: + - goto: terminals + text: '"Where can I find terminals?"' + - end: true + text: '"Goodbye."' diff --git a/data/objects/oak_tree.yaml b/data/objects/oak_tree.yaml index 2ad1ed2..0d42dab 100644 --- a/data/objects/oak_tree.yaml +++ b/data/objects/oak_tree.yaml @@ -1,4 +1,25 @@ +color: "113" +gather: + base_wait: 6 + deplete_timer: 45 + drops: + - depletes: false + item_id: oak_logs + message: You get some {113}oak logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the oak tree... + level: 15 + nest_chance: 256 + respawn_broadcast: An {name} grows back. + respawn_timer: 14 + skill: woodcutting + success: + base: 0.4 + cap: 0.9 + per_level: 0.01 + tools: + - axe + xp: 37 id: oak_tree name: oak tree -color: "113" -behavior: chop_oak diff --git a/data/objects/redwood_tree.yaml b/data/objects/redwood_tree.yaml index 541095a..cf16e86 100644 --- a/data/objects/redwood_tree.yaml +++ b/data/objects/redwood_tree.yaml @@ -1,4 +1,25 @@ +color: "131" +gather: + base_wait: 14 + deplete_timer: 440 + drops: + - depletes: false + item_id: redwood_logs + message: You get some {131}redwood logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the redwood tree... + level: 90 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 199 + skill: woodcutting + success: + base: 0.1 + cap: 0.6 + per_level: 0.01 + tools: + - axe + xp: 380 id: redwood_tree name: redwood tree -color: "131" -behavior: chop_redwood diff --git a/data/objects/runite_rock.yaml b/data/objects/runite_rock.yaml index 2475908..abfb06c 100644 --- a/data/objects/runite_rock.yaml +++ b/data/objects/runite_rock.yaml @@ -1,4 +1,23 @@ +color: "87" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: runite_ore + message: You manage to mine some {87}runite ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the runite rock... + level: 85 + respawn_broadcast: A pale blue glint of runite catches your eye from some {name}. + respawn_timer: 180 + skill: mining + success: + base: 0.08 + cap: 0.8 + per_level: 0.01 + tools: + - pickaxe + xp: 125 id: runite_rock name: runite rock -color: "87" -behavior: mine_runite diff --git a/data/objects/sacred_eel_spot.yaml b/data/objects/sacred_eel_spot.yaml index 199ad96..07c1388 100644 --- a/data/objects/sacred_eel_spot.yaml +++ b/data/objects/sacred_eel_spot.yaml @@ -1,5 +1,23 @@ +color: "177" +description: Waters shimmer with an otherworldly light. Use a fishing rod with bait here. +gather: + bait: fishing_bait + base_wait: 6 + drops: + - item_id: sacred_eel + level: 87 + message: You catch a {177}sacred eel{/}. + weight: 100 + xp: 125 + fail_message: Nothing seems to bite. + gather_message: You cast your line near the sacred waters... + level: 1 + skill: fishing + success: + base: 0.25 + cap: 0.75 + per_level: 0.005 + tools: + - fishing_rod id: sacred_eel_spot name: sacred eel spot -color: "177" -behavior: fish_sacred_eel -description: "Waters shimmer with an otherworldly light. Use a fishing rod with bait here." diff --git a/data/objects/scrap_pile.yaml b/data/objects/scrap_pile.yaml index 2106822..d4a6a48 100644 --- a/data/objects/scrap_pile.yaml +++ b/data/objects/scrap_pile.yaml @@ -1,4 +1,19 @@ +color: "243" +gather: + base_wait: 10 + drops: + - depletes: false + item_id: scrap_metal + message: You salvage some {243}scrap metal{/}. + weight: 100 + gather_message: You swing your pickaxe at the scrap pile... + level: 1 + skill: mining + success: + base: 1.0 + cap: 1.0 + tools: + - pickaxe + xp: 5 id: scrap_pile name: scrap pile -color: "243" -behavior: mine_scrap diff --git a/data/objects/sheep.yaml b/data/objects/sheep.yaml index 2739c9c..865c20e 100644 --- a/data/objects/sheep.yaml +++ b/data/objects/sheep.yaml @@ -1,6 +1,25 @@ +color: "255" +description: A fluffy sheep, ready to be shorn. +gather: + base_wait: 3 + drops: + - depletes: true + item_id: wool + message: You shear some wool from the sheep. + weight: 100 + fail_message: The sheep wriggles free! + gather_message: You hold the sheep still and begin shearing... + level: 1 + respawn_broadcast: A sheep grows a fresh coat of wool. + respawn_timer: 100 + skill: crafting + success: + base: 0.95 + cap: 0.95 + per_level: 0 + tools: + - shears + xp: 0 id: sheep +inroom_description: A fluffy sheep is wandering around here. name: sheep -color: "255" -behavior: shear_sheep -description: "A fluffy sheep, ready to be shorn." -inroom_description: "A fluffy sheep is wandering around here." diff --git a/data/objects/silver_rock.yaml b/data/objects/silver_rock.yaml index 702df59..9cc7d22 100644 --- a/data/objects/silver_rock.yaml +++ b/data/objects/silver_rock.yaml @@ -1,4 +1,23 @@ +color: "253" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: silver_ore + message: You manage to mine some {253}silver ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the silver rock... + level: 20 + respawn_broadcast: A silvery glint catches your eye from some {name}. + respawn_timer: 70 + skill: mining + success: + base: 0.35 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 40 id: silver_rock name: silver rock -color: "253" -behavior: mine_silver diff --git a/data/objects/swamp_fishing_spot.yaml b/data/objects/swamp_fishing_spot.yaml index f5a3f13..e05b424 100644 --- a/data/objects/swamp_fishing_spot.yaml +++ b/data/objects/swamp_fishing_spot.yaml @@ -1,5 +1,28 @@ +color: "108" +description: Murky water bubbles ominously. Use a fishing rod with bait here. +gather: + bait: fishing_bait + base_wait: 5 + drops: + - item_id: raw_slimy_eel + level: 28 + message: You catch a {108}raw slimy eel{/}. + weight: 60 + xp: 80 + - item_id: raw_cave_eel + level: 38 + message: You catch a {80}raw cave eel{/}. + weight: 40 + xp: 80 + fail_message: Nothing seems to bite. + gather_message: You cast your line into the murky swamp... + level: 1 + skill: fishing + success: + base: 0.45 + cap: 0.85 + per_level: 0.005 + tools: + - fishing_rod id: swamp_fishing_spot name: swamp fishing spot -color: "108" -behavior: fish_swamp -description: "Murky water bubbles ominously. Use a fishing rod with bait here." diff --git a/data/objects/teak_tree.yaml b/data/objects/teak_tree.yaml index a680c10..f234f17 100644 --- a/data/objects/teak_tree.yaml +++ b/data/objects/teak_tree.yaml @@ -1,4 +1,25 @@ +color: "179" +gather: + base_wait: 6 + deplete_timer: 50 + drops: + - depletes: false + item_id: teak_logs + message: You get some {179}teak logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the teak tree... + level: 35 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 15 + skill: woodcutting + success: + base: 0.32 + cap: 0.82 + per_level: 0.01 + tools: + - axe + xp: 85 id: teak_tree name: teak tree -color: "179" -behavior: chop_teak diff --git a/data/objects/tin_rock.yaml b/data/objects/tin_rock.yaml index d0969fb..baa5caf 100644 --- a/data/objects/tin_rock.yaml +++ b/data/objects/tin_rock.yaml @@ -1,4 +1,23 @@ +color: "253" +gather: + base_wait: 10 + drops: + - depletes: true + item_id: tin_ore + message: You manage to mine some {253}tin ore{/}. + weight: 100 + fail_message: You chip away but get nothing useful. + gather_message: You swing your pickaxe at the rock... + level: 1 + respawn_broadcast: A glint of tin catches your eye from some {name}. + respawn_timer: 50 + skill: mining + success: + base: 0.5 + cap: 0.95 + per_level: 0.01 + tools: + - pickaxe + xp: 17 id: tin_rock name: tin rock -color: "253" -behavior: mine_tin diff --git a/data/objects/tool_shed.yaml b/data/objects/tool_shed.yaml index 752c921..9b59682 100644 --- a/data/objects/tool_shed.yaml +++ b/data/objects/tool_shed.yaml @@ -1,7 +1,91 @@ -id: tool_shed -name: tool shed color: "94" -behavior: tool_shed_talk +description: A small shed for storing farming tools. Talk to it to store or retrieve tools. hidden: false -inroom_description: "A weathered tool shed stands against the wall." -description: "A small shed for storing farming tools. Talk to it to store or retrieve tools." +id: tool_shed +inroom_description: A weathered tool shed stands against the wall. +name: tool shed +talk: + nodes: + retrieve_rake: + action: + give_item: rake + set_player_flags: + tool_shed_rake: false + message: You retrieve the rake from the tool shed. + options: + - goto: start + text: Continue + retrieve_spade: + action: + give_item: spade + set_player_flags: + tool_shed_spade: false + message: You retrieve the spade from the tool shed. + options: + - goto: start + text: Continue + retrieve_watering_can: + action: + give_item: watering_can + set_player_flags: + tool_shed_watering_can: false + message: You retrieve the watering can from the tool shed. + options: + - goto: start + text: Continue + start: + message: The tool shed is open. What would you like to do? + options: + - condition: + has_item: rake + goto: store_rake + text: Store rake + - condition: + has_item: spade + goto: store_spade + text: Store spade + - condition: + has_item: watering_can + goto: store_watering_can + text: Store watering can + - condition: + player_flag: tool_shed_rake + goto: retrieve_rake + text: Retrieve rake + - condition: + player_flag: tool_shed_spade + goto: retrieve_spade + text: Retrieve spade + - condition: + player_flag: tool_shed_watering_can + goto: retrieve_watering_can + text: Retrieve watering can + - end: true + text: Never mind + store_rake: + action: + set_player_flags: + tool_shed_rake: true + take_item: rake + message: You store the rake in the tool shed. + options: + - goto: start + text: Continue + store_spade: + action: + set_player_flags: + tool_shed_spade: true + take_item: spade + message: You store the spade in the tool shed. + options: + - goto: start + text: Continue + store_watering_can: + action: + set_player_flags: + tool_shed_watering_can: true + take_item: watering_can + message: You store the watering can in the tool shed. + options: + - goto: start + text: Continue diff --git a/data/objects/tree.yaml b/data/objects/tree.yaml index 4f799e8..1d4bdee 100644 --- a/data/objects/tree.yaml +++ b/data/objects/tree.yaml @@ -1,4 +1,25 @@ +color: "107" +gather: + base_wait: 4 + depleted_message: You don't see any usable wood in this tree. + drops: + - depletes: true + item_id: logs + message: You get some {107}logs{/}. + weight: 100 + exhausted_message: The tree comes crashing down! + fail_message: You swing but get no logs. + gather_message: You swing your axe at the tree... + level: 1 + respawn_broadcast: A {name} grows back. + respawn_timer: 80 + skill: woodcutting + success: + base: 0.5 + cap: 0.95 + per_level: 0.01 + tools: + - axe + xp: 25 id: tree name: tree -color: "107" -behavior: chop_tree diff --git a/data/objects/willow_tree.yaml b/data/objects/willow_tree.yaml index 01cc346..a4db0cf 100644 --- a/data/objects/willow_tree.yaml +++ b/data/objects/willow_tree.yaml @@ -1,4 +1,25 @@ +color: "114" +gather: + base_wait: 6 + deplete_timer: 50 + drops: + - depletes: false + item_id: willow_logs + message: You get some {114}willow logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the willow tree... + level: 30 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 14 + skill: woodcutting + success: + base: 0.35 + cap: 0.85 + per_level: 0.01 + tools: + - axe + xp: 67 id: willow_tree name: willow tree -color: "114" -behavior: chop_willow diff --git a/data/objects/yew_tree.yaml b/data/objects/yew_tree.yaml index cb81172..b521052 100644 --- a/data/objects/yew_tree.yaml +++ b/data/objects/yew_tree.yaml @@ -1,4 +1,25 @@ +color: "108" +gather: + base_wait: 10 + deplete_timer: 190 + drops: + - depletes: false + item_id: yew_logs + message: You get some {108}yew logs{/}. + weight: 100 + fail_message: You swing but get no logs. + gather_message: You swing your axe at the yew tree... + level: 60 + nest_chance: 256 + respawn_broadcast: A {name} grows back. + respawn_timer: 99 + skill: woodcutting + success: + base: 0.2 + cap: 0.7 + per_level: 0.01 + tools: + - axe + xp: 175 id: yew_tree name: yew tree -color: "108" -behavior: chop_yew diff --git a/data/rooms/9.yaml b/data/rooms/9.yaml index da72432..fe15233 100644 --- a/data/rooms/9.yaml +++ b/data/rooms/9.yaml @@ -14,3 +14,4 @@ objects: - id: scrap_pile - id: scrap_pile - id: scrap_pile + - id: solar_altar diff --git a/internal/action/action.go b/internal/action/action.go index 44f27f8..5e66708 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -1,5 +1,28 @@ package action +import "strings" + +func WordPrefixMatch(input, name string) bool { + inputWords := strings.Fields(strings.ToLower(input)) + if len(inputWords) == 0 { + return false + } + nameWords := strings.Fields(strings.ToLower(name)) + for _, iw := range inputWords { + found := false + for _, nw := range nameWords { + if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + type Action struct { Type string TargetID string diff --git a/internal/action/behavior.go b/internal/action/behavior.go index e1a79e3..4071f0d 100644 --- a/internal/action/behavior.go +++ b/internal/action/behavior.go @@ -91,9 +91,3 @@ type UseConfig struct { Level int `yaml:"level"` XP int `yaml:"xp"` } - -type ToggleConfig struct { - Message string `yaml:"message"` - SetFlags map[string]any `yaml:"set_flags"` - Check *Condition `yaml:"check"` -} diff --git a/internal/action/recipe.go b/internal/action/recipe.go index d1acaa5..c1f30fb 100644 --- a/internal/action/recipe.go +++ b/internal/action/recipe.go @@ -1,7 +1,6 @@ package action import ( - "fmt" "os" "path/filepath" @@ -34,13 +33,52 @@ type RecipeDef struct { type RecipeStore struct { dataDir string + cache map[string][]RecipeDef } func NewRecipeStore(dataDir string) *RecipeStore { - return &RecipeStore{dataDir: dataDir} + return &RecipeStore{dataDir: dataDir, cache: make(map[string][]RecipeDef)} } func (s *RecipeStore) LoadAll() ([]RecipeDef, error) { + return s.LoadByType("") +} + +func (s *RecipeStore) LoadByType(recipeType string) ([]RecipeDef, error) { + if recipeType == "" { + recipeType = "_all" + } + if cached, ok := s.cache[recipeType]; ok { + result := make([]RecipeDef, len(cached)) + copy(result, cached) + return result, nil + } + + all, err := s.loadAllRaw() + if err != nil { + return nil, err + } + + if recipeType == "_all" { + s.cache[recipeType] = all + result := make([]RecipeDef, len(all)) + copy(result, all) + return result, nil + } + + var filtered []RecipeDef + for _, r := range all { + if r.Type == recipeType { + filtered = append(filtered, r) + } + } + s.cache[recipeType] = filtered + result := make([]RecipeDef, len(filtered)) + copy(result, filtered) + return result, nil +} + +func (s *RecipeStore) loadAllRaw() ([]RecipeDef, error) { dir := filepath.Join(s.dataDir, "recipes") entries, err := os.ReadDir(dir) if err != nil { @@ -76,14 +114,11 @@ func (r *RecipeDef) MatchesEntry(itemID string) bool { } func (s *RecipeStore) FindByInput(recipeType string, itemID string) *RecipeDef { - all, err := s.LoadAll() + all, err := s.LoadByType(recipeType) if err != nil { return nil } for i := range all { - if recipeType != "" && all[i].Type != recipeType { - continue - } for _, e := range all[i].Consume { for _, id := range e.Items { if id == itemID { @@ -95,54 +130,9 @@ func (s *RecipeStore) FindByInput(recipeType string, itemID string) *RecipeDef { return nil } -func (s *RecipeStore) FindByItem(itemID, recipeType string) ([]RecipeDef, error) { - all, err := s.LoadAll() - if err != nil { - return nil, err - } - var matches []RecipeDef - for _, r := range all { - if recipeType != "" && r.Type != recipeType { - continue - } - if r.MatchesEntry(itemID) { - matches = append(matches, r) - } - } - return matches, nil -} - -func (s *RecipeStore) FindByItems(a, b string) ([]RecipeDef, error) { - all, err := s.LoadAll() - if err != nil { - return nil, err - } - var matches []RecipeDef - for _, r := range all { - if len(r.Consume) < 2 { - continue - } - aEntry := -1 - bEntry := -1 - for ei, e := range r.Consume { - for _, id := range e.Items { - if id == a && aEntry < 0 { - aEntry = ei - } - if id == b && bEntry < 0 { - bEntry = ei - } - } - } - if aEntry >= 0 && bEntry >= 0 && aEntry != bEntry { - matches = append(matches, r) - } - } - return matches, nil -} func (s *RecipeStore) FindByStation(itemID string, stationIDs []string, recipeType string) ([]RecipeDef, error) { - all, err := s.LoadAll() + all, err := s.LoadByType(recipeType) if err != nil { return nil, err } @@ -181,21 +171,7 @@ func (r *RecipeDef) EffectiveSkill() string { return r.Type } -func (s *RecipeStore) FindByNameOrID(query string, recipeType string) (*RecipeDef, error) { - all, err := s.LoadAll() - if err != nil { - return nil, err - } - for _, r := range all { - if recipeType != "" && r.Type != recipeType { - continue - } - if r.ID == query || r.Output == query { - return &r, nil - } - } - return nil, fmt.Errorf("recipe not found: %s", query) -} + func (r *RecipeDef) FirstItemName(load func(string) (string, bool)) string { for _, e := range r.Consume { diff --git a/internal/action/store.go b/internal/action/store.go index fd58281..a8db9df 100644 --- a/internal/action/store.go +++ b/internal/action/store.go @@ -5,107 +5,10 @@ import ( "math/rand" "os" "path/filepath" - "sync" "gopkg.in/yaml.v3" ) -type Store struct { - dataDir string - mu sync.Mutex - cache map[string]*RawBehavior -} - -type RawBehavior struct { - ID string - Type string - Raw map[string]any -} - -func NewStore(dataDir string) *Store { - return &Store{ - dataDir: dataDir, - cache: make(map[string]*RawBehavior), - } -} - -func (s *Store) Load(id string) (*RawBehavior, error) { - s.mu.Lock() - if b, ok := s.cache[id]; ok { - s.mu.Unlock() - return b, nil - } - s.mu.Unlock() - - path := filepath.Join(s.dataDir, "behaviors", id+".yaml") - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read behavior %s: %w", id, err) - } - - var raw map[string]any - if err := yaml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parse behavior %s: %w", id, err) - } - - rb := &RawBehavior{ID: id, Raw: raw} - if t, ok := raw["type"].(string); ok { - rb.Type = t - } - if rid, ok := raw["id"].(string); ok { - rb.ID = rid - } - - s.mu.Lock() - s.cache[id] = rb - s.mu.Unlock() - return rb, nil -} - -func unmarshalRaw[T any](rb *RawBehavior, expectedType string) (*T, error) { - if rb.Type != expectedType { - return nil, fmt.Errorf("behavior %s is type %s, expected %s", rb.ID, rb.Type, expectedType) - } - var cfg T - raw, _ := yaml.Marshal(rb.Raw) - if err := yaml.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("parse %s config %s: %w", expectedType, rb.ID, err) - } - return &cfg, nil -} - -func (s *Store) LoadGather(id string) (*GatherConfig, error) { - rb, err := s.Load(id) - if err != nil { - return nil, err - } - return unmarshalRaw[GatherConfig](rb, "gather") -} - -func (s *Store) LoadTalk(id string) (*TalkConfig, error) { - rb, err := s.Load(id) - if err != nil { - return nil, err - } - return unmarshalRaw[TalkConfig](rb, "talk") -} - -func (s *Store) LoadUse(id string) (*UseConfig, error) { - rb, err := s.Load(id) - if err != nil { - return nil, err - } - return unmarshalRaw[UseConfig](rb, "use") -} - -func (s *Store) LoadToggle(id string) (*ToggleConfig, error) { - rb, err := s.Load(id) - if err != nil { - return nil, err - } - return unmarshalRaw[ToggleConfig](rb, "toggle") -} - func SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64 { chance := cfg.Base + float64(level-requiredLevel)*cfg.PerLevel if chance > cfg.Cap { @@ -117,8 +20,8 @@ func SuccessChance(cfg SuccessFormula, level int, requiredLevel int) float64 { return chance } -func (s *Store) LoadDropTable(id string) (*DropTableDef, error) { - path := filepath.Join(s.dataDir, "drops", id+".yaml") +func LoadDropTable(dataDir, id string) (*DropTableDef, error) { + path := filepath.Join(dataDir, "drops", id+".yaml") data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read drop table %s: %w", id, err) @@ -130,7 +33,7 @@ func (s *Store) LoadDropTable(id string) (*DropTableDef, error) { return &dt, nil } -func (s *Store) ResolveDrop(drops []DropEntry) *DropEntry { +func ResolveDrop(dataDir string, drops []DropEntry) *DropEntry { if len(drops) == 0 { return nil } @@ -147,9 +50,9 @@ func (s *Store) ResolveDrop(drops []DropEntry) *DropEntry { cumulative += drops[i].Weight if roll < cumulative { if drops[i].Table != "" { - sub, err := s.LoadDropTable(drops[i].Table) + sub, err := LoadDropTable(dataDir, drops[i].Table) if err == nil { - if resolved := s.ResolveDrop(sub.Drops); resolved != nil { + if resolved := ResolveDrop(dataDir, sub.Drops); resolved != nil { qty := drops[i].Quantity if qty <= 0 { qty = resolved.Quantity diff --git a/internal/config/config.go b/internal/config/config.go index e7e1b02..122c311 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,7 @@ func DefaultColors() ColorsConfig { "tech_depleted": "196", "science_mod": "99", "assassin_task": "219", + "dim": "243 dim", "warning": "208", "farm_grow": "34", "farm_disease": "196", diff --git a/internal/game/action.go b/internal/game/action.go index dc21927..f25cabc 100644 --- a/internal/game/action.go +++ b/internal/game/action.go @@ -23,8 +23,6 @@ var verbAliases = map[string]string{ "talk": "talk", "speak": "talk", "ask": "talk", - "pull": "toggle", - "push": "toggle", "steal": "steal", "thieve": "steal", } @@ -46,8 +44,8 @@ func normalizeVerb(v string) string { return v } -func (g *Game) StartAction(sess *net.Session, verb, target string) { - p := sess.Player.(*player.Player) +func (g *Game) startAction(sess *net.Session, verb, target string) { + p := sess.Player verb = normalizeVerb(verb) @@ -57,9 +55,9 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) lower := strings.ToLower(target) instanceIdx := -1 @@ -77,7 +75,6 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { var obj *object.ObjectDef var mob *world.MobInstance - var behaviorID string if len(instances) > 0 { uniqueDefs := make(map[string]bool) @@ -109,16 +106,15 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { } var err error obj, err = g.ObjectStore.Load(chosen.DefID) - if err != nil || obj.BehaviorID == "" { + if err != nil || !obj.IsInteractable() { sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) return } - behaviorID = obj.BehaviorID } else { mobs := g.MobStore.MobsInRoom(p.RoomID) var candidates []*world.MobInstance for _, m := range mobs { - if m.BehaviorID == "" { + if !m.IsTalkable() { continue } if q := m.MatchQuality(lower); q >= world.MatchPrefix { @@ -140,17 +136,6 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { sess.WriteLine("Which one?") return } - behaviorID = mob.BehaviorID - } - - bh, err := g.BehaviorStore.Load(behaviorID) - if err != nil { - if obj != nil { - sess.WriteLine(fmt.Sprintf("Something is wrong with the %s.", obj.Name)) - } else { - sess.WriteLine("Something is wrong with that.") - } - return } if obj != nil && obj.ID == "estate_directory" { @@ -158,7 +143,16 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { return } - if bh.Type != verb { + bhType := "" + if obj != nil { + bhType = obj.BehaviorType() + } else if mob != nil { + if mob.IsTalkable() { + bhType = "talk" + } + } + + if bhType != verb { if obj != nil { sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) } else { @@ -167,7 +161,7 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { return } - switch bh.Type { + switch bhType { case "gather": if obj == nil { sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) @@ -200,21 +194,19 @@ func (g *Game) StartAction(sess *net.Session, verb, target string) { } case "use": g.startUse(sess, p, obj) - case "toggle": - g.startToggle(sess, p, obj) default: sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) } } -func (g *Game) CancelAction(p *player.Player) { +func (g *Game) cancelAction(p *player.Player) { p.Action = nil p.ActionState = nil p.WalkSequence = nil p.ClearMoveState() } -func (g *Game) CancelBackgroundAction(p *player.Player) { +func (g *Game) cancelBackgroundAction(p *player.Player) { p.BackgroundAction = nil p.BackgroundActionState = nil } @@ -224,8 +216,8 @@ func (g *Game) AdvanceActions() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p.Action == nil { + p := sess.Player + if p == nil || p.Action == nil { continue } if !p.Action.Advance() { @@ -269,8 +261,8 @@ func (g *Game) AdvanceActions() { } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p.BackgroundAction == nil { + p := sess.Player + if p == nil || p.BackgroundAction == nil { continue } if !p.BackgroundAction.Advance() { @@ -289,7 +281,7 @@ func (g *Game) AdvanceActions() { } func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { - p, _ := sess.Player.(*player.Player) + p := sess.Player if c.AllOf != nil { for _, sub := range c.AllOf { diff --git a/internal/game/action_agility.go b/internal/game/action_agility.go index 089f026..37fbab9 100644 --- a/internal/game/action_agility.go +++ b/internal/game/action_agility.go @@ -68,7 +68,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { ticksPerPhase := data["ticks_per_phase"].(float64) if phase >= len(messages) { - g.CancelAction(p) + g.cancelAction(p) return } @@ -95,10 +95,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { 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))) - } + g.awardSkillXP(sess, p, player.Agility, obstacleXP) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("(+%dxp %s)", obstacleXP, player.SkillAbbr[player.Agility]))) @@ -109,10 +106,7 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { 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))) - } + g.awardSkillXP(sess, p, player.Agility, completionXP) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("(+%dxp %s course bonus)", completionXP, player.SkillAbbr[player.Agility]))) @@ -127,10 +121,10 @@ func (g *Game) advanceObstacle(sess *net.Session, p *player.Player) { sess.WriteLine(g.colorize(sess, "broadcast", fmt.Sprintf("Course complete! %s lap %d finished.", courseName, laps))) - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, startRoom) } else { - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, nextRoom) } @@ -162,7 +156,7 @@ func (g *Game) obstacleFail(sess *net.Session, p *player.Player, data map[string fmt.Sprintf("You take %d damage. HP: %d/%d", damage, p.HP, p.MaxHP()))) g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, startRoom) } diff --git a/internal/game/action_burn.go b/internal/game/action_burn.go index 0230f24..4d8b65e 100644 --- a/internal/game/action_burn.go +++ b/internal/game/action_burn.go @@ -13,9 +13,9 @@ import ( func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) itemID, fromGround, ambiguous := g.resolveBurnTarget(p, input) if ambiguous { @@ -32,9 +32,9 @@ func (g *Game) doBurn(sess *net.Session, p *player.Player, input string) { func (g *Game) doStoke(sess *net.Session, p *player.Player, input string) { if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) if !g.hasFireObject(p.RoomID) { sess.WriteLine("There's no fire here to stoke.") @@ -183,14 +183,14 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { logDef, err := g.ItemStore.Load(itemID) if err != nil { - g.CancelAction(p) + g.cancelAction(p) return } if phase == 0 { if !p.HasItem(itemID) { sess.WriteLine(fmt.Sprintf("You're out of %s.", g.itemColorize(sess, logDef, logDef.Name))) - g.CancelAction(p) + g.cancelAction(p) return } p.RemoveItem(itemID, 1) @@ -208,7 +208,7 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { ground := g.World.GroundItems(p.RoomID) if ground[itemID] <= 0 { sess.WriteLine("The thing you were trying to burn is gone.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -235,13 +235,11 @@ func (g *Game) advanceBurn(sess *net.Session, p *player.Player) { xp := logDef.FireXP if xp > 0 { - if newLevel := p.AddSkillXP(player.Firemaking, xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d firemaking! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Firemaking, xp) } g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) msg := g.colorize(sess, "fire", "You manage to get a fire going!") if xp > 0 && p.OptionBool("xp_drops") { @@ -273,7 +271,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { st := g.World.GetObjStateByKey(fireKey) if st == nil || st.Quality <= 0 { sess.WriteLine("The fire went out!") - g.CancelAction(p) + g.cancelAction(p) return } @@ -285,7 +283,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { if !p.HasItem(itemID) { sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) - g.CancelAction(p) + g.cancelAction(p) return } @@ -293,9 +291,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { st.Quality += burnTicks / 2 if xp > 0 { - if newLevel := p.AddSkillXP(player.Firemaking, xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d firemaking! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Firemaking, xp) } g.AccountStore.SaveCharacter(p) @@ -308,7 +304,7 @@ func (g *Game) advanceStoke(sess *net.Session, p *player.Player) { if !p.HasItem(itemID) { sess.WriteLine(g.colorize(sess, "fire", fmt.Sprintf("You're out of %s and the fire is roaring.", g.itemColorize(sess, logDef, name)))) - g.CancelAction(p) + g.cancelAction(p) return } @@ -416,8 +412,8 @@ func (g *Game) hasFireObject(roomID int) bool { func (g *Game) broadcastDrop(p *player.Player, itemName string) { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - op, ok := other.Player.(*player.Player) - if ok && op.Name != p.Name { + op := other.Player + if op != nil && op.Name != p.Name { other.WriteLine(fmt.Sprintf("\n%s drops a %s.", p.Name, itemName)) } } @@ -438,10 +434,10 @@ func (g *Game) FireTick() { func (g *Game) cancelStokers(roomID int) { for _, other := range g.Hub.PlayersInRoom(roomID) { - op, ok := other.Player.(*player.Player) - if ok && op.Action != nil && op.Action.Type == "stoke" { + op := other.Player + if op != nil && op.Action != nil && op.Action.Type == "stoke" { other.WriteLine("The fire went out!") - g.CancelAction(op) + g.cancelAction(op) g.writePrompt(other) } } diff --git a/internal/game/action_clean.go b/internal/game/action_clean.go index dab3619..104dcb0 100644 --- a/internal/game/action_clean.go +++ b/internal/game/action_clean.go @@ -21,7 +21,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { allRecipes, err := g.RecipeStore.LoadAll() if err != nil { - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -51,7 +51,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { if recipe == nil { sess.WriteLine("\nYou've finished cleaning herbs.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -64,10 +64,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { } if recipe.xp > 0 { - if newLevel := p.AddSkillXP(player.Pharmacy, recipe.xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d pharmacy! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Pharmacy, recipe.xp) } g.AccountStore.SaveCharacter(p) @@ -108,7 +105,7 @@ func (g *Game) advanceClean(sess *net.Session, p *player.Player) { if !hasMore { sess.WriteLine("\nYou've finished cleaning herbs.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } diff --git a/internal/game/action_default_target.go b/internal/game/action_default_target.go index b3825ca..2ca0bd3 100644 --- a/internal/game/action_default_target.go +++ b/internal/game/action_default_target.go @@ -30,21 +30,11 @@ func (g *Game) resolveDefaultTarget(roomID int, verb string) string { seen[st.DefID] = true objDef, err := g.ObjectStore.Load(st.DefID) - if err != nil || objDef.BehaviorID == "" { + if err != nil || !objDef.IsGatherable() { continue } - bh, err := g.BehaviorStore.Load(objDef.BehaviorID) - if err != nil || bh.Type != "gather" { - continue - } - - cfg, err := g.BehaviorStore.LoadGather(objDef.BehaviorID) - if err != nil { - continue - } - - if strings.EqualFold(cfg.Skill, skill) { + if strings.EqualFold(objDef.Gather.Skill, skill) { candidates = append(candidates, candidate{defID: st.DefID, name: objDef.Name}) } } diff --git a/internal/game/action_farm.go b/internal/game/action_farm.go index 12b99c0..d79150f 100644 --- a/internal/game/action_farm.go +++ b/internal/game/action_farm.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" @@ -38,9 +39,7 @@ func farmFlagPrefix(defID string, index int) string { } func (g *Game) ensureFarmState(p *player.Player, prefix string) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } + p.EnsureFlags() if _, exists := p.Flags[prefix+"_seed"]; !exists { p.Flags[prefix+"_weeds"] = true p.Flags[prefix+"_seed"] = "" @@ -86,8 +85,8 @@ func (g *Game) FarmTick() { } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Flags == nil { + p := sess.Player + if p == nil || p.Flags == nil { continue } g.advanceFarmGrowth(sess, p) @@ -120,7 +119,7 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { continue } - stage := intFlag(p.Flags, prefix+"_stage") + stage := intFromFlag(p.Flags, prefix+"_stage") watered, _ := p.Flags[prefix+"_watered"].(bool) seedDef, err := g.ItemStore.Load(seedID) @@ -188,10 +187,7 @@ func (g *Game) advancePlant(sess *net.Session, p *player.Player) { p.Flags[prefix+"_ready"] = false if intXP := int(xp); intXP > 0 { - if newLevel := p.AddSkillXP(player.Farming, intXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d farming! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Farming, intXP) } g.AccountStore.SaveCharacter(p) @@ -275,10 +271,7 @@ func (g *Game) advanceHarvest(sess *net.Session, p *player.Player) { } if intXP := int(xp); intXP > 0 { - if newLevel := p.AddSkillXP(player.Farming, intXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d farming! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Farming, intXP) } g.ensureFarmState(p, prefix) @@ -416,7 +409,7 @@ func (g *Game) farmPatchLookSuffix(p *player.Player, defID string, index int) st return fmt.Sprintf("\nA fully grown %s is ready to harvest!", name) } - stage := intFlag(p.Flags, prefix+"_stage") + stage := intFromFlag(p.Flags, prefix+"_stage") maxStages := 4 if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { maxStages = def.FarmStages @@ -450,20 +443,6 @@ func (g *Game) toolShedLookSuffix(p *player.Player) string { return fmt.Sprintf("\nInside: %s.", strings.Join(stored, ", ")) } -func intFlag(flags map[string]any, key string) int { - v, ok := flags[key] - if !ok { - return 0 - } - switch n := v.(type) { - case int: - return n - case float64: - return int(n) - } - return 0 -} - func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, defID string, index int) { objInstances := g.World.AllObjInstances(p.RoomID) if len(objInstances) == 0 { @@ -482,24 +461,17 @@ func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, d if !farmPatchDefIDs[obj.DefID] { continue } - for i, inst := range objInstances { - if inst.DefID != obj.DefID { - continue - } - pref := farmFlagPrefix(obj.DefID, i) - if pref == "" { - break - } - var name string - if def, err := g.ObjectStore.Load(obj.DefID); err == nil { - name = def.Name - } else { - name = obj.DefID - } - farms = append(farms, indexedFarm{prefix: pref, defID: obj.DefID, index: i, name: name}) - break + pref := farmFlagPrefix(obj.DefID, obj.Index) + if pref == "" { + continue + } + var name string + if def, err := g.ObjectStore.Load(obj.DefID); err == nil { + name = def.Name + } else { + name = obj.DefID } - break + farms = append(farms, indexedFarm{prefix: pref, defID: obj.DefID, index: obj.Index, name: name}) } if len(farms) == 0 { @@ -512,7 +484,7 @@ func (g *Game) farmMatchPrefix(p *player.Player, input string) (prefix string, d lower := strings.ToLower(input) for _, f := range farms { - if strings.Contains(lower, strings.ToLower(f.name)) || world.WordPrefixMatch(lower, f.name) { + if strings.Contains(lower, strings.ToLower(f.name)) || action.WordPrefixMatch(lower, f.name) { return f.prefix, f.defID, f.index } } @@ -620,7 +592,7 @@ func (g *Game) showFarmPatches(sess *net.Session, p *player.Player) { continue } - stage := intFlag(p.Flags, prefix+"_stage") + stage := intFromFlag(p.Flags, prefix+"_stage") maxStages := 4 if sd, err := g.ItemStore.Load(seedID); err == nil && sd.FarmStages > 0 { maxStages = sd.FarmStages diff --git a/internal/game/action_finishing_blow.go b/internal/game/action_finishing_blow.go new file mode 100644 index 0000000..1a31d86 --- /dev/null +++ b/internal/game/action_finishing_blow.go @@ -0,0 +1,78 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + "thehouseoficarus/internal/world" +) + +func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { + p := sess.Player + cs := combat.GetCombat(p.Name) + if cs == nil { + return false + } + mob := g.MobStore.GetInstance(cs.MobID) + if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { + return false + } + lower := strings.ToLower(input) + var itemPart, targetPart string + for _, sep := range []string{" on ", " with "} { + if idx := strings.Index(lower, sep); idx > 0 { + itemPart = strings.TrimSpace(input[:idx]) + targetPart = strings.TrimSpace(input[idx+len(sep):]) + break + } + } + if targetPart == "" { + itemPart = strings.TrimSpace(input) + if mob.MatchQuality(itemPart) != world.MatchNone { + targetPart = itemPart + itemPart = strings.TrimSpace(mob.FinishingBlow) + } + } + if targetPart == "" { + return false + } + if mob.MatchQuality(targetPart) == world.MatchNone { + return false + } + g.doFinishingBlow(sess, p, mob, itemPart) + return true +} + +func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { + fbDef, err := g.ItemStore.Load(mob.FinishingBlow) + if err != nil { + sess.WriteLine("Something went wrong.") + return + } + if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { + sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) + return + } + if !p.HasItem(mob.FinishingBlow) { + sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) + return + } + autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow + consumed := true + if p.Flags != nil { + if val, ok := p.Flags[autoKey]; ok { + if b, ok := val.(bool); ok && b { + consumed = false + } + } + } + if consumed { + p.RemoveItem(mob.FinishingBlow, 1) + } + sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) + mob.HP = 0 + g.endCombat(sess, p, mob) +} diff --git a/internal/game/action_fletch.go b/internal/game/action_fletch.go index ddb4d2c..aa5dfc7 100644 --- a/internal/game/action_fletch.go +++ b/internal/game/action_fletch.go @@ -10,9 +10,7 @@ import ( ) func (g *Game) startFletchAction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } + p.EnsureFlags() p.Flags["last_fletch"] = recipe.ID g.AccountStore.SaveCharacter(p) @@ -61,9 +59,7 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, recipe *acti } if recipe.XP > 0 { - if newLevel := p.AddSkillXP(skill, recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, recipe.EffectiveSkill()))) - } + g.awardSkillXP(sess, p, skill, recipe.XP) } batches++ @@ -138,7 +134,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { recipe := g.loadProductionRecipe(recipeID) if recipe == nil { - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -150,7 +146,7 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { if !g.canDoFletchRecipe(p, recipe) { sess.WriteLine("\nYou've run out of fletching materials.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } @@ -179,16 +175,14 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("\nYour inventory is too full!") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) } if recipe.XP > 0 { - if newLevel := p.AddSkillXP(skill, recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, recipe.EffectiveSkill()))) - } + g.awardSkillXP(sess, p, skill, recipe.XP) } g.AccountStore.SaveCharacter(p) @@ -211,14 +205,14 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) { p.BackgroundAction.Data["remaining"] = remaining if remaining <= 0 { sess.WriteLine("\nYou've finished fletching.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } } if !g.canContinueProduction(p, recipe) { sess.WriteLine("\nYou've run out of fletching materials.") - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) return } diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go index edc8545..e169beb 100644 --- a/internal/game/action_gather.go +++ b/internal/game/action_gather.go @@ -14,9 +14,19 @@ import ( "thehouseoficarus/internal/world" ) -func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { - cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID) +const birdsNestItemID = "birds_nest" + +func loadGatherConfig(g *Game, objDefID string) *action.GatherConfig { + def, err := g.ObjectStore.Load(objDefID) if err != nil { + return nil + } + return def.Gather +} + +func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { + cfg := obj.Gather + if cfg == nil { sess.WriteLine("Something is wrong with this object.") return } @@ -34,7 +44,7 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje msg += fmt.Sprintf(" (%d ticks to respawn)", int(st.DepleteTimer)) } sess.WriteLine(msg) - } else if cfg.DepleteTimer > 0 { + } else if cfg.DepleteTimer > 0 { sess.WriteLine(fmt.Sprintf("The %s has been cut down for %d more ticks.", obj.Name, int(st.DepleteTimer))) } else { sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, int(st.DepleteTimer))) @@ -156,13 +166,13 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } data := map[string]any{ - "behavior_id": obj.BehaviorID, + "obj_def_id": obj.ID, "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), "instance_idx": st.Index + 1, "effective_wait": wait, "step": 0, } - if cfg.DepleteTimer > 0 { + if cfg.DepleteTimer > 0 { data["deplete_timer"] = true } p.Action = &action.Action{ @@ -175,10 +185,10 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } func (g *Game) advanceGather(sess *net.Session, p *player.Player) { - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadGather(behaviorID) - if err != nil { - g.CancelAction(p) + objDefID := p.Action.Data["obj_def_id"].(string) + cfg := loadGatherConfig(g, objDefID) + if cfg == nil { + g.cancelAction(p) return } @@ -190,7 +200,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if step == 0 { if p.FirstFreeSlot() == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } if cfg.Bait != "" { @@ -200,7 +210,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { baitName = def.Name } sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) - g.CancelAction(p) + g.cancelAction(p) return } } @@ -228,7 +238,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { baitName = def.Name } sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName))) - g.CancelAction(p) + g.cancelAction(p) return } p.RemoveItem(cfg.Bait, 1) @@ -239,12 +249,12 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if rand.Float64() < chance { eligible := filterDropsByLevel(cfg.Drops, skillLevel) - drop := g.BehaviorStore.ResolveDrop(eligible) + drop := action.ResolveDrop(g.DataDir, eligible) if drop != nil { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } @@ -265,9 +275,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { xp = cfg.XP } if xp > 0 { - if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), xp); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))) - } + g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), xp) } g.AccountStore.SaveCharacter(p) @@ -284,20 +292,20 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { g.checkBirdNest(sess, p, cfg, freeSlot) - if shared { - st := g.World.GetObjStateByKey(instanceKey) - if st != nil && st.SharedTimer <= 0 { - g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ - instanceKey: instanceKey, - behaviorID: behaviorID, - targetName: p.Action.TargetName, - playerNames: []string{p.Name}, - }) - p.Action.Data["step"] = 2 - p.Action.WaitLeft = engine.ToTicks(1) - return + if shared { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.SharedTimer <= 0 { + g.pendingDepletions = append(g.pendingDepletions, pendingDepletion{ + instanceKey: instanceKey, + objDefID: objDefID, + targetName: p.Action.TargetName, + playerNames: []string{p.Name}, + }) + p.Action.Data["step"] = 2 + p.Action.WaitLeft = engine.ToTicks(1) + return + } } - } if !shared && drop.Depletes { delay := engine.ToTicks(cfg.RespawnTimer) @@ -310,20 +318,20 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { st.DepleteTimer = float64(delay) } - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other == sess { - continue - } - op, ok := other.Player.(*player.Player) - if !ok || op.Action == nil || op.Action.Type != "gather" { - continue - } - if op.Action.Data["instance_key"] == instanceKey { - other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) - g.CancelAction(op) - g.writePrompt(other) + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other == sess { + continue + } + op := other.Player + if op == nil || op.Action == nil || op.Action.Type != "gather" { + continue + } + if op.Action.Data["instance_key"] == instanceKey { + other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name))) + g.cancelAction(op) + g.writePrompt(other) + } } - } p.Action.Data["step"] = 2 p.Action.WaitLeft = engine.ToTicks(1) @@ -332,7 +340,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { if p.FirstFreeSlot() == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } p.Action.Data["step"] = 0 @@ -344,7 +352,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { sess.WriteLine(cfg.FailMsg) if p.FirstFreeSlot() == -1 { sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!")) - g.CancelAction(p) + g.cancelAction(p) return } p.Action.Data["step"] = 0 @@ -375,8 +383,8 @@ func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, t } for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { - op, ok := sess.Player.(*player.Player) - if !ok || op.Action == nil || op.Action.Type != "gather" { + op := sess.Player + if op == nil || op.Action == nil || op.Action.Type != "gather" { continue } if op.Action.Data["instance_key"] != g.World.ObjStateKey(st.RoomID, st.DefID, st.Index) { @@ -394,7 +402,7 @@ func (g *Game) depleteSharedTree(st *world.ObjState, cfg *action.GatherConfig, t continue } sess.WriteLine(fmt.Sprintf("\n%s", msg)) - g.CancelAction(op) + g.cancelAction(op) g.writePrompt(sess) } } @@ -419,8 +427,8 @@ func (g *Game) flushPendingDepletions() { if st == nil || st.Depleted { continue } - cfg, err := g.BehaviorStore.LoadGather(pd.behaviorID) - if err != nil { + cfg := loadGatherConfig(g, pd.objDefID) + if cfg == nil { continue } g.depleteSharedTree(st, cfg, pd.targetName, pd.playerNames) @@ -438,7 +446,7 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.Ga } nestName := "bird's nest" - if def, err := g.ItemStore.Load("birds_nest"); err == nil { + if def, err := g.ItemStore.Load(birdsNestItemID); err == nil { nestName = def.Name } @@ -453,12 +461,12 @@ func (g *Game) checkBirdNest(sess *net.Session, p *player.Player, cfg *action.Ga } } if freeSlot == -1 { - g.World.AddGroundItem(p.RoomID, "birds_nest", 1) + g.World.AddGroundItem(p.RoomID, birdsNestItemID, 1) sess.WriteLine(fmt.Sprintf("A %s falls to the ground.", nestName)) return } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: "birds_nest", Quantity: 1}) + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: birdsNestItemID, Quantity: 1}) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("A %s falls out of the tree!", nestName)) } diff --git a/internal/game/action_identify.go b/internal/game/action_identify.go index 3f63e62..b04bf97 100644 --- a/internal/game/action_identify.go +++ b/internal/game/action_identify.go @@ -14,7 +14,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { scrapCount := p.CountItem("scrap_metal") if scrapCount == 0 { sess.WriteLine("You don't have any scrap metal.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -32,7 +32,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { } if !hasExistingStack && p.FirstFreeSlot() == -1 { sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) + g.cancelAction(p) return } @@ -65,10 +65,7 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { msg := fmt.Sprintf("The altar hums. You identify %d scrap metal into %d %s.", scrapCount, totalJunk, junkName) - if newLevel := p.AddSkillXP(player.Scavenging, totalXP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", - fmt.Sprintf("*** You are now level %d scavenging! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Scavenging, totalXP) if p.OptionBool("xp_drops") && totalXP > 0 { msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp scv)", totalXP)) @@ -76,5 +73,5 @@ func (g *Game) advanceIdentify(sess *net.Session, p *player.Player) { sess.WriteLine(msg) g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) } diff --git a/internal/game/action_production.go b/internal/game/action_production.go deleted file mode 100644 index 788cadb..0000000 --- a/internal/game/action_production.go +++ /dev/null @@ -1,798 +0,0 @@ -package game - -import ( - "fmt" - "math/rand" - "sort" - "strconv" - "strings" - - "thehouseoficarus/internal/action" - "thehouseoficarus/internal/color" - "thehouseoficarus/internal/engine" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" -) - -type recipeEntry struct { - ItemName string - Recipe action.RecipeDef -} - -type productionTypeInfo struct { - ActionType string - DisplayVerb string -} - -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"}, - "construction": {"construct", "constructing"}, -} - -var productionActionTypes map[string]bool - -func init() { - productionActionTypes = make(map[string]bool) - for _, pt := range productionTypes { - productionActionTypes[pt.ActionType] = true - } -} - -func recipeMenuData(recipes []action.RecipeDef) []map[string]string { - out := make([]map[string]string, len(recipes)) - for i, r := range recipes { - out[i] = map[string]string{"recipe_id": r.ID} - } - return out -} - -func entryMenuData(entries []recipeEntry) []map[string]string { - out := make([]map[string]string, len(entries)) - for i, e := range entries { - out[i] = map[string]string{"recipe_id": e.Recipe.ID} - } - return out -} - -func (g *Game) promptHowMany(sess *net.Session, recipeID string) { - sess.PendingRecipeID = recipeID - sess.State = net.StateHowMany - sess.Write("How many (return for all)?: ") -} - -type recipeSelection struct { - Recipe *action.RecipeDef - Count int -} - -func (g *Game) resolveRecipeByInput(input string, recipes []action.RecipeDef, lastRecipeID string) (sel recipeSelection, errMsg string) { - if input == "" { - if lastRecipeID == "" { - return sel, "never_mind" - } - for i := range recipes { - if recipes[i].ID == lastRecipeID { - sel.Recipe = &recipes[i] - return sel, "" - } - } - return sel, "never_mind" - } - - qty, productName := parseQty(input) - productName = strings.TrimSpace(productName) - - if idx, err := strconv.Atoi(productName); err == nil && idx > 0 && idx <= len(recipes) { - sel.Recipe = &recipes[idx-1] - sel.Count = qty - return sel, "" - } - - matchCount := 0 - for i := range recipes { - outDef, _ := g.ItemStore.Load(recipes[i].Output) - name := recipes[i].Output - if outDef != nil { - name = outDef.Name - } - if world.WordPrefixMatch(productName, name) { - if matchCount == 0 { - sel.Recipe = &recipes[i] - sel.Count = qty - } - matchCount++ - } - } - if matchCount > 1 { - return sel, "ambiguous" - } - if sel.Recipe == nil { - return sel, "never_mind" - } - return sel, "" -} - -func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { - p := sess.Player.(*player.Player) - tbl := &Table{Title: title} - for i, name := range names { - tbl.Rows = append(tbl.Rows, []string{fmt.Sprintf("%d", i+1), name}) - } - unicode := p.OptionBool("unicode") - sess.WriteLine("") - for _, line := range tbl.Render(unicode) { - sess.WriteLine(line) - } -} - -func (g *Game) handleHowMany(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - recipeID := sess.PendingRecipeID - sess.PendingRecipeID = "" - sess.State = net.StateGame - - input = strings.TrimSpace(input) - - var count int - if input == "" { - count = 0 - } else if n, err := strconv.Atoi(input); err == nil && n > 0 { - count = n - } else { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - var recipe *action.RecipeDef - if strings.HasPrefix(recipeID, "combine_") { - itemID := strings.TrimPrefix(recipeID, "combine_") - itemDef, err := g.ItemStore.Load(itemID) - if err != nil || len(itemDef.MadeFrom) == 0 { - g.reprompt(sess) - return - } - recipe = g.buildCombineRecipe(itemDef) - } else { - all, err := g.RecipeStore.LoadAll() - if err != nil { - g.reprompt(sess) - return - } - for _, r := range all { - if r.ID == recipeID { - recipe = &r - break - } - } - } - - if recipe == nil { - g.reprompt(sess) - return - } - - g.startProductionFromRecipe(sess, p, recipe, count) -} - -func (g *Game) buildCombineRecipe(def *object.ItemDef) *action.RecipeDef { - var consume []action.ConsumeEntry - for _, mf := range def.MadeFrom { - consume = append(consume, action.ConsumeEntry{ - Items: mf.Items, - Qty: mf.Qty, - Byproducts: mf.Byproducts, - }) - } - wait := def.Ticks - if wait <= 0 { - wait = 2 - } - return &action.RecipeDef{ - ID: "combine_" + def.ID, - Type: "combine", - Wait: wait, - Consume: consume, - Output: def.ID, - Message: fmt.Sprintf("You create %s.", def.Name), - } -} - -func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, actionType, displayVerb, startMsg, endMsg string, count int) { - g.CancelAction(p) - g.CancelBackgroundAction(p) - - skill := recipe.EffectiveSkill() - if skill != "" { - skillLevel := p.Level(player.SkillName(skill)) - if skillLevel < recipe.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", recipe.Level, skill)) - g.reprompt(sess) - return - } - } - - if !recipe.HasAllItemsQty(p.CountItem) { - sess.WriteLine("You don't have the required materials.") - g.reprompt(sess) - return - } - - outDef, _ := g.ItemStore.Load(recipe.Output) - - wait := recipe.Wait - if wait <= 0 { - wait = 4 - } - - outputName := recipe.Output - if outDef != nil { - outputName = outDef.Name - } - - p.Action = &action.Action{ - Type: actionType, - TargetID: recipe.ID, - TargetName: outputName, - Data: map[string]any{ - "recipe_id": recipe.ID, - "phase": 0, - "wait": wait, - "start_msg": startMsg, - "end_msg": endMsg, - "remaining": count, - }, - WaitLeft: engine.ToTicks(1), - } - - 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) { - _, stationName := g.findStation(p.RoomID, recipe.Station) - if stationName == "" { - stationName = "inventory" - } - - firstItem := recipe.FirstItemName(func(id string) (string, bool) { - def, err := g.ItemStore.Load(id) - if err != nil { - return id, false - } - return def.Name, true - }) - - info, ok := productionTypes[recipe.Type] - if !ok { - info = productionTypeInfo{recipe.Type, recipe.Type} - } - actionType := info.ActionType - displayVerb := info.DisplayVerb - - var startMsg, endMsg string - if stationName != "inventory" { - startMsg = fmt.Sprintf("You start %s %s on the %s.", displayVerb, firstItem, stationName) - } else { - startMsg = fmt.Sprintf("You start %s %s.", displayVerb, firstItem) - } - endMsg = fmt.Sprintf("You've finished %s.", displayVerb) - - g.startProduction(sess, p, recipe, actionType, displayVerb, startMsg, endMsg, count) -} - -func (g *Game) loadProductionRecipe(recipeID string) *action.RecipeDef { - all, err := g.RecipeStore.LoadAll() - if err == nil { - for _, r := range all { - if r.ID == recipeID { - return &r - } - } - } - if strings.HasPrefix(recipeID, "combine_") { - itemID := strings.TrimPrefix(recipeID, "combine_") - if itemDef, err := g.ItemStore.Load(itemID); err == nil && len(itemDef.MadeFrom) > 0 { - return g.buildCombineRecipe(itemDef) - } - } - return nil -} - -func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { - recipeID := p.Action.Data["recipe_id"].(string) - phase := p.Action.Data["phase"].(int) - wait := p.Action.Data["wait"].(float64) - startMsg, _ := p.Action.Data["start_msg"].(string) - endMsg, _ := p.Action.Data["end_msg"].(string) - remaining, _ := p.Action.Data["remaining"].(int) - - recipe := g.loadProductionRecipe(recipeID) - if recipe == nil { - g.CancelAction(p) - return false - } - - if phase == 0 { - if startMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", startMsg)) - } - p.Action.Data["phase"] = 1 - p.Action.WaitLeft = engine.ToTicks(wait) - return true - } - - skill := recipe.EffectiveSkill() - skillLevel := p.Level(player.SkillName(skill)) - chance := 1.0 - if recipe.Success != nil { - chance = action.SuccessChance(*recipe.Success, skillLevel, recipe.Level) - } - - if rand.Float64() < chance { - outputQty := recipe.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - - byproducts := g.collectByproducts(p, recipe) - - placed := false - outDef, _ := g.ItemStore.Load(recipe.Output) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == recipe.Output { - recipe.ConsumeAll(p.HasItem, p.RemoveItem) - slot.Quantity += outputQty - placed = true - break - } - } - } - if !placed { - recipe.ConsumeAll(p.HasItem, p.RemoveItem) - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is too full!") - g.CancelAction(p) - return false - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) - } - - for _, bp := range byproducts { - if slot := p.FirstFreeSlot(); slot >= 0 { - p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) - } - } - - if recipe.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(skill), recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) - } - } - g.AccountStore.SaveCharacter(p) - - msg := recipe.Message - if msg == "" { - outputName := recipe.Output - if outDef != nil { - outputName = outDef.Name - } - msg = fmt.Sprintf("You produce %s.", outputName) - } - if p.OptionBool("xp_drops") && recipe.XP > 0 { - abbr := player.SkillAbbr[player.SkillName(skill)] - msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) - } - sess.WriteLine(msg) - } else { - recipe.ConsumeAll(p.HasItem, p.RemoveItem) - - if recipe.Fail != "" { - freeSlot := p.FirstFreeSlot() - if freeSlot >= 0 { - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Fail, Quantity: 1}) - } - } - g.AccountStore.SaveCharacter(p) - - msg := recipe.FailMessage - if msg == "" { - msg = "You fail and the materials are lost." - } - sess.WriteLine(g.colorize(sess, "damage", msg)) - } - - if remaining > 0 { - remaining-- - p.Action.Data["remaining"] = remaining - if remaining <= 0 { - if endMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) - } - g.CancelAction(p) - return false - } - } - - if g.canContinueProduction(p, recipe) { - p.Action.WaitLeft = engine.ToTicks(wait) - return true - } - - if endMsg != "" { - sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) - } - g.CancelAction(p) - return false -} - -func (g *Game) collectByproducts(p *player.Player, recipe *action.RecipeDef) []string { - var byproducts []string - for _, e := range recipe.Consume { - if len(e.Byproducts) == 0 { - continue - } - for i, id := range e.Items { - if p.HasItem(id) && i < len(e.Byproducts) && e.Byproducts[i] != "" { - byproducts = append(byproducts, e.Byproducts[i]) - break - } - } - } - return byproducts -} - -func (g *Game) canContinueProduction(p *player.Player, recipe *action.RecipeDef) bool { - if !recipe.HasAllItemsQty(p.CountItem) { - return false - } - outputQty := recipe.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - outDef, _ := g.ItemStore.Load(recipe.Output) - if outDef != nil && outDef.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == recipe.Output { - return true - } - } - } - return p.FirstFreeSlot() >= 0 -} - -func (g *Game) handleRecipeChoice(sess *net.Session, input string) { - input = strings.TrimSpace(input) - - menuData := sess.PendingMenu - if len(menuData) == 0 { - sess.State = net.StateGame - g.reprompt(sess) - return - } - - if input == "" { - sess.PendingMenu = nil - sess.State = net.StateGame - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - if choice, err := strconv.Atoi(input); err == nil { - sess.PendingMenu = nil - sess.State = net.StateGame - if choice <= 0 || choice > len(menuData) { - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - g.dispatchMenuEntry(sess, menuData[choice-1]) - return - } - - lower := strings.ToLower(input) - matchIdx := -1 - for i, entry := range menuData { - name := g.menuEntryName(entry) - if name == "" { - continue - } - if strings.ToLower(name) == lower || world.WordPrefixMatch(input, name) { - if matchIdx >= 0 { - sess.WriteLine("That's ambiguous.") - return - } - matchIdx = i - } - } - if matchIdx >= 0 { - sess.PendingMenu = nil - sess.State = net.StateGame - g.dispatchMenuEntry(sess, menuData[matchIdx]) - return - } - - sess.PendingMenu = nil - sess.State = net.StateGame - sess.WriteLine("Never mind.") - g.reprompt(sess) -} - -func (g *Game) menuEntryName(entry map[string]string) string { - if rid, ok := entry["recipe_id"]; ok { - all, _ := g.RecipeStore.LoadAll() - for _, r := range all { - if r.ID == rid { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return def.Name - } - return r.Output - } - } - } - if rid, ok := entry["fletch_recipe_id"]; ok { - all, _ := g.RecipeStore.LoadAll() - for _, r := range all { - if r.ID == rid { - if def, err := g.ItemStore.Load(r.Output); err == nil { - return def.Name - } - return r.Output - } - } - } - if barID, ok := entry["bar_id"]; ok { - if def, _ := g.ItemStore.Load(barID); def != nil { - return def.Name - } - return barID - } - if cid, ok := entry["combine_item"]; ok { - if def, _ := g.ItemStore.Load(cid); def != nil { - return def.Name - } - return cid - } - return "" -} - -func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { - if cid, ok := entry["combine_item"]; ok { - g.promptHowMany(sess, "combine_"+cid) - return - } - if barID, ok := entry["bar_id"]; ok { - p := sess.Player.(*player.Player) - allRecipes, _ := g.RecipeStore.LoadAll() - g.showSmithTable(sess, p, barID, allRecipes) - return - } - if rid, ok := entry["fletch_recipe_id"]; ok { - p := sess.Player.(*player.Player) - allRecipes, _ := g.RecipeStore.LoadAll() - for _, r := range allRecipes { - if r.ID == rid { - g.startFletchAction(sess, p, &r, 0) - return - } - } - g.reprompt(sess) - return - } - if rid, ok := entry["recipe_id"]; ok { - g.promptHowMany(sess, rid) - return - } - g.reprompt(sess) -} - -func (g *Game) formatRecipeMaterials(r *action.RecipeDef) string { - var parts []string - for _, e := range r.Consume { - itemName := e.Items[0] - if def, err := g.ItemStore.Load(e.Items[0]); err == nil { - itemName = def.Name - } - if e.Qty > 1 { - parts = append(parts, fmt.Sprintf("%s x%d", itemName, e.Qty)) - } else { - parts = append(parts, itemName) - } - } - return strings.Join(parts, ", ") -} - -func (g *Game) showProductionTable(sess *net.Session, p *player.Player, recipes []action.RecipeDef, title, skill, lastFlagKey, promptVerb string, background bool) { - sort.Slice(recipes, func(i, j int) bool { - return recipes[i].Level < recipes[j].Level - }) - - mode := g.colorMode(sess) - skillLevel := p.Level(player.SkillName(skill)) - dimSpec := color.Parse("240") - - tbl := &Table{ - Title: title, - Columns: []string{"#", "Product", "Materials", "Level"}, - } - - for i, r := range recipes { - outDef, _ := g.ItemStore.Load(r.Output) - productName := r.Output - if outDef != nil { - productName = outDef.Name - } - - outputQty := r.OutputQty - if outputQty <= 0 { - outputQty = 1 - } - if outDef != nil && outDef.Stackable && outputQty > 1 { - productName = fmt.Sprintf("%s x%d", productName, outputQty) - } - - matStr := g.formatRecipeMaterials(&r) - levelStr := fmt.Sprint(r.Level) - numStr := fmt.Sprintf("%d", i+1) - - canMake := skillLevel >= r.Level && r.HasAllItemsQty(p.CountItem) - if canMake { - productName = g.itemColorize(sess, outDef, productName) - } else { - productName = color.Render(mode, dimSpec, productName) - matStr = color.Render(mode, dimSpec, matStr) - levelStr = color.Render(mode, dimSpec, levelStr) - numStr = color.Render(mode, dimSpec, numStr) - } - - tbl.Rows = append(tbl.Rows, []string{numStr, productName, matStr, levelStr}) - } - - unicode := p.OptionBool("unicode") - sess.WriteLine("") - for _, line := range tbl.Render(unicode) { - sess.WriteLine(line) - } - - lastRecipeID, _ := p.Flags[lastFlagKey].(string) - hint := "" - if lastRecipeID != "" { - for _, r := range recipes { - if r.ID == lastRecipeID { - if outDef, err := g.ItemStore.Load(r.Output); err == nil { - hint = outDef.Name - } - break - } - } - } - - if hint != "" { - sess.Write(fmt.Sprintf("%s what (enter for all %s): ", promptVerb, hint)) - } else { - sess.Write(fmt.Sprintf("%s what: ", promptVerb)) - } - - sess.PendingMenu = recipeMenuData(recipes) - sess.PendingSkill = skill - sess.PendingLastFlag = lastFlagKey - sess.PendingBackground = background - sess.State = net.StateProductChoice -} - -func (g *Game) handleProductChoice(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - menuData := sess.PendingMenu - skill := sess.PendingSkill - lastFlagKey := sess.PendingLastFlag - background := sess.PendingBackground - sess.PendingMenu = nil - sess.PendingSkill = "" - sess.PendingLastFlag = "" - sess.PendingBackground = false - sess.State = net.StateGame - - input = strings.TrimSpace(input) - - allRecipes, err := g.RecipeStore.LoadAll() - if err != nil { - g.reprompt(sess) - return - } - - var recipes []action.RecipeDef - for _, entry := range menuData { - rid := entry["recipe_id"] - for _, r := range allRecipes { - if r.ID == rid { - recipes = append(recipes, r) - break - } - } - } - - if len(recipes) == 0 { - g.reprompt(sess) - return - } - - sort.Slice(recipes, func(i, j int) bool { - return recipes[i].Level < recipes[j].Level - }) - - lastRecipeID, _ := p.Flags[lastFlagKey].(string) - - sel, errMsg := g.resolveRecipeByInput(input, recipes, lastRecipeID) - switch errMsg { - case "ambiguous": - sess.WriteLine("That's ambiguous.") - g.reprompt(sess) - return - case "never_mind": - sess.WriteLine("Never mind.") - g.reprompt(sess) - return - } - - skillLevel := p.Level(player.SkillName(skill)) - if skillLevel < sel.Recipe.Level { - sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", sel.Recipe.Level, skill)) - g.reprompt(sess) - return - } - if !sel.Recipe.HasAllItemsQty(p.CountItem) { - sess.WriteLine("You don't have the materials for that.") - g.reprompt(sess) - return - } - - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[lastFlagKey] = sel.Recipe.ID - g.AccountStore.SaveCharacter(p) - - if background { - g.startFletchAction(sess, p, sel.Recipe, sel.Count) - } else { - g.startProductionFromRecipe(sess, p, sel.Recipe, sel.Count) - } -} - -func (g *Game) hasToolType(p *player.Player, toolType string) bool { - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil && def.ToolType == toolType { - return true - } - } - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - if def, err := g.ItemStore.Load(slot.ItemID); err == nil && def.ToolType == toolType { - return true - } - } - return false -} diff --git a/internal/game/action_room.go b/internal/game/action_room.go index 603ac7e..b997352 100644 --- a/internal/game/action_room.go +++ b/internal/game/action_room.go @@ -7,7 +7,7 @@ import ( "thehouseoficarus/internal/net" ) -func (g *Game) RunEnterSteps(sess *net.Session, roomID int) { +func (g *Game) runEnterSteps(sess *net.Session, roomID int) { room, err := g.World.LoadRoom(roomID) if err != nil || len(room.OnEnter) == 0 { return @@ -26,11 +26,11 @@ func (g *Game) BroadcastRespawns() { respawns := g.World.FlushObjRespawns() for _, st := range respawns { def, err := g.ObjectStore.Load(st.DefID) - if err != nil || def.BehaviorID == "" { + if err != nil || !def.IsGatherable() { continue } - cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) - if err != nil || cfg.RespawnBroadcast == "" { + cfg := def.Gather + if cfg.RespawnBroadcast == "" { continue } name := def.Name diff --git a/internal/game/action_search.go b/internal/game/action_search.go index 884157e..d7dabe0 100644 --- a/internal/game/action_search.go +++ b/internal/game/action_search.go @@ -53,7 +53,7 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { def, err := g.ItemStore.Load(itemID) if err != nil { sess.WriteLine("Something went wrong.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -70,7 +70,7 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { slot := p.InvSlot(slotIdx) if slot == nil || slot.ItemID != itemID || slot.Quantity <= 0 { sess.WriteLine("The item is gone.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -80,18 +80,18 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { p.SetInvSlot(slotIdx, nil) } - dt, err := g.BehaviorStore.LoadDropTable(def.SearchTable) + dt, err := action.LoadDropTable(g.DataDir, def.SearchTable) if err == nil && len(dt.Drops) > 0 { - drop := g.BehaviorStore.ResolveDrop(dt.Drops) + drop := action.ResolveDrop(g.DataDir, dt.Drops) if drop != nil && drop.ItemID != "" { g.giveSearchLoot(sess, p, drop) } } if def.SearchMiscTable != "" { - dt2, err := g.BehaviorStore.LoadDropTable(def.SearchMiscTable) + dt2, err := action.LoadDropTable(g.DataDir, def.SearchMiscTable) if err == nil && len(dt2.Drops) > 0 { - drop := g.BehaviorStore.ResolveDrop(dt2.Drops) + drop := action.ResolveDrop(g.DataDir, dt2.Drops) if drop != nil && drop.ItemID != "" { g.giveSearchLoot(sess, p, drop) } @@ -99,7 +99,7 @@ func (g *Game) advanceSearch(sess *net.Session, p *player.Player) { } g.AccountStore.SaveCharacter(p) - g.CancelAction(p) + g.cancelAction(p) } func (g *Game) giveSearchLoot(sess *net.Session, p *player.Player, drop *action.DropEntry) { diff --git a/internal/game/action_state.go b/internal/game/action_state.go index 55d3392..0ba3767 100644 --- a/internal/game/action_state.go +++ b/internal/game/action_state.go @@ -1,7 +1,5 @@ package game -import "fmt" - type ActionType string const ( @@ -11,7 +9,6 @@ const ( ActionMoving ActionType = "moving" ActionUsing ActionType = "using" ActionTalking ActionType = "talking" - ActionToggling ActionType = "toggling" ActionBurning ActionType = "burning" ActionStoking ActionType = "stoking" ActionPickingUp ActionType = "picking_up" @@ -23,9 +20,7 @@ const ( ActionFletching ActionType = "fletching" ActionEating ActionType = "eating" ActionCleaning ActionType = "cleaning" - ActionMixing ActionType = "mixing" ActionIdentifying ActionType = "identifying" - ActionTriggering ActionType = "triggering" ActionStealing ActionType = "stealing" ActionPlanting ActionType = "planting" ActionHarvesting ActionType = "harvesting_crop" @@ -63,8 +58,6 @@ func (a *ActionState) Description() string { return "using a " + a.TargetName case ActionTalking: return "talking to " + a.TargetName - case ActionToggling: - return fmt.Sprintf("pulling a %s", a.TargetName) case ActionBurning: return "trying to start a fire" case ActionStoking: @@ -87,12 +80,8 @@ func (a *ActionState) Description() string { return "eating " + a.TargetName case ActionCleaning: return "cleaning herbs" - case ActionMixing: - return "mixing " + a.TargetName case ActionIdentifying: return "identifying scrap at " + a.TargetName - case ActionTriggering: - return "triggering " + a.TargetName case ActionStealing: return "stealing from " + a.TargetName case ActionPlanting: diff --git a/internal/game/action_steal.go b/internal/game/action_steal.go index 5347c2a..ee5456c 100644 --- a/internal/game/action_steal.go +++ b/internal/game/action_steal.go @@ -16,6 +16,34 @@ import ( "thehouseoficarus/internal/world" ) +var stallGuardTalk = &action.TalkConfig{ + Nodes: map[string]action.TalkNode{ + "start": { + Message: "Caught you red-handed! You have three options, thief.", + Options: []action.TalkOption{ + {Text: "\"I'll pay a fine. (500 credits)\"", Goto: "bribe", Condition: &action.Condition{MinCredits: 500}}, + {Text: "\"Take me to jail.\"", Goto: "jail"}, + {Text: "\"You'll have to catch me first!\"", Goto: "fight"}, + }, + }, + "bribe": { + Message: "Smart choice. Hand over 500 credits and we'll forget this happened.", + Action: &action.NodeAction{Cost: 500}, + Options: []action.TalkOption{{Text: "\"Fine, take it.\"", End: true}}, + }, + "jail": { + Message: "Off to the detention cell with you!", + Action: &action.NodeAction{Teleport: 162}, + Options: []action.TalkOption{{Text: "(You are dragged away)", End: true}}, + }, + "fight": { + Message: "Then defend yourself!", + Action: &action.NodeAction{SetFlags: map[string]any{"guard_hostile": true}}, + Options: []action.TalkOption{{Text: "(The guard attacks!)", End: true}}, + }, + }, +} + var stealSuccess = action.SuccessFormula{ Base: 0.5, PerLevel: 0.03, @@ -23,14 +51,14 @@ var stealSuccess = action.SuccessFormula{ } func (g *Game) doSteal(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") return } - g.CancelAction(p) + g.cancelAction(p) targetType, mob, objDef, errMsg := g.resolveStealTarget(sess, p, input) if errMsg != "" { @@ -131,7 +159,7 @@ func (g *Game) resolveStealTarget(sess *net.Session, p *player.Player, input str var matchingObjs []*object.ObjectDef for _, o := range stealObjs { - if world.WordPrefixMatch(searchName, o.Name) { + if action.WordPrefixMatch(searchName, o.Name) { matchingObjs = append(matchingObjs, o) } } @@ -265,12 +293,12 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { mob := g.MobStore.GetInstance(mobInstanceID) if mob == nil || mob.HP <= 0 || mob.RoomID != p.RoomID { sess.WriteLine("Your target is gone.") - g.CancelAction(p) + g.cancelAction(p) return } if combat.IsMobInCombat(mob.InstanceID) { sess.WriteLine(fmt.Sprintf("The %s is busy.", targetName)) - g.CancelAction(p) + g.cancelAction(p) return } data["steal_level"] = mob.StealLevel @@ -290,7 +318,7 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { } if !found { sess.WriteLine("Your target is gone.") - g.CancelAction(p) + g.cancelAction(p) return } objDef, err := g.ObjectStore.Load(objDefID) @@ -328,11 +356,11 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { } if rand.Float64() < chance { - dt, err := g.BehaviorStore.LoadDropTable(stealTable) + dt, err := action.LoadDropTable(g.DataDir, stealTable) if err != nil || len(dt.Drops) == 0 { sess.WriteLine("You steal nothing of value.") } else { - drop := g.BehaviorStore.ResolveDrop(dt.Drops) + drop := action.ResolveDrop(g.DataDir, dt.Drops) if drop == nil || drop.ItemID == "" { sess.WriteLine("You steal nothing of value.") } else { @@ -341,19 +369,14 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { } if stealXP > 0 { - prevLevel := p.Level(player.Thieving) - p.AddSkillXP(player.Thieving, stealXP) - newLevel := p.Level(player.Thieving) - if newLevel > prevLevel { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d thieving! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Thieving, stealXP) } g.AccountStore.SaveCharacter(p) if p.FirstFreeSlot() == -1 { sess.WriteLine("Your inventory is full.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -363,7 +386,7 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { mobInstanceID := data["mob_instance_id"].(string) mob := g.MobStore.GetInstance(mobInstanceID) sess.WriteLine(fmt.Sprintf("The %s notices you! They attack!", targetName)) - g.CancelAction(p) + g.cancelAction(p) if mob != nil && mob.HP > 0 { g.startCombat(sess, p, mob) } @@ -372,7 +395,7 @@ func (g *Game) advanceSteal(sess *net.Session, p *player.Player) { guard := g.findGuardInRoom(p.RoomID, guardMob) if guard != nil { sess.WriteLine("You fumble and the Guard spots you!") - g.CancelAction(p) + g.cancelAction(p) g.startStealGuardTalk(sess, p, guard) return } @@ -433,14 +456,10 @@ func (g *Game) isGuardWatching(roomID int, objDefID string) bool { } func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob *world.MobInstance) { - cfg, err := g.BehaviorStore.LoadTalk("stall_guard_talk") - if err != nil { - sess.WriteLine("The Guard glares at you but says nothing.") - return - } - + cfg := stallGuardTalk node, ok := cfg.Nodes["start"] if !ok { + sess.WriteLine("The Guard glares at you but says nothing.") return } @@ -450,7 +469,7 @@ func (g *Game) startStealGuardTalk(sess *net.Session, p *player.Player, guardMob Type: "talk", TargetID: guardMob.DefID, TargetName: guardMob.Name, - Data: map[string]any{"node": "start", "behavior_id": "stall_guard_talk", "steal_guard": true}, + Data: map[string]any{"node": "start", "talk_cfg": cfg, "steal_guard": true}, } g.showTalkNode(sess, node) diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go index c18e254..2f463c7 100644 --- a/internal/game/action_talk.go +++ b/internal/game/action_talk.go @@ -12,8 +12,8 @@ import ( ) func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { - cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID) - if err != nil { + cfg := mob.TalkConfig + if cfg == nil { sess.WriteLine("This person has nothing to say.") return } @@ -24,7 +24,7 @@ func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobI Type: "talk", TargetID: mob.DefID, TargetName: mob.Name, - Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID}, + Data: map[string]any{"node": "start", "talk_cfg": cfg}, } g.showTalkNode(sess, node) } else { @@ -33,8 +33,8 @@ func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobI } func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID) - if err != nil { + cfg := obj.Talk + if cfg == nil { sess.WriteLine("This person has nothing to say.") return } @@ -45,7 +45,7 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object Type: "talk", TargetID: obj.ID, TargetName: obj.Name, - Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID}, + Data: map[string]any{"node": "start", "talk_cfg": cfg}, } g.showTalkNode(sess, node) } else { @@ -66,12 +66,12 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { if g.WorldFlags["guard_hostile"] != nil { delete(g.WorldFlags, "guard_hostile") - p, _ := sess.Player.(*player.Player) + p := sess.Player if p != nil && p.Action != nil && p.Action.Data["steal_guard"] == true { guardMob := g.findGuardInRoom(p.RoomID, "guard") if guardMob != nil { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) guardMob.Protected = false g.startCombat(sess, p, guardMob) } @@ -99,33 +99,30 @@ func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { } func (g *Game) handleTalkInput(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok || p.Action == nil || p.Action.Type != "talk" { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != "talk" { sess.State = net.StateGame g.writePrompt(sess) 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" { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return } - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadTalk(behaviorID) - if err != nil { - g.CancelAction(p) + if _, ok := p.Action.Data["estate_directory"].(bool); ok { + g.handleEstateDirectoryTalk(sess, input) + return + } + + cfg, ok := p.Action.Data["talk_cfg"].(*action.TalkConfig) + if !ok || cfg == nil { + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -134,7 +131,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { nodeKey := p.Action.Data["node"].(string) node, ok := cfg.Nodes[nodeKey] if !ok { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -142,7 +139,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { idx, err := parseChoiceIndex(input) if err != nil || idx <= 0 { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -163,14 +160,14 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { } if chosen == nil { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return } if chosen.End { - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) return @@ -184,7 +181,7 @@ func (g *Game) handleTalkInput(sess *net.Session, input string) { } } - g.CancelAction(p) + g.cancelAction(p) sess.State = net.StateGame g.writePrompt(sess) } @@ -197,8 +194,8 @@ func (g *Game) enterShop(sess *net.Session, cfg *action.ShopConfig) { } func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { return } @@ -225,9 +222,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.WorldFlags[k] = v } for k, v := range na.SetPlayerFlags { - if p.Flags == nil { - p.Flags = make(map[string]any) - } + p.EnsureFlags() p.Flags[k] = v } if na.TakeItem != "" { @@ -254,7 +249,7 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { g.Hub.EnterRoom(sess, p.RoomID) } g.doLook(sess) - g.RunEnterSteps(sess, p.RoomID) + g.runEnterSteps(sess, p.RoomID) } if na.Heal > 0 { p.HP += na.Heal @@ -274,69 +269,3 @@ func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { 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_toggle.go b/internal/game/action_toggle.go deleted file mode 100644 index db7a199..0000000 --- a/internal/game/action_toggle.go +++ /dev/null @@ -1,48 +0,0 @@ -package game - -import ( - "fmt" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" -) - -func (g *Game) startToggle(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadToggle(obj.BehaviorID) - if err != nil { - sess.WriteLine("Nothing happens.") - return - } - - if cfg.Check != nil { - val, ok := g.WorldFlags[cfg.Check.Flag] - if cfg.Check.Not { - if ok && val == cfg.Check.Value { - sess.WriteLine("Nothing happens.") - return - } - } else { - if !ok || val != cfg.Check.Value { - sess.WriteLine("Nothing happens.") - return - } - } - } - - for flag, val := range cfg.SetFlags { - g.WorldFlags[flag] = val - } - - p.ActionState = &ActionState{Type: ActionToggling, TargetName: obj.Name} - - sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) - - 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))) - } - } - } -} diff --git a/internal/game/action_use.go b/internal/game/action_use.go index 78bf028..368162b 100644 --- a/internal/game/action_use.go +++ b/internal/game/action_use.go @@ -12,8 +12,8 @@ import ( ) func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { - cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID) - if err != nil { + cfg := obj.Use + if cfg == nil { sess.WriteLine("You can't use this.") return } @@ -49,17 +49,16 @@ func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectD TargetID: obj.ID, TargetName: obj.Name, WaitLeft: engine.ToTicks(1), - Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, + Data: map[string]any{"step": 0, "use_cfg": cfg}, } sess.WriteLine(fmt.Sprintf("\n%s", cfg.Message)) } func (g *Game) advanceUse(sess *net.Session, p *player.Player) { - behaviorID := p.Action.Data["behavior_id"].(string) - cfg, err := g.BehaviorStore.LoadUse(behaviorID) - if err != nil { - g.CancelAction(p) + cfg, ok := p.Action.Data["use_cfg"].(*action.UseConfig) + if !ok || cfg == nil { + g.cancelAction(p) return } @@ -70,12 +69,12 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { defName = def.Name } sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) - g.CancelAction(p) + g.cancelAction(p) return } if !p.RemoveItem(itemID, qty) { sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) - g.CancelAction(p) + g.cancelAction(p) return } } @@ -95,7 +94,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { freeSlot := p.FirstFreeSlot() if freeSlot == -1 { sess.WriteLine("Your inventory is full.") - g.CancelAction(p) + g.cancelAction(p) return } @@ -106,9 +105,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) if cfg.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), cfg.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill))) - } + g.awardSkillXP(sess, p, player.SkillName(cfg.Skill), cfg.XP) } g.AccountStore.SaveCharacter(p) @@ -125,7 +122,7 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { if p.FirstFreeSlot() == -1 { sess.WriteLine("Your inventory is full.") - g.CancelAction(p) + g.cancelAction(p) return } diff --git a/internal/game/aggro.go b/internal/game/aggro.go index 8f4da42..2a9fff4 100644 --- a/internal/game/aggro.go +++ b/internal/game/aggro.go @@ -6,11 +6,10 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) checkAggro(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { return diff --git a/internal/game/assassin.go b/internal/game/assassin.go index d7af1a4..1927399 100644 --- a/internal/game/assassin.go +++ b/internal/game/assassin.go @@ -3,52 +3,12 @@ package game import ( "fmt" "math/rand" - "strings" - "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) -func getPlayerFlagInt(p *player.Player, key string) int { - if p.Flags == nil { - return 0 - } - val, ok := p.Flags[key] - if !ok { - return 0 - } - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - } - return 0 -} - -func getPlayerFlagString(p *player.Player, key string) string { - if p.Flags == nil { - return "" - } - val, ok := p.Flags[key] - if !ok { - return "" - } - s, _ := val.(string) - return s -} - -func setPlayerFlag(p *player.Player, key string, val any) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags[key] = val -} - type assassinTaskEntry struct { MobID string MinLevel int @@ -114,10 +74,7 @@ func (g *Game) onAssassinKill(sess *net.Session, p *player.Player, mob *world.Mo return } xp := mob.MaxHP * 2 - newLevel := p.AddSkillXP(player.Assassin, xp) - if newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d Assassin! ***", newLevel))) - } + g.awardSkillXP(sess, p, player.Assassin, xp) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp asm)", xp))) } @@ -215,70 +172,3 @@ func streakBonus(streak int) int { } return 0 } - -func (g *Game) tryFinishingBlow(sess *net.Session, input string) bool { - p := sess.Player.(*player.Player) - cs := combat.GetCombat(p.Name) - if cs == nil { - return false - } - mob := g.MobStore.GetInstance(cs.MobID) - if mob == nil || mob.FinishingBlow == "" || mob.HP != 1 { - return false - } - lower := strings.ToLower(input) - var itemPart, targetPart string - for _, sep := range []string{" on ", " with "} { - if idx := strings.Index(lower, sep); idx > 0 { - itemPart = strings.TrimSpace(input[:idx]) - targetPart = strings.TrimSpace(input[idx+len(sep):]) - break - } - } - if targetPart == "" { - itemPart = strings.TrimSpace(input) - if mob.MatchQuality(itemPart) != world.MatchNone { - targetPart = itemPart - itemPart = strings.TrimSpace(mob.FinishingBlow) - } - } - if targetPart == "" { - return false - } - if mob.MatchQuality(targetPart) == world.MatchNone { - return false - } - g.doFinishingBlow(sess, p, mob, itemPart) - return true -} - -func (g *Game) doFinishingBlow(sess *net.Session, p *player.Player, mob *world.MobInstance, itemInput string) { - fbDef, err := g.ItemStore.Load(mob.FinishingBlow) - if err != nil { - sess.WriteLine("Something went wrong.") - return - } - if !fbDef.MatchesName(itemInput) && !strings.EqualFold(itemInput, fbDef.ID) { - sess.WriteLine(fmt.Sprintf("That won't work on %s. You need %s.", mobDisplayName(mob, true), fbDef.Name)) - return - } - if !p.HasItem(mob.FinishingBlow) { - sess.WriteLine(fmt.Sprintf("You don't have any %s.", fbDef.Name)) - return - } - autoKey := "assassin_unlocked_auto_" + mob.FinishingBlow - consumed := true - if p.Flags != nil { - if val, ok := p.Flags[autoKey]; ok { - if b, ok := val.(bool); ok && b { - consumed = false - } - } - } - if consumed { - p.RemoveItem(mob.FinishingBlow, 1) - } - sess.WriteLine(fmt.Sprintf("\nYou use the %s on %s!", g.itemColorize(sess, fbDef, fbDef.Name), g.colorize(sess, "mob_name", mobDisplayName(mob, true)))) - mob.HP = 0 - g.endCombat(sess, p, mob) -} diff --git a/internal/game/buff.go b/internal/game/buff.go index 39abd8a..9449731 100644 --- a/internal/game/buff.go +++ b/internal/game/buff.go @@ -15,7 +15,7 @@ func (g *Game) BuffTick() { if sess.Player == nil { continue } - p := sess.Player.(*player.Player) + p := sess.Player remaining := make([]player.PotionBuff, 0, len(p.ActiveBuffs)) dirty := false for _, buff := range p.ActiveBuffs { diff --git a/internal/game/cmd_agility.go b/internal/game/cmd_agility.go index 3bd777f..084c5cf 100644 --- a/internal/game/cmd_agility.go +++ b/internal/game/cmd_agility.go @@ -9,7 +9,7 @@ import ( ) func (g *Game) doObstacle(sess *net.Session, verb string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -29,9 +29,9 @@ func (g *Game) doObstacle(sess *net.Session, verb string) { } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) g.startObstacle(sess, p, info) } diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index 77cd569..b0ce214 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" @@ -15,7 +16,7 @@ import ( ) func (g *Game) doAttack(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You are already in combat!") @@ -23,7 +24,7 @@ func (g *Game) doAttack(sess *net.Session, input string) { } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } mob := g.findMob(sess, input, p.RoomID) @@ -125,17 +126,17 @@ func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobIn func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { g.cancelRest(p.Name) - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) combat.EnterCombat(p.Name, mob.InstanceID) - autocastActive := p.AutocastMod != "" + autotriggerActive := p.AutotriggerMod != "" playerSpeed := g.playerWeaponSpeed(p) - if autocastActive { + if autotriggerActive { playerSpeed = 5.0 } - if !autocastActive { + if !autotriggerActive { attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) var styleParts []string if attBonus > 0 { @@ -153,8 +154,8 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn } sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), styleStr)) } else { - mod := GetMod(p.AutocastMod) - modName := p.AutocastMod + mod := GetMod(p.AutotriggerMod) + modName := p.AutotriggerMod if mod != nil { modName = mod.Name } @@ -182,16 +183,16 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn g.endCombat(sess, p, currentMob) return false } - if p.AutocastMod != "" { - mod := GetMod(p.AutocastMod) + if p.AutotriggerMod != "" { + mod := GetMod(p.AutotriggerMod) if mod != nil { if !g.scienceAttack(sess, p, currentMob, mod) { - p.AutocastMod = "" + p.AutotriggerMod = "" sess.WriteLine("You've run out of junk. Switching to melee.") g.playerAttack(sess, p, currentMob) } } else { - p.AutocastMod = "" + p.AutotriggerMod = "" g.playerAttack(sess, p, currentMob) } } else { @@ -228,8 +229,17 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI return } - attackType := "crush" - var weaponType object.WeaponType + attackType, weaponType, attRoll, defRoll, maxHit := g.calculatePlayerAttackRoll(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyPlayerHit(sess, p, mob, attackType, weaponType, maxHit) + } else { + g.applyPlayerMiss(sess, p, mob, weaponType) + } +} + +func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType object.WeaponType, attRoll int, defRoll int, maxHit int) { + attackType = "crush" if itemID, ok := p.Equipment[object.SlotMainHand]; ok { if def, err := g.ItemStore.Load(itemID); err == nil { if def.AttackType != "" { @@ -246,8 +256,6 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI totals := g.playerEquipBonuses(p) isRanged := weaponType == object.WeaponRanged - var attRoll, defRoll, maxHit int - if isRanged { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) equipAttack := totals.RangedAttack @@ -273,90 +281,99 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI defRoll = combat.DefenseRoll(mob.Defense, 0, mobDefBonus) maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) } + return +} - if combat.HitCheck(attRoll, defRoll) { - dmg := combat.RollDamage(maxHit) +func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, weaponType object.WeaponType, maxHit int) { + dmg := combat.RollDamage(maxHit) - mob.HP -= dmg - if mob.HP < 0 { - mob.HP = 0 - } - if mob.FinishingBlow != "" && mob.HP <= 0 { - mob.HP = 1 - } - if mob.HP < mob.MaxHP && mob.HP > 0 { - mob.StartRegen() - } + mob.HP -= dmg + if mob.HP < 0 { + mob.HP = 0 + } + if mob.FinishingBlow != "" && mob.HP <= 0 { + mob.HP = 1 + } + if mob.HP < mob.MaxHP && mob.HP > 0 { + mob.StartRegen() + } - if mob.FinishingBlow != "" && mob.HP == 1 { - fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) - fbName := mob.FinishingBlow - if fbDef != nil { - fbName = fbDef.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", - mobDisplayName(mob, false), fbName))) + if mob.FinishingBlow != "" && mob.HP == 1 { + fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) + fbName := mob.FinishingBlow + if fbDef != nil { + fbName = fbDef.Name } - gains, leveled := g.awardCombatXP(p, dmg, isRanged) + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s resists death! Use %s on it to finish it off.", + mobDisplayName(mob, false), fbName))) + } - if isRanged { - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - } - g.AccountStore.SaveCharacter(p) - } + isRanged := weaponType == object.WeaponRanged + gains := g.awardCombatXP(sess, p, dmg, isRanged) - for _, skill := range leveled { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) + if isRanged { + p.AmmoQty-- + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 } + g.AccountStore.SaveCharacter(p) + } - mobName := mobDisplayName(mob, true) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)) - if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w { - w = w2 - } - g.combatPadWidth = w - prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) - line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) - if p.OptionBool("xp_drops") && len(gains) > 0 { - var parts []string - for _, gain := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) - } - line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + mobName := mobDisplayName(mob, true) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + w := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)) + if w2 := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP) + line := fmt.Sprintf("%-*s %s", w, prefix, hpPart) + if p.OptionBool("xp_drops") && len(gains) > 0 { + var parts []string + for _, gain := range gains { + parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)])) } - sess.WriteLine(line) + line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")") + } + sess.WriteLine(line) - if isRanged && p.AmmoQty <= 0 { + if isRanged && p.AmmoQty <= 0 { + sess.WriteLine("You've run out of ammo!") + combat.LeaveCombat(p.Name) + } +} + +func (g *Game) applyPlayerMiss(sess *net.Session, p *player.Player, mob *world.MobInstance, weaponType object.WeaponType) { + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) + + if weaponType == object.WeaponRanged { + p.AmmoQty-- + if p.AmmoQty <= 0 { + delete(p.Equipment, object.SlotAmmo) + p.AmmoQty = 0 + g.AccountStore.SaveCharacter(p) sess.WriteLine("You've run out of ammo!") combat.LeaveCombat(p.Name) } - } else { - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true)))) - - if isRanged { - p.AmmoQty-- - if p.AmmoQty <= 0 { - delete(p.Equipment, object.SlotAmmo) - p.AmmoQty = 0 - g.AccountStore.SaveCharacter(p) - sess.WriteLine("You've run out of ammo!") - combat.LeaveCombat(p.Name) - } - } } } func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - cs := combat.GetCombat(p.Name) + attRoll, defRoll := g.calculateMobAttack(p, mob) + + if combat.HitCheck(attRoll, defRoll) { + g.applyMobHit(sess, p, mob) + } else { + g.applyMobMiss(sess, mob) + } +} + +func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) { _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) mobAttackType := mob.AttackType @@ -364,87 +381,89 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst mobAttackType = "crush" } - attRoll := combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) + attRoll = combat.AttackRoll(mob.Attack, 0, mob.AttackBonus) totals := g.playerEquipBonuses(p) 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")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + defRoll = combat.DefenseRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + return +} - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) - dmg := combat.RollDamage(maxHit) - dmg = g.applyTechProtection(p, mob, dmg) - - if mob.DamageWithout != "" { - hasProtection := false - for _, itemID := range p.Equipment { - if itemID == mob.DamageWithout { - hasProtection = true - break - } +func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) { + maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) + dmg := combat.RollDamage(maxHit) + dmg = g.applyTechProtection(p, mob, dmg) + + if mob.DamageWithout != "" { + hasProtection := false + for _, itemID := range p.Equipment { + if itemID == mob.DamageWithout { + hasProtection = true + break + } + } + if !hasProtection { + dmg = dmg * 3 / 2 + if dmg < 1 { + dmg = 1 } - if !hasProtection { - dmg = dmg * 3 / 2 - if dmg < 1 { - dmg = 1 + cs := combat.GetCombat(p.Name) + if cs != nil && !cs.DamageWarningShown { + cs.DamageWarningShown = true + fbDef, _ := g.ItemStore.Load(mob.DamageWithout) + fbName := mob.DamageWithout + if fbDef != nil { + fbName = fbDef.Name } - if cs != nil && !cs.DamageWarningShown { - cs.DamageWarningShown = true - fbDef, _ := g.ItemStore.Load(mob.DamageWithout) - fbName := mob.DamageWithout - if fbDef != nil { - fbName = fbDef.Name - } - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(g.colorize(sess, "warning", - fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", - attacker, fbName))) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name } + sess.WriteLine(g.colorize(sess, "warning", + fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", + attacker, fbName))) } } + } - p.HP -= dmg - if p.HP < 0 { - p.HP = 0 - } - p.StartRegen() - g.AccountStore.SaveCharacter(p) + p.HP -= dmg + if p.HP < 0 { + p.HP = 0 + } + p.StartRegen() + g.AccountStore.SaveCharacter(p) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - mobName := mobDisplayName(mob, true) - w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)) - if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w { - w = w2 - } - g.combatPadWidth = w - prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg))) - hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP()) - sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)) + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + mobName := mobDisplayName(mob, true) + w := len(fmt.Sprintf(" %s hits you for %d damage.", attacker, dmg)) + if w2 := len(fmt.Sprintf(" You hit %s for %d damage.", mobName, dmg)); w2 > w { + w = w2 + } + prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg))) + hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP()) + sess.WriteLine(fmt.Sprintf("%-*s %s", w, prefix, hpPart)) - if p.MoveTicks > 0 { - p.ClearMoveState() - p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} - sess.WriteLine("Can't escape!") - } - } else { - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker))) + if p.MoveTicks > 0 { + p.ClearMoveState() + p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name} + sess.WriteLine("Can't escape!") + } +} + +func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name } + sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker))) } func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - g.combatPadWidth = 0 combat.LeaveCombat(p.Name) p.ActionState = nil @@ -486,7 +505,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst if g.Hub != nil { for _, other := range g.Hub.PlayersInRoom(p.RoomID) { if other != sess && other.Player != nil { - op := other.Player.(*player.Player) + op := other.Player mobLvl := mobCombatLevel(mob) levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) @@ -510,7 +529,7 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } if len(mob.Drops.Loot) > 0 { - entry := g.BehaviorStore.ResolveDrop(mob.Drops.Loot) + entry := action.ResolveDrop(g.DataDir, mob.Drops.Loot) if entry != nil && entry.ItemID != "" { qty := entry.Quantity if qty <= 0 { @@ -548,10 +567,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst } } -func (g *Game) awardCombatXP(p *player.Player, dmg int, isRanged bool) ([]xpGain, []player.SkillName) { +func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRanged bool) []xpGain { baseXP := dmg * 4 var gains []xpGain - var leveledUp []player.SkillName if isRanged { gains = []xpGain{ @@ -578,71 +596,10 @@ func (g *Game) awardCombatXP(p *player.Player, dmg int, isRanged bool) ([]xpGain } for _, gain := range gains { - if newLevel := p.AddSkillXP(player.SkillName(gain.Skill), gain.XP); newLevel > 0 { - leveledUp = append(leveledUp, player.SkillName(gain.Skill)) - } + g.awardSkillXP(sess, p, player.SkillName(gain.Skill), gain.XP) } g.AccountStore.SaveCharacter(p) - return gains, leveledUp -} - -func (g *Game) dropItemsOnDeath(p *player.Player) { - roomID := p.RoomID - - if p.Credits > 0 { - g.World.AddGroundItem(roomID, "credits", p.Credits) - p.Credits = 0 - } - - var items []deathDrop - - for slot, inv := range p.Inventory { - if inv == nil || inv.Quantity <= 0 { - continue - } - val := 0 - if def, err := g.ItemStore.Load(inv.ItemID); err == nil { - val = def.Value * inv.Quantity - } - items = append(items, deathDrop{ - itemID: inv.ItemID, - quantity: inv.Quantity, - totalVal: val, - invSlot: slot, - }) - } - - for eqSlot, itemID := range p.Equipment { - val := 0 - if def, err := g.ItemStore.Load(itemID); err == nil { - val = def.Value - } - items = append(items, deathDrop{ - itemID: itemID, - quantity: 1, - totalVal: val, - isEquip: true, - equipSlot: eqSlot, - }) - } - - if len(items) <= 3 { - return - } - - sort.Slice(items, func(i, j int) bool { - return items[i].totalVal > items[j].totalVal - }) - - for i := 3; i < len(items); i++ { - it := items[i] - if it.isEquip { - delete(p.Equipment, it.equipSlot) - } else { - p.SetInvSlot(it.invSlot, nil) - } - g.World.AddGroundItem(roomID, it.itemID, it.quantity) - } + return gains } func (g *Game) stopCombat(playerName string) { @@ -663,7 +620,7 @@ func (g *Game) respawnMob(instanceID string) { if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(homeRoom) { - if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mob_spawn") { + if p := sess.Player; p != nil && p.OptionBool("mob_spawn") { mobLvl := mobCombatLevel(inst) levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", g.colorize(sess, "mob_name", mobDisplayName(inst, false)), levelStr)) @@ -672,12 +629,3 @@ func (g *Game) respawnMob(instanceID string) { } } -func (g *Game) playerWeaponSpeed(p *player.Player) float64 { - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - def, err := g.ItemStore.Load(itemID) - if err == nil && def.Speed > 0 { - return def.Speed - } - } - return 5.0 -} diff --git a/internal/game/cmd_autocast.go b/internal/game/cmd_autocast.go deleted file mode 100644 index 205fd8d..0000000 --- a/internal/game/cmd_autocast.go +++ /dev/null @@ -1,54 +0,0 @@ -package game - -import ( - "fmt" - "strings" - - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" -) - -func (g *Game) doAutocast(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - input = strings.TrimSpace(input) - - if input == "" { - if p.AutocastMod == "" { - sess.WriteLine("No autocast mod set. Use 'autocast ' to set one.") - } else { - mod := GetMod(p.AutocastMod) - if mod == nil { - sess.WriteLine("Autocast: none (invalid mod)") - p.AutocastMod = "" - } else { - sess.WriteLine(fmt.Sprintf("Autocast: %s (Lv%d)", mod.Name, mod.Level)) - } - } - return - } - - if strings.ToLower(input) == "off" { - p.AutocastMod = "" - sess.WriteLine("Autocast disabled.") - return - } - - mod := FindMod(strings.ToLower(input)) - if mod == nil { - sess.WriteLine("Unknown mod.") - return - } - - if mod.Category != ModCombat { - sess.WriteLine("You can only autocast combat mods.") - return - } - - if p.Level(player.Science) < mod.Level { - sess.WriteLine(fmt.Sprintf("You need level %d science to autocast %s.", mod.Level, mod.Name)) - return - } - - p.AutocastMod = mod.ID - sess.WriteLine(fmt.Sprintf("Autocast set to: %s", mod.Name)) -} diff --git a/internal/game/cmd_autotrigger.go b/internal/game/cmd_autotrigger.go new file mode 100644 index 0000000..3c41735 --- /dev/null +++ b/internal/game/cmd_autotrigger.go @@ -0,0 +1,54 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +func (g *Game) doAutotrigger(sess *net.Session, input string) { + p := sess.Player + input = strings.TrimSpace(input) + + if input == "" { + if p.AutotriggerMod == "" { + sess.WriteLine("No autotrigger mod set. Use 'autotrigger ' to set one.") + } else { + mod := GetMod(p.AutotriggerMod) + if mod == nil { + sess.WriteLine("Autotrigger: none (invalid mod)") + p.AutotriggerMod = "" + } else { + sess.WriteLine(fmt.Sprintf("Autotrigger: %s (Lv%d)", mod.Name, mod.Level)) + } + } + return + } + + if strings.ToLower(input) == "off" { + p.AutotriggerMod = "" + sess.WriteLine("Autotrigger disabled.") + return + } + + mod := FindMod(strings.ToLower(input)) + if mod == nil { + sess.WriteLine("Unknown mod.") + return + } + + if mod.Category != ModCombat { + sess.WriteLine("You can only autotrigger combat mods.") + return + } + + if p.Level(player.Science) < mod.Level { + sess.WriteLine(fmt.Sprintf("You need level %d science to autotrigger %s.", mod.Level, mod.Name)) + return + } + + p.AutotriggerMod = mod.ID + sess.WriteLine(fmt.Sprintf("Autotrigger set to: %s", mod.Name)) +} diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index ff3a3ac..0785b81 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -5,9 +5,9 @@ import ( "sort" "strings" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) handleBankInput(sess *net.Session, input string) { @@ -57,7 +57,7 @@ func (g *Game) writeBankPrompt(sess *net.Session) { } func (g *Game) showBankBrowse(sess *net.Session) { - p, _ := sess.Player.(*player.Player) + p := sess.Player unicode := p.OptionBool("unicode") @@ -110,7 +110,7 @@ func (g *Game) showBankBrowse(sess *net.Session) { } func (g *Game) doBankDeposit(sess *net.Session, input string) { - p, _ := sess.Player.(*player.Player) + p := sess.Player matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -158,7 +158,7 @@ func (g *Game) doBankDeposit(sess *net.Session, input string) { } func (g *Game) doBankDepositAll(sess *net.Session) { - p, _ := sess.Player.(*player.Player) + p := sess.Player var deposited int for i := 0; i < 28; i++ { @@ -187,7 +187,7 @@ func (g *Game) doBankDepositAll(sess *net.Session) { } func (g *Game) doBankWithdraw(sess *net.Session, input string) { - p, _ := sess.Player.(*player.Player) + p := sess.Player freeSlot := p.FirstFreeSlot() if freeSlot == -1 { @@ -235,7 +235,7 @@ func (g *Game) doBankWithdraw(sess *net.Session, input string) { } lower := strings.ToLower(input) for _, e := range entries { - if world.WordPrefixMatch(e.name, lower) { + if action.WordPrefixMatch(e.name, lower) { matches = append(matches, e) } } @@ -326,7 +326,7 @@ func (g *Game) canUseBank(p *player.Player) bool { } func (g *Game) doBank(sess *net.Session) { - p, _ := sess.Player.(*player.Player) + p := sess.Player if !g.canUseBank(p) { sess.WriteLine("There is no bank here.") diff --git a/internal/game/cmd_clean.go b/internal/game/cmd_clean.go index 70dedb9..1f00d75 100644 --- a/internal/game/cmd_clean.go +++ b/internal/game/cmd_clean.go @@ -12,7 +12,7 @@ import ( ) func (g *Game) doClean(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -50,7 +50,7 @@ func (g *Game) doClean(sess *net.Session, input string) { return } - g.CancelBackgroundAction(p) + g.cancelBackgroundAction(p) p.BackgroundAction = &action.Action{ Type: "clean", diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go index 1dd14fa..3fb7716 100644 --- a/internal/game/cmd_color.go +++ b/internal/game/cmd_color.go @@ -7,7 +7,6 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/config" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doColor(sess *net.Session, input string) { @@ -160,7 +159,7 @@ var colorCategoryOrder = []string{ } func showColorTable(g *Game, sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) table := &Table{ Columns: []string{ diff --git a/internal/game/cmd_colortable.go b/internal/game/cmd_colortable.go index 90ae8c4..5aa36d9 100644 --- a/internal/game/cmd_colortable.go +++ b/internal/game/cmd_colortable.go @@ -6,7 +6,6 @@ import ( "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doColortable(sess *net.Session) { @@ -16,7 +15,7 @@ func (g *Game) doColortable(sess *net.Session) { return } - p := sess.Player.(*player.Player) + p := sess.Player unicode := p.OptionBool("unicode") sess.WriteLine(fmt.Sprintf("\nColor mode: %s", mode)) diff --git a/internal/game/cmd_construct.go b/internal/game/cmd_construct.go deleted file mode 100644 index 4ab6409..0000000 --- a/internal/game/cmd_construct.go +++ /dev/null @@ -1,157 +0,0 @@ -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_cook.go b/internal/game/cmd_cook.go index 9b76a84..5b78745 100644 --- a/internal/game/cmd_cook.go +++ b/internal/game/cmd_cook.go @@ -9,8 +9,8 @@ import ( ) func (g *Game) doCook(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) stationDefID, stationName := g.findStation(p.RoomID, []string{"fire", "cooking_range"}) if stationDefID == "" { @@ -66,7 +66,7 @@ func (g *Game) doCook(sess *net.Session, input string) { return } - sess.PendingMenu = recipeMenuData(recipes) + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, r := range recipes { @@ -116,7 +116,11 @@ func (g *Game) showCookMenu(sess *net.Session, p *player.Player, allRecipes []ac names = append(names, e.ItemName) } g.showMenuTable(sess, fmt.Sprintf("What would you like to cook on the %s?", stationName), names) - sess.PendingMenu = entryMenuData(entries) + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.Recipe.ID + } + sess.PendingMenu = recipeMenuData(ids) } func (g *Game) recipeName(sess *net.Session, r *action.RecipeDef) string { diff --git a/internal/game/cmd_craft.go b/internal/game/cmd_craft.go index 50a99e0..7f8808b 100644 --- a/internal/game/cmd_craft.go +++ b/internal/game/cmd_craft.go @@ -6,12 +6,19 @@ import ( "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) doCraft(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + g.doGenericStationSkill(sess, input, "crafting", player.Crafting, "last_craft", "Crafting", "craft", "Craft") +} + +func (g *Game) doConstruct(sess *net.Session, input string) { + g.doGenericStationSkill(sess, input, "construction", player.Construction, "last_construct", "Construction", "construct", "Construct") +} + +func (g *Game) doGenericStationSkill(sess *net.Session, input string, recipeType string, skill player.SkillName, flagKey, label, verbPast, verbCap string) { + p := sess.Player + g.cancelAction(p) allRecipes, err := g.RecipeStore.LoadAll() if err != nil { @@ -19,29 +26,29 @@ func (g *Game) doCraft(sess *net.Session, input string) { return } - var craftRecipes []action.RecipeDef + var recipes []action.RecipeDef for _, r := range allRecipes { - if r.Type != "crafting" { + if r.Type != recipeType { continue } - if !g.craftRecipeVisible(p, &r) { + if !g.genericRecipeVisible(p, &r) { continue } - craftRecipes = append(craftRecipes, r) + recipes = append(recipes, r) } if input != "" { - g.doCraftWithInput(sess, p, craftRecipes, input) + g.doGenericWithInput(sess, p, recipes, input, skill, flagKey, label, verbCap) return } - lastRecipeID, _ := p.Flags["last_craft"].(string) + lastRecipeID, _ := p.Flags[flagKey].(string) if lastRecipeID != "" { - for i := range craftRecipes { - if craftRecipes[i].ID == lastRecipeID { - r := &craftRecipes[i] - if g.canDoCraftRecipe(p, r) { - g.startCraftRecipe(sess, p, r, 0) + for i := range recipes { + if recipes[i].ID == lastRecipeID { + r := &recipes[i] + if g.canDoGenericRecipe(p, r, skill) { + g.startGenericRecipe(sess, p, r, 0, flagKey) return } break @@ -49,44 +56,45 @@ func (g *Game) doCraft(sess *net.Session, input string) { } } - available := g.availableCraftRecipes(p, craftRecipes) + available := g.availableGenericRecipes(p, recipes) if len(available) == 0 { - sess.WriteLine("You don't have any materials to craft.") + sess.WriteLine("You don't have any materials to " + verbPast + ".") return } - if p.OptionBool("craft_all") && len(available) == 1 { - g.startCraftRecipe(sess, p, &available[0], 0) + optionKey := verbPast + "_all" + if p.OptionBool(optionKey) && len(available) == 1 { + g.startGenericRecipe(sess, p, &available[0], 0, flagKey) return } - g.showProductionTable(sess, p, available, "Crafting", "crafting", "last_craft", "Craft", false) + g.showProductionTable(sess, p, available, label, recipeType, flagKey, verbCap, false) } -func (g *Game) doCraftWithInput(sess *net.Session, p *player.Player, craftRecipes []action.RecipeDef, input string) { +func (g *Game) doGenericWithInput(sess *net.Session, p *player.Player, recipes []action.RecipeDef, input string, skill player.SkillName, flagKey, label, verbCap string) { qty, productName := parseQty(input) productName = strings.TrimSpace(productName) var matched []action.RecipeDef - for _, r := range craftRecipes { + for _, r := range recipes { outDef, _ := g.ItemStore.Load(r.Output) name := r.Output if outDef != nil { name = outDef.Name } - if world.WordPrefixMatch(productName, name) { + if action.WordPrefixMatch(productName, name) { matched = append(matched, r) } } if len(matched) == 0 { - sess.WriteLine("You can't craft that.") + sess.WriteLine("You can't " + strings.ToLower(verbCap) + " that.") return } var available []action.RecipeDef for _, r := range matched { - if g.canDoCraftRecipe(p, &r) { + if g.canDoGenericRecipe(p, &r, skill) { available = append(available, r) } } @@ -97,14 +105,14 @@ func (g *Game) doCraftWithInput(sess *net.Session, p *player.Player, craftRecipe } if len(available) == 1 { - g.startCraftRecipe(sess, p, &available[0], qty) + g.startGenericRecipe(sess, p, &available[0], qty, flagKey) return } - g.showProductionTable(sess, p, available, "Crafting", "crafting", "last_craft", "Craft", false) + g.showProductionTable(sess, p, available, label, label, flagKey, verbCap, false) } -func (g *Game) craftRecipeVisible(p *player.Player, r *action.RecipeDef) bool { +func (g *Game) genericRecipeVisible(p *player.Player, r *action.RecipeDef) bool { if len(r.Station) > 0 { stID, _ := g.findStation(p.RoomID, r.Station) if stID == "" { @@ -117,8 +125,8 @@ func (g *Game) craftRecipeVisible(p *player.Player, r *action.RecipeDef) bool { return true } -func (g *Game) canDoCraftRecipe(p *player.Player, r *action.RecipeDef) bool { - if p.Level(player.Crafting) < r.Level { +func (g *Game) canDoGenericRecipe(p *player.Player, r *action.RecipeDef, skill player.SkillName) bool { + if p.Level(skill) < r.Level { return false } if !r.HasAllItemsQty(p.CountItem) { @@ -136,9 +144,9 @@ func (g *Game) canDoCraftRecipe(p *player.Player, r *action.RecipeDef) bool { return true } -func (g *Game) availableCraftRecipes(p *player.Player, allCraft []action.RecipeDef) []action.RecipeDef { +func (g *Game) availableGenericRecipes(p *player.Player, allRecipes []action.RecipeDef) []action.RecipeDef { var result []action.RecipeDef - for _, r := range allCraft { + for _, r := range allRecipes { if r.HasAllItemsQty(p.CountItem) { result = append(result, r) } @@ -146,11 +154,9 @@ func (g *Game) availableCraftRecipes(p *player.Player, allCraft []action.RecipeD return result } -func (g *Game) startCraftRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int) { - if p.Flags == nil { - p.Flags = make(map[string]any) - } - p.Flags["last_craft"] = recipe.ID +func (g *Game) startGenericRecipe(sess *net.Session, p *player.Player, recipe *action.RecipeDef, count int, flagKey string) { + p.EnsureFlags() + p.Flags[flagKey] = recipe.ID g.AccountStore.SaveCharacter(p) g.startProductionFromRecipe(sess, p, recipe, count) diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go index 20ada37..ced5dcc 100644 --- a/internal/game/cmd_description.go +++ b/internal/game/cmd_description.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doDescription(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player sess.WriteLine("") if p.Description != "" { sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description)) @@ -22,7 +21,7 @@ func (g *Game) doDescription(sess *net.Session) { func (g *Game) handleDescriptionChange(sess *net.Session, input string) { if input != "" { - p := sess.Player.(*player.Player) + p := sess.Player p.Description = input g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("Description set to: %s", input)) diff --git a/internal/game/cmd_drink.go b/internal/game/cmd_drink.go index c7f9b34..44ef991 100644 --- a/internal/game/cmd_drink.go +++ b/internal/game/cmd_drink.go @@ -12,7 +12,7 @@ import ( ) func (g *Game) doDrink(sess *net.Session, args []string) { - p := sess.Player.(*player.Player) + p := sess.Player if len(args) == 0 { sess.WriteLine("Drink what?") @@ -57,7 +57,7 @@ func (g *Game) doDrink(sess *net.Session, args []string) { return } - g.CancelAction(p) + g.cancelAction(p) g.consumeQueue[p.Name] = &QueuedCommand{ Session: sess, diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go index 1fd99b4..d3141e3 100644 --- a/internal/game/cmd_drop.go +++ b/internal/game/cmd_drop.go @@ -5,12 +5,11 @@ import ( "strings" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doDropAll(sess *net.Session) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) p.ActionState = &ActionState{Type: ActionDropping, TargetName: "inventory"} count := 0 @@ -28,8 +27,8 @@ func (g *Game) doDropAll(sess *net.Session) { } func (g *Game) doDropAllNamed(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -86,8 +85,8 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) { } func (g *Game) doDrop(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) qty, itemName := parseQty(input) @@ -182,14 +181,14 @@ func (g *Game) doDrop(sess *net.Session, input string) { } func (g *Game) handleDropAllConfirm(sess *net.Session, input string) { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { sess.State = net.StateGame g.writePrompt(sess) return } - g.CancelAction(p) + g.cancelAction(p) input = strings.TrimSpace(strings.ToLower(input)) if input != "y" && input != "yes" { sess.State = net.StateGame diff --git a/internal/game/cmd_eat.go b/internal/game/cmd_eat.go index e6a0386..f94ade3 100644 --- a/internal/game/cmd_eat.go +++ b/internal/game/cmd_eat.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doEat(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if input == "" { sess.WriteLine("Eat what?") @@ -52,7 +52,7 @@ func (g *Game) doEat(sess *net.Session, input string) { return } - g.CancelAction(p) + g.cancelAction(p) g.consumeQueue[p.Name] = &QueuedCommand{ Session: sess, diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go index b256bc3..f5a9a11 100644 --- a/internal/game/cmd_equipment.go +++ b/internal/game/cmd_equipment.go @@ -5,11 +5,10 @@ import ( "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" ) func (g *Game) doEquipment(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player sess.WriteLine("") sess.WriteLine("Equipment:") diff --git a/internal/game/cmd_estate_directory.go b/internal/game/cmd_estate_directory.go index 6c66ca4..c79135f 100644 --- a/internal/game/cmd_estate_directory.go +++ b/internal/game/cmd_estate_directory.go @@ -15,12 +15,7 @@ import ( ) 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 - } + p := sess.Player homeowners := g.scanHomeowners("owns_house_local_neighborhood") ownedByMe := false @@ -40,7 +35,7 @@ func (g *Game) startEstateDirectory(sess *net.Session, verb string) { if ownedByMe { houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood") if houseRoom > 0 { - g.CancelAction(p) + g.cancelAction(p) g.teleportPlayer(sess, p, houseRoom) return } @@ -82,7 +77,7 @@ func (g *Game) showEstateDirectoryTalk(sess *net.Session, p *player.Player, home Type: "talk", TargetID: "estate_directory", TargetName: "Estate Directory", - Data: map[string]any{"node": "start", "behavior_id": "estate_directory_talk"}, + Data: map[string]any{"node": "start", "estate_directory": true}, } sess.Write("\nChoice: ") return @@ -101,8 +96,8 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { switch input { case "enter": - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { return } houseRoom := g.getOwnedHouseRoom(p, "owns_house_local_neighborhood") @@ -118,8 +113,8 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { p.ActionState = nil g.writePrompt(sess) case "leave", "bye", "exit", "quit": - p, ok := sess.Player.(*player.Player) - if ok { + p := sess.Player + if p != nil { p.Action = nil p.ActionState = nil } @@ -134,7 +129,7 @@ func (g *Game) handleEstateDirectoryTalk(sess *net.Session, input string) { func (g *Game) scanHomeowners(flagKey string) []string { var homeowners []string - charsDir := filepath.Join(g.dataDir, "players", "characters") + charsDir := filepath.Join(g.DataDir, "players", "characters") entries, err := os.ReadDir(charsDir) if err != nil { return homeowners @@ -179,9 +174,7 @@ func (g *Game) scanHomeowners(flagKey string) []string { } func (g *Game) getOwnedHouseRoom(p *player.Player, flagKey string) int { - if p.Flags == nil { - return 0 - } + p.EnsureFlags() val, ok := p.Flags[flagKey] if !ok || val == nil { return 0 @@ -218,6 +211,6 @@ func (g *Game) teleportPlayer(sess *net.Session, p *player.Player, roomID int) { g.seedRoomObjects(p.RoomID) g.AccountStore.SaveCharacter(p) g.doLook(sess) - g.RunEnterSteps(sess, p.RoomID) + g.runEnterSteps(sess, p.RoomID) g.writePrompt(sess) } diff --git a/internal/game/cmd_farm.go b/internal/game/cmd_farm.go index 0a374f0..5ef36d9 100644 --- a/internal/game/cmd_farm.go +++ b/internal/game/cmd_farm.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doPlant(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if input == "" { sess.WriteLine("Plant what?") @@ -93,7 +93,7 @@ func (g *Game) doPlant(sess *net.Session, input string) { } func (g *Game) doHarvest(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player prefix, _, _ := g.findFarmPatch(sess, p, input, func(flags map[string]any, prefix string) bool { ready, _ := flags[prefix+"_ready"].(bool) @@ -179,7 +179,7 @@ func (g *Game) doHarvest(sess *net.Session, input string) { } func (g *Game) doRake(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if !g.hasToolType(p, "rake") { sess.WriteLine("You need a rake to do that.") @@ -225,7 +225,7 @@ func (g *Game) doRake(sess *net.Session, input string) { } func (g *Game) doWater(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if !g.hasToolType(p, "watering_can") { sess.WriteLine("You need a watering can to do that.") @@ -285,7 +285,7 @@ func (g *Game) doWater(sess *net.Session, input string) { } func (g *Game) doCure(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if !p.HasItem("plant_cure") { sess.WriteLine("You don't have any plant cure.") @@ -329,7 +329,7 @@ func (g *Game) doCure(sess *net.Session, input string) { } func (g *Game) doInspect(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player objInstances := g.World.AllObjInstances(p.RoomID) var farmPatches []struct { @@ -437,7 +437,7 @@ func (g *Game) doInspect(sess *net.Session, input string) { continue } - stage := intFlag(p.Flags, fp.prefix+"_stage") + stage := intFromFlag(p.Flags, fp.prefix+"_stage") maxStages := 4 if def, err := g.ItemStore.Load(seedID); err == nil && def.FarmStages > 0 { maxStages = def.FarmStages diff --git a/internal/game/cmd_fletch.go b/internal/game/cmd_fletch.go index 60acb92..1f6a075 100644 --- a/internal/game/cmd_fletch.go +++ b/internal/game/cmd_fletch.go @@ -7,7 +7,6 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) var fletchLogItems = map[string]bool{ @@ -51,7 +50,7 @@ func fletchNeedsChisel(r *action.RecipeDef) bool { } func (g *Game) doFletch(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -121,7 +120,7 @@ func (g *Game) doFletchWithInput(sess *net.Session, p *player.Player, fletchReci if outDef != nil { name = outDef.Name } - if world.WordPrefixMatch(productName, name) { + if action.WordPrefixMatch(productName, name) { matched = append(matched, r) } } diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go index 6f08892..bae721b 100644 --- a/internal/game/cmd_get.go +++ b/internal/game/cmd_get.go @@ -8,8 +8,8 @@ import ( ) func (g *Game) doGet(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") @@ -129,8 +129,8 @@ func (g *Game) doGet(sess *net.Session, input string) { } func (g *Game) doGetAllNamed(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") @@ -251,8 +251,8 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) { } func (g *Game) doGetAll(sess *net.Session) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) ground := g.World.GroundItems(p.RoomID) if len(ground) == 0 { sess.WriteLine("There's nothing on the ground to pick up.") diff --git a/internal/game/cmd_handlers.go b/internal/game/cmd_handlers.go new file mode 100644 index 0000000..b8db359 --- /dev/null +++ b/internal/game/cmd_handlers.go @@ -0,0 +1,370 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" +) + +func (g *Game) executeGet(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Get what?") + } else if args[0] == "all" { + if len(args) == 1 { + g.doGetAll(sess) + } else { + g.doGetAllNamed(sess, strings.Join(args[1:], " ")) + } + } else { + g.doGet(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeDrop(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Drop what?") + } else if args[0] == "all" { + if len(args) == 1 { + g.doDropAll(sess) + } else { + g.doDropAllNamed(sess, strings.Join(args[1:], " ")) + } + } else { + g.doDrop(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeAttack(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + p := sess.Player + target := g.resolveDefaultMob(p.RoomID) + if target == "" { + sess.WriteLine("Attack what?") + return + } + g.doAttack(sess, target) + } else { + g.doAttack(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeStyle(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doStyle(sess, "") + } else { + g.doStyle(sess, args[0]) + } +} + +func (g *Game) executeLook(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doLook(sess) + } else { + g.doLookTarget(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeMove(sess *net.Session, args []string, rawInput string) { + g.doMove(sess, strings.TrimSpace(rawInput), 1.0) +} + +func (g *Game) executeSay(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + sess.WriteLine("Say what?") + } else { + msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 + if msgStart >= 4 && msgStart < len(rawInput) { + g.doSay(sess, rawInput[msgStart:]) + } else { + g.doSay(sess, strings.Join(args, " ")) + } + } +} + +func (g *Game) executeScore(sess *net.Session, args []string, rawInput string) { + g.doScore(sess) +} + +func (g *Game) executeTask(sess *net.Session, args []string, rawInput string) { + g.doTask(sess) +} + +func (g *Game) executeStats(sess *net.Session, args []string, rawInput string) { + g.doStats(sess) +} + +func (g *Game) executeTech(sess *net.Session, args []string, rawInput string) { + g.doTech(sess, strings.Join(args, " ")) +} + +func (g *Game) executeInventory(sess *net.Session, args []string, rawInput string) { + g.doInventory(sess) +} + +func (g *Game) executeQuit(sess *net.Session, args []string, rawInput string) { + g.doQuit(sess) +} + +func (g *Game) executeDescription(sess *net.Session, args []string, rawInput string) { + g.doDescription(sess) +} + +func (g *Game) executeOption(sess *net.Session, args []string, rawInput string) { + g.doOption(sess, strings.Join(args, " ")) +} + +func (g *Game) executeColor(sess *net.Session, args []string, rawInput string) { + g.doColor(sess, strings.Join(args, " ")) +} + +func (g *Game) executeColortable(sess *net.Session, args []string, rawInput string) { + g.doColortable(sess) +} + +func (g *Game) executePrompt(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doPrompt(sess, "") + } else { + msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7 + if msgStart >= 7 && msgStart < len(rawInput) { + g.doPrompt(sess, rawInput[msgStart:]) + } else { + g.doPrompt(sess, strings.Join(args, " ")) + } + } +} + +func (g *Game) executeExits(sess *net.Session, args []string, rawInput string) { + g.doExits(sess) +} + +func (g *Game) executeMap(sess *net.Session, args []string, rawInput string) { + g.doMap(sess) +} + +func (g *Game) executeQueued(sess *net.Session, args []string, rawInput string) { + g.doQueued(sess) +} + +func (g *Game) executeHelp(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doHelp(sess, "") + } else { + g.doHelp(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeGather(sess *net.Session, args []string, rawInput string) { + verb := strings.TrimSpace(rawInput) + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + target := g.resolveDefaultTarget(p.RoomID, verb) + if target == "" { + sess.WriteLine(fmt.Sprintf("%s what?", verb)) + return + } + g.startAction(sess, verb, target) + } else { + g.startAction(sess, verb, strings.Join(args, " ")) + } +} + +func (g *Game) executeUse(sess *net.Session, args []string, rawInput string) { + if g.tryFinishingBlow(sess, strings.Join(args, " ")) { + return + } + g.doUse(sess, strings.Join(args, " ")) +} + +func (g *Game) executeCook(sess *net.Session, args []string, rawInput string) { + g.doCook(sess, strings.Join(args, " ")) +} + +func (g *Game) executeSmelt(sess *net.Session, args []string, rawInput string) { + g.doSmelt(sess, strings.Join(args, " ")) +} + +func (g *Game) executeSmith(sess *net.Session, args []string, rawInput string) { + g.doSmith(sess, strings.Join(args, " ")) +} + +func (g *Game) executeCraft(sess *net.Session, args []string, rawInput string) { + g.doCraft(sess, strings.Join(args, " ")) +} + +func (g *Game) executeEat(sess *net.Session, args []string, rawInput string) { + g.doEat(sess, strings.Join(args, " ")) +} + +func (g *Game) executeDrink(sess *net.Session, args []string, rawInput string) { + g.doDrink(sess, args) +} + +func (g *Game) executeFletch(sess *net.Session, args []string, rawInput string) { + g.doFletch(sess, strings.Join(args, " ")) +} + +func (g *Game) executeClean(sess *net.Session, args []string, rawInput string) { + g.doClean(sess, strings.Join(args, " ")) +} + +func (g *Game) executeMix(sess *net.Session, args []string, rawInput string) { + g.doMix(sess, strings.Join(args, " ")) +} + +func (g *Game) executeIdentify(sess *net.Session, args []string, rawInput string) { + g.doIdentify(sess, strings.Join(args, " ")) +} + +func (g *Game) executeAutotrigger(sess *net.Session, args []string, rawInput string) { + g.doAutotrigger(sess, strings.Join(args, " ")) +} + +func (g *Game) executeMods(sess *net.Session, args []string, rawInput string) { + showAll := len(args) > 0 && args[0] == "all" + g.doMods(sess, showAll) +} + +func (g *Game) executeSneak(sess *net.Session, args []string, rawInput string) { + g.doSneak(sess) +} + +func (g *Game) executeTrigger(sess *net.Session, args []string, rawInput string) { + g.doTrigger(sess, strings.Join(args, " ")) +} + +func (g *Game) executeTalk(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Talk to whom?") + } else { + g.startAction(sess, "talk", strings.Join(args, " ")) + } +} + +func (g *Game) executeBurn(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doBurn(sess, p, strings.Join(args, " ")) +} + +func (g *Game) executeStoke(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doStoke(sess, p, strings.Join(args, " ")) +} + +func (g *Game) executeAlias(sess *net.Session, args []string, rawInput string) { + g.doAlias(sess, args) +} + +func (g *Game) executeUnalias(sess *net.Session, args []string, rawInput string) { + g.doUnalias(sess, args) +} + +func (g *Game) executeSearch(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Search what?") + } else { + g.doSearch(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeSteal(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doSteal(sess, "") + } else { + g.doSteal(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executePlant(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + sess.WriteLine("Plant what?") + } else { + g.doPlant(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeHarvest(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doHarvest(sess, "") + } else { + g.doHarvest(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeRake(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doRake(sess, "") + } else { + g.doRake(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeWater(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doWater(sess, "") + } else { + g.doWater(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeCure(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + if len(args) == 0 { + g.doCure(sess, "") + } else { + g.doCure(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeInspect(sess *net.Session, args []string, rawInput string) { + if len(args) == 0 { + g.doInspect(sess, "") + } else { + g.doInspect(sess, strings.Join(args, " ")) + } +} + +func (g *Game) executeWalk(sess *net.Session, args []string, rawInput string) { + g.doWalk(sess, args) +} + +func (g *Game) executeBank(sess *net.Session, args []string, rawInput string) { + g.doBank(sess) +} + +func (g *Game) executeConstruct(sess *net.Session, args []string, rawInput string) { + g.doConstruct(sess, strings.Join(args, " ")) +} + +func (g *Game) executeJack(sess *net.Session, args []string, rawInput string) { + p := sess.Player + g.cancelAction(p) + g.doJack(sess, strings.Join(args, " ")) +} + +func (g *Game) executeWear(sess *net.Session, args []string, rawInput string) { + g.doWear(sess, strings.Join(args, " ")) +} + +func (g *Game) executeRemove(sess *net.Session, args []string, rawInput string) { + g.doRemove(sess, strings.Join(args, " ")) +} diff --git a/internal/game/cmd_id.go b/internal/game/cmd_id.go index 5ea146d..f7158e9 100644 --- a/internal/game/cmd_id.go +++ b/internal/game/cmd_id.go @@ -82,8 +82,8 @@ func junkMultiplier(junkID string, level int) int { } func (g *Game) doIdentify(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) var found altarConfig foundAny := false diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go index 8684fcd..ae7b328 100644 --- a/internal/game/cmd_inventory.go +++ b/internal/game/cmd_inventory.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doInventory(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player sess.WriteLine("") sess.WriteLine(fmt.Sprintf("Inventory (%d free):", p.FreeSlots())) diff --git a/internal/game/cmd_jack.go b/internal/game/cmd_jack.go index db157f3..8cb5062 100644 --- a/internal/game/cmd_jack.go +++ b/internal/game/cmd_jack.go @@ -5,12 +5,13 @@ import ( "strings" "thehouseoficarus/internal/combat" + "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) func (g *Game) doJack(sess *net.Session, target string) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't do that during combat!") @@ -32,7 +33,7 @@ func (g *Game) doJack(sess *net.Session, target string) { } defID := instances[0].DefID - tDef, ok := terminalDefs[defID] + tDef, ok := hacking.Terminals[defID] if !ok { sess.WriteLine("You can't jack into that.") return @@ -44,13 +45,13 @@ func (g *Game) doJack(sess *net.Session, target string) { return } - g.CancelAction(p) - g.CancelBackgroundAction(p) + g.cancelAction(p) + g.cancelBackgroundAction(p) minigame := tDef.Minigame() display := minigame.Init(hackLevel) - g.hackingStates[p.Name] = &HackingSession{ + g.hackingStates[p.Name] = &hacking.Session{ Minigame: minigame, TerminalID: defID, Level: hackLevel, diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go index 6c83130..5fa2cf4 100644 --- a/internal/game/cmd_look.go +++ b/internal/game/cmd_look.go @@ -15,18 +15,30 @@ import ( ) func (g *Game) doLook(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player room, err := g.World.LoadRoom(p.RoomID) if err != nil { sess.WriteLine("You are in a void.") return } + g.showRoomName(sess, p, room) + g.showRoomDescription(sess, p, room) + g.showRoomMobs(sess, p, room) + g.showRoomObjects(sess, p, room) + g.showGroundItems(sess, p, room) + g.showRoomExits(sess, p, room) + g.showRoomPlayers(sess, p, room) +} + +func (g *Game) showRoomName(sess *net.Session, p *player.Player, room *world.Room) { sess.WriteLines( "", fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))), ) +} +func (g *Game) showRoomDescription(sess *net.Session, p *player.Player, room *world.Room) { descWidth := p.OptionInt("room_desc_width") if descWidth <= 0 { descWidth = 70 @@ -51,8 +63,10 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLine(line) } } +} - mobs := g.MobStore.MobsInRoom(p.RoomID) +func (g *Game) showRoomMobs(sess *net.Session, p *player.Player, room *world.Room) { + mobs := g.MobStore.MobsInRoom(p.RoomID) if len(mobs) > 0 { sort.Slice(mobs, func(i, j int) bool { iDamaged := mobs[i].HP < mobs[i].MaxHP @@ -93,7 +107,9 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLine(fmt.Sprintf(" %s %s%s%s", g.colorize(sess, npcColor, displayName), levelStr, hp, desc)) } } +} +func (g *Game) showRoomObjects(sess *net.Session, p *player.Player, room *world.Room) { objs := g.World.AllObjInstances(p.RoomID) if len(objs) > 0 { sess.WriteLine("") @@ -155,6 +171,9 @@ func (g *Game) doLook(sess *net.Session) { } roomDesc := def.InRoomDescription + if roomDesc != "" { + roomDesc = color.ExpandTags(g.colorMode(sess), roomDesc) + } coloredName := g.objColorize(sess, def, def.Name) coloredPlural := g.objColorize(sess, def, def.Name+"s") @@ -217,7 +236,9 @@ func (g *Game) doLook(sess *net.Session) { } g.showFarmPatches(sess, p) +} +func (g *Game) showGroundItems(sess *net.Session, p *player.Player, room *world.Room) { ground := g.World.GroundItemsDetailed(p.RoomID) if len(ground) > 0 { showDespawn := p.OptionBool("despawn") @@ -334,13 +355,15 @@ func (g *Game) doLook(sess *net.Session) { } } } +} +func (g *Game) showRoomExits(sess *net.Session, p *player.Player, room *world.Room) { if len(room.Exits) > 0 { sess.WriteLine("") - if p.OptionBool("exits") { + if p.OptionBool("exits") { sess.WriteLine("Exits:") type exitLine struct { - dir string + dir string targetName string } var lines []exitLine @@ -383,11 +406,13 @@ func (g *Game) doLook(sess *net.Session) { sess.WriteLine("") } } +} +func (g *Game) showRoomPlayers(sess *net.Session, p *player.Player, room *world.Room) { others := g.Hub.PlayersInRoom(p.RoomID) for _, other := range others { if other != sess && other.Player != nil { - op := other.Player.(*player.Player) + op := other.Player line := fmt.Sprintf("\n%s is here", g.colorize(sess, "player_name", op.Name)) if cs := combat.GetCombat(op.Name); cs != nil { if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 { @@ -408,7 +433,7 @@ func (g *Game) doLook(sess *net.Session) { } func (g *Game) doLookTarget(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player lower := strings.ToLower(input) if exitDir := g.World.ResolveExit(lower); exitDir != "" { @@ -572,7 +597,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { if other == sess || other.Player == nil { continue } - op := other.Player.(*player.Player) + op := other.Player if strings.ToLower(op.Name) != lower { continue } @@ -584,7 +609,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) { } func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { - myP := sess.Player.(*player.Player) + myP := sess.Player theirLevel := p.CombatLevel() levelStr := g.levelColorize(sess, myP.CombatLevel(), theirLevel, fmt.Sprint(theirLevel)) sess.WriteLines( @@ -623,7 +648,7 @@ func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) { } func (g *Game) doExits(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player room, err := g.World.LoadRoom(p.RoomID) if err != nil || len(room.Exits) == 0 { sess.WriteLine("There are no exits here.") diff --git a/internal/game/cmd_map.go b/internal/game/cmd_map.go index 1c78be3..7cad008 100644 --- a/internal/game/cmd_map.go +++ b/internal/game/cmd_map.go @@ -2,11 +2,10 @@ package game import ( "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doMap(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mapWidth := p.OptionInt("mapwidth") mapHeight := p.OptionInt("mapheight") if mapWidth < 5 { diff --git a/internal/game/cmd_mix.go b/internal/game/cmd_mix.go index 3082870..8fc600b 100644 --- a/internal/game/cmd_mix.go +++ b/internal/game/cmd_mix.go @@ -4,12 +4,11 @@ import ( "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) func (g *Game) doMix(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) allRecipes, err := g.RecipeStore.LoadAll() if err != nil { @@ -35,7 +34,7 @@ func (g *Game) doMix(sess *net.Session, input string) { if outDef != nil { name = outDef.Name } - if world.WordPrefixMatch(itemName, name) { + if action.WordPrefixMatch(itemName, name) { matched = append(matched, r) } } @@ -68,7 +67,7 @@ func (g *Game) doMix(sess *net.Session, input string) { names = append(names, g.recipeName(sess, &r)) } g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = recipeMenuData(available) + sess.PendingMenu = recipeMenuData(recipeDefIDs(available)) } func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []action.RecipeDef) { @@ -109,5 +108,9 @@ func (g *Game) showMixMenu(sess *net.Session, p *player.Player, allRecipes []act names = append(names, e.ItemName) } g.showMenuTable(sess, "What would you like to mix?", names) - sess.PendingMenu = entryMenuData(entries) + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.Recipe.ID + } + sess.PendingMenu = recipeMenuData(ids) } diff --git a/internal/game/cmd_mods.go b/internal/game/cmd_mods.go index b9161e3..65d7ad7 100644 --- a/internal/game/cmd_mods.go +++ b/internal/game/cmd_mods.go @@ -3,109 +3,96 @@ package game import ( "fmt" "sort" - "strings" "thehouseoficarus/internal/color" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" ) -func (g *Game) doMods(sess *net.Session) { - p := sess.Player.(*player.Player) - mode := g.colorMode(sess) +func (g *Game) doMods(sess *net.Session, showAll bool) { + p := sess.Player sciLevel := p.Level(player.Science) + mode := g.colorMode(sess) categories := []struct { Name string Cat ModCategory }{ {"Combat", ModCombat}, - {"Processing", ModProcessing}, {"Utility", ModUtility}, {"Transport", ModTransport}, {"Enchantment", ModEnchant}, } sess.WriteLine("") - anyMods := false for _, cat := range categories { - var mods []*ModDef + var catMods []*ModDef for _, m := range AllMods { - if m.Category == cat.Cat && m.Level <= sciLevel { - mods = append(mods, m) + if m.Category != cat.Cat { + continue + } + if !showAll && m.Level > sciLevel { + continue } + catMods = append(catMods, m) } - if len(mods) == 0 { + if len(catMods) == 0 { continue } + anyMods = true - sort.Slice(mods, func(i, j int) bool { - return mods[i].Level < mods[j].Level + sort.Slice(catMods, func(i, j int) bool { + return catMods[i].Level < catMods[j].Level }) - t := &Table{Title: cat.Name + " Mods", Columns: []string{ - color.Render(mode, color.Parse("75"), "Mod"), - color.Render(mode, color.Parse("230"), "Lv"), - color.Render(mode, color.Parse("245"), "Cost"), - color.Render(mode, color.Parse("222"), "XP"), - }} + t := &Table{ + Title: cat.Name, + Columns: []string{ + color.Render(mode, color.Parse("75"), "Module"), + color.Render(mode, color.Parse("245"), "Level"), + color.Render(mode, color.Parse("222"), "Cost"), + color.Render(mode, color.Parse("179"), "XP"), + }, + } + + for _, m := range catMods { + unlocked := m.Level <= sciLevel + levelStr := fmt.Sprintf("Lv %d", m.Level) + costStr := g.junkCostString(p, m) + xpStr := fmt.Sprintf("%d XP", m.BaseXP) - for _, m := range mods { - costStr := g.modCostDisplay(p, m) - xpStr := fmt.Sprintf("%.1f", m.BaseXP) - if m.MaxHit > 0 { - xpStr += fmt.Sprintf(" (max %d)", m.MaxHit) + if unlocked { + t.Rows = append(t.Rows, []string{ + color.Render(mode, color.Parse("75"), m.Name), + color.Render(mode, color.Parse("245"), levelStr), + color.Render(mode, color.Parse("222"), costStr), + color.Render(mode, color.Parse("179"), xpStr), + }) + } else { + t.Rows = append(t.Rows, []string{ + g.colorize(sess, "dim", m.Name), + g.colorize(sess, "dim", levelStr), + g.colorize(sess, "dim", costStr), + g.colorize(sess, "dim", xpStr), + }) } - t.Rows = append(t.Rows, []string{ - color.Render(mode, color.Parse("75"), m.Name), - color.Render(mode, color.Parse("230"), fmt.Sprint(m.Level)), - color.Render(mode, color.Parse("245"), costStr), - color.Render(mode, color.Parse("222"), xpStr), - }) } for _, line := range t.Render(p.OptionBool("unicode")) { sess.WriteLine(line) } - anyMods = true + sess.WriteLine("") } if !anyMods { - sess.WriteLine("You don't know any mods yet. Train Science to unlock mods.") + sess.WriteLine("No modules found.") } - if p.AutocastMod != "" { - mod := GetMod(p.AutocastMod) + if p.AutotriggerMod != "" { + mod := GetMod(p.AutotriggerMod) if mod != nil { - sess.WriteLine(fmt.Sprintf("\nAutocast: %s", g.colorize(sess, "science_mod", mod.Name))) - } - } -} - -func (g *Game) modCostDisplay(p *player.Player, mod *ModDef) string { - cost := g.effectiveJunkCost(p, mod) - if len(cost) == 0 { - return "free" - } - var parts []string - keys := make([]string, 0, len(cost)) - for k := range cost { - keys = append(keys, k) - } - sort.Strings(keys) - for _, itemID := range keys { - qty := cost[itemID] - def, _ := g.ItemStore.Load(itemID) - name := itemID - if def != nil { - name = def.Name - } - if qty > 1 { - parts = append(parts, fmt.Sprintf("%d %s", qty, name)) - } else { - parts = append(parts, name) + sess.WriteLine(fmt.Sprintf("Autotrigger: %s", g.colorize(sess, "science_mod", mod.Name))) } } - return strings.Join(parts, ", ") } diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go index b3fe254..5d6ea66 100644 --- a/internal/game/cmd_move.go +++ b/internal/game/cmd_move.go @@ -10,8 +10,8 @@ import ( "thehouseoficarus/internal/player" ) -func (g *Game) doMove(sess *net.Session, dir string) { - p := sess.Player.(*player.Player) +func (g *Game) doMove(sess *net.Session, dir string, multiplier float64) { + p := sess.Player exitDir := g.World.ResolveExit(dir) if exitDir == "" { sess.WriteLine("Go where?") @@ -50,10 +50,10 @@ func (g *Game) doMove(sess *net.Session, dir string) { inCombat := combat.GetCombat(p.Name) != nil if !inCombat && p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } - ticks := g.moveTicks(p) + ticks := g.moveTicks(p, multiplier) p.MoveDirection = string(exitDir) p.MoveTarget = targetID @@ -83,7 +83,7 @@ var gracefulItems = map[string]bool{ "graceful_cape": true, } -func (g *Game) moveTicks(p *player.Player) int { +func (g *Game) moveTicks(p *player.Player, multiplier float64) int { if id, ok := p.Equipment[object.SlotBack]; ok && id == "cape_of_agility" { return 0 } @@ -95,11 +95,12 @@ func (g *Game) moveTicks(p *player.Player) int { } } - base := 4.0 - float64(count)*0.25 + base := 2.0 - float64(count)*0.15 if count == 6 { - base -= 0.5 + base -= 0.1 } + base *= multiplier return engine.ToTicks(base) - 1 } @@ -152,7 +153,7 @@ func (g *Game) completeMove(sess *net.Session, p *player.Player) { g.doMap(sess) } - g.RunEnterSteps(sess, targetID) + g.runEnterSteps(sess, targetID) g.checkAggro(sess) } @@ -183,11 +184,8 @@ func (g *Game) seedRoomObjects(roomID int) { if len(obj.WanderRooms) > 0 { g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval) } - if def.BehaviorID != "" { - cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) - if err == nil && cfg.DepleteTimer > 0 { - g.World.SetObjDepleteTimer(roomID, obj.ID, cfg.DepleteTimer) - } + if def.IsGatherable() && def.Gather.DepleteTimer > 0 { + g.World.SetObjDepleteTimer(roomID, obj.ID, def.Gather.DepleteTimer) } } } diff --git a/internal/game/cmd_option.go b/internal/game/cmd_option.go index 3d4d420..fdf5371 100644 --- a/internal/game/cmd_option.go +++ b/internal/game/cmd_option.go @@ -11,7 +11,7 @@ import ( ) func (g *Game) doOption(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) if input == "" { diff --git a/internal/game/cmd_prompt.go b/internal/game/cmd_prompt.go index 6cc45f9..8850ea1 100644 --- a/internal/game/cmd_prompt.go +++ b/internal/game/cmd_prompt.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doPrompt(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player if input == "" { prompt := p.Prompt diff --git a/internal/game/cmd_queued.go b/internal/game/cmd_queued.go index d9e92d5..fdb1643 100644 --- a/internal/game/cmd_queued.go +++ b/internal/game/cmd_queued.go @@ -5,11 +5,10 @@ import ( "sort" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doQueued(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player freeCmds := g.freeQueue[p.Name] activeCmd := g.activeQueue[p.Name] diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go index 2ec0221..d52c9ae 100644 --- a/internal/game/cmd_quit.go +++ b/internal/game/cmd_quit.go @@ -6,18 +6,17 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doQuit(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player if combat.GetCombat(p.Name) != nil { sess.WriteLine("You can't rest during combat!") return } - g.CancelAction(p) + g.cancelAction(p) p.ActionState = &ActionState{Type: ActionResting} g.cancelRest(p.Name) diff --git a/internal/game/cmd_registry.go b/internal/game/cmd_registry.go new file mode 100644 index 0000000..e5bf30e --- /dev/null +++ b/internal/game/cmd_registry.go @@ -0,0 +1,170 @@ +package game + +import ( + "fmt" + "strings" + + "thehouseoficarus/internal/net" +) + +type commandHandler func(g *Game, sess *net.Session, args []string, rawInput string) + +type commandDef struct { + handler commandHandler + class CommandClass +} + +var commandRegistry = map[string]commandDef{ + "get": {(*Game).executeGet, ClassActive}, + "take": {(*Game).executeGet, ClassActive}, + "grab": {(*Game).executeGet, ClassActive}, + "pick": {(*Game).executeGet, ClassActive}, + "drop": {(*Game).executeDrop, ClassActive}, + "attack": {(*Game).executeAttack, ClassActive}, + "kill": {(*Game).executeAttack, ClassActive}, + "style": {(*Game).executeStyle, ClassInstant}, + "look": {(*Game).executeLook, ClassInstant}, + "l": {(*Game).executeLook, ClassInstant}, + "north": {(*Game).executeMove, ClassActive}, + "n": {(*Game).executeMove, ClassActive}, + "south": {(*Game).executeMove, ClassActive}, + "s": {(*Game).executeMove, ClassActive}, + "east": {(*Game).executeMove, ClassActive}, + "e": {(*Game).executeMove, ClassActive}, + "west": {(*Game).executeMove, ClassActive}, + "w": {(*Game).executeMove, ClassActive}, + "up": {(*Game).executeMove, ClassActive}, + "u": {(*Game).executeMove, ClassActive}, + "down": {(*Game).executeMove, ClassActive}, + "d": {(*Game).executeMove, ClassActive}, + "say": {(*Game).executeSay, ClassInstant}, + "sc": {(*Game).executeScore, ClassInstant}, + "score": {(*Game).executeScore, ClassInstant}, + "task": {(*Game).executeTask, ClassInstant}, + "stats": {(*Game).executeStats, ClassInstant}, + "tech": {(*Game).executeTech, ClassInstant}, + "t": {(*Game).executeTech, ClassInstant}, + "i": {(*Game).executeInventory, ClassInstant}, + "inv": {(*Game).executeInventory, ClassInstant}, + "inventory": {(*Game).executeInventory, ClassInstant}, + "quit": {(*Game).executeQuit, ClassActive}, + "description": {(*Game).executeDescription, ClassInstant}, + "desc": {(*Game).executeDescription, ClassInstant}, + "option": {(*Game).executeOption, ClassInstant}, + "options": {(*Game).executeOption, ClassInstant}, + "color": {(*Game).executeColor, ClassInstant}, + "colors": {(*Game).executeColor, ClassInstant}, + "colortable": {(*Game).executeColortable, ClassInstant}, + "prompt": {(*Game).executePrompt, ClassInstant}, + "exits": {(*Game).executeExits, ClassInstant}, + "map": {(*Game).executeMap, ClassInstant}, + "queued": {(*Game).executeQueued, ClassInstant}, + "help": {(*Game).executeHelp, ClassInstant}, + "mine": {(*Game).executeGather, ClassActive}, + "chop": {(*Game).executeGather, ClassActive}, + "fish": {(*Game).executeGather, ClassActive}, + "cut": {(*Game).executeGather, ClassActive}, + "pull": {(*Game).executeUse, ClassActive}, + "push": {(*Game).executeUse, ClassActive}, + "use": {(*Game).executeUse, ClassActive}, + "cook": {(*Game).executeCook, ClassActive}, + "smelt": {(*Game).executeSmelt, ClassActive}, + "smith": {(*Game).executeSmith, ClassActive}, + "craft": {(*Game).executeCraft, ClassActive}, + "eat": {(*Game).executeEat, ClassFree}, + "drink": {(*Game).executeDrink, ClassFree}, + "fletch": {(*Game).executeFletch, ClassFree}, + "clean": {(*Game).executeClean, ClassFree}, + "mix": {(*Game).executeMix, ClassActive}, + "id": {(*Game).executeIdentify, ClassActive}, + "identify": {(*Game).executeIdentify, ClassActive}, + "autotrigger": {(*Game).executeAutotrigger, ClassInstant}, + "auto": {(*Game).executeAutotrigger, ClassInstant}, + "mods": {(*Game).executeMods, ClassInstant}, + "modlist": {(*Game).executeMods, ClassInstant}, + "mod": {(*Game).executeMods, ClassInstant}, + "sneak": {(*Game).executeSneak, ClassInstant}, + "trigger": {(*Game).executeTrigger, ClassActive}, + "talk": {(*Game).executeTalk, ClassActive}, + "speak": {(*Game).executeTalk, ClassActive}, + "ask": {(*Game).executeTalk, ClassActive}, + "burn": {(*Game).executeBurn, ClassActive}, + "stoke": {(*Game).executeStoke, ClassActive}, + "alias": {(*Game).executeAlias, ClassInstant}, + "unalias": {(*Game).executeUnalias, ClassInstant}, + "search": {(*Game).executeSearch, ClassActive}, + "steal": {(*Game).executeSteal, ClassActive}, + "thieve": {(*Game).executeSteal, ClassActive}, + "plant": {(*Game).executePlant, ClassActive}, + "harvest": {(*Game).executeHarvest, ClassActive}, + "rake": {(*Game).executeRake, ClassActive}, + "water": {(*Game).executeWater, ClassActive}, + "cure": {(*Game).executeCure, ClassActive}, + "inspect": {(*Game).executeInspect, ClassInstant}, + "walk": {(*Game).executeWalk, ClassActive}, + "bank": {(*Game).executeBank, ClassActive}, + "construct": {(*Game).executeConstruct, ClassActive}, + "make": {(*Game).executeConstruct, ClassActive}, + "jack": {(*Game).executeJack, ClassActive}, + "jackin": {(*Game).executeJack, ClassActive}, + "eq": {(*Game).executeWear, ClassFree}, + "equipment": {(*Game).executeWear, ClassFree}, + "equip": {(*Game).executeWear, ClassFree}, + "wear": {(*Game).executeWear, ClassFree}, + "wield": {(*Game).executeWear, ClassFree}, + "remove": {(*Game).executeRemove, ClassFree}, + "unwear": {(*Game).executeRemove, ClassFree}, + "unwield": {(*Game).executeRemove, ClassFree}, +} + +func classifyCommand(cmd string) CommandClass { + if def, ok := commandRegistry[cmd]; ok { + return def.class + } + if _, ok := verbAliases[cmd]; ok { + return ClassActive + } + if obstacleVerbs[cmd] { + return ClassActive + } + return ClassUnknown +} + +func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { + if def, ok := commandRegistry[cmd]; ok { + def.handler(g, sess, args, rawInput) + return + } + + if obstacleVerbs[cmd] { + g.doObstacle(sess, cmd) + return + } + + p := sess.Player + if a, ok := verbAliases[cmd]; ok { + g.cancelAction(p) + switch a { + case "gather": + if len(args) == 0 { + target := g.resolveDefaultTarget(p.RoomID, cmd) + if target == "" { + sess.WriteLine(fmt.Sprintf("%s what?", cmd)) + return + } + g.startAction(sess, cmd, target) + } else { + g.startAction(sess, cmd, strings.Join(args, " ")) + } + case "talk": + if len(args) == 0 { + sess.WriteLine("Talk to whom?") + } else { + g.startAction(sess, "talk", strings.Join(args, " ")) + } + } + return + } + + sess.WriteLine("Unknown command.") +} diff --git a/internal/game/cmd_remove.go b/internal/game/cmd_remove.go index d36a8fd..e168fd9 100644 --- a/internal/game/cmd_remove.go +++ b/internal/game/cmd_remove.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doRemove(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player lower := strings.ToLower(strings.TrimSpace(input)) if lower == "all" { @@ -22,7 +22,7 @@ func (g *Game) doRemove(sess *net.Session, input string) { return } - g.CancelAction(p) + g.cancelAction(p) qty, itemName := parseQty(input) @@ -136,7 +136,7 @@ func (g *Game) removeAmmo(sess *net.Session, p *player.Player, qty int) { } func (g *Game) doRemoveAll(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player if len(p.Equipment) == 0 { sess.WriteLine("You aren't wearing anything.") @@ -153,7 +153,7 @@ func (g *Game) doRemoveAll(sess *net.Session) { return } - g.CancelAction(p) + g.cancelAction(p) for _, slot := range EquipSlots { itemID, ok := p.Equipment[slot] diff --git a/internal/game/cmd_say.go b/internal/game/cmd_say.go index 67d8165..3d619a0 100644 --- a/internal/game/cmd_say.go +++ b/internal/game/cmd_say.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doSay(sess *net.Session, msg string) { - p := sess.Player.(*player.Player) + p := sess.Player roomID := p.RoomID for _, other := range g.Hub.PlayersInRoom(roomID) { diff --git a/internal/game/cmd_score.go b/internal/game/cmd_score.go index 82c0803..0b0caf2 100644 --- a/internal/game/cmd_score.go +++ b/internal/game/cmd_score.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doScore(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) sess.WriteLines( "", diff --git a/internal/game/cmd_search.go b/internal/game/cmd_search.go index cf37c90..cc22d49 100644 --- a/internal/game/cmd_search.go +++ b/internal/game/cmd_search.go @@ -5,12 +5,11 @@ import ( "strings" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doSearch(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) lower := strings.ToLower(strings.TrimSpace(input)) if lower == "" { diff --git a/internal/game/cmd_shop.go b/internal/game/cmd_shop.go index a3f7613..2742151 100644 --- a/internal/game/cmd_shop.go +++ b/internal/game/cmd_shop.go @@ -2,11 +2,12 @@ package game import ( "fmt" + "strconv" "strings" "thehouseoficarus/internal/action" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" + "thehouseoficarus/internal/object" ) func (g *Game) handleShopInput(sess *net.Session, input string) { @@ -27,17 +28,9 @@ func (g *Game) handleShopInput(sess *net.Session, input string) { switch cmd { case "buy": - if len(parts) < 2 { - sess.WriteLine("Buy what? Use 'browse' to see available items.") - } else { - g.doShopBuy(sess, strings.Join(parts[1:], " ")) - } + g.cmdShopBuy(sess, parts[1:]) case "sell": - if len(parts) < 2 { - sess.WriteLine("Sell what?") - } else { - g.doShopSell(sess, strings.Join(parts[1:], " ")) - } + g.cmdShopSell(sess, parts[1:]) case "browse", "list": g.showShopBrowse(sess) case "leave", "bye", "exit", "quit": @@ -49,15 +42,65 @@ func (g *Game) handleShopInput(sess *net.Session, input string) { g.writeShopPrompt(sess) } +func parseShopQty(args []string) (int, string, bool) { + if len(args) == 0 { + return 1, "", false + } + if strings.EqualFold(args[0], "all") { + rest := strings.Join(args[1:], " ") + return 0, rest, true + } + if n, err := strconv.Atoi(args[0]); err == nil && n > 0 { + return n, strings.Join(args[1:], " "), false + } + return 1, strings.Join(args, " "), false +} + +func (g *Game) cmdShopBuy(sess *net.Session, args []string) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + qty, itemName, all := parseShopQty(args) + if itemName == "" { + sess.WriteLine("Buy what? Use 'browse' to see available items.") + return + } + + g.doShopBuy(sess, itemName, qty, all) +} + +func (g *Game) cmdShopSell(sess *net.Session, args []string) { + cfg := g.shopConfig(sess) + if cfg == nil { + return + } + + qty, itemName, all := parseShopQty(args) + if itemName == "" { + sess.WriteLine("Sell what?") + return + } + + g.doShopSell(sess, itemName, qty, all) +} + func (g *Game) writeShopPrompt(sess *net.Session) { sess.Write(fmt.Sprintf("\n%s", g.colorize(sess, "dialog", "> "))) } func (g *Game) shopConfig(sess *net.Session) *action.ShopConfig { - cfg, _ := sess.Shop.(*action.ShopConfig) + cfg := sess.Shop return cfg } +type shopMatch struct { + si *action.ShopItem + name string + def *object.ItemDef +} + func (g *Game) showShopBrowse(sess *net.Session) { cfg := g.shopConfig(sess) if cfg == nil { @@ -74,16 +117,16 @@ func (g *Game) showShopBrowse(sess *net.Session) { } unicode := true - if p, ok := sess.Player.(*player.Player); ok { + if p := sess.Player; p != nil { unicode = p.OptionBool("unicode") } t := Table{ Title: "Shop Inventory", - Columns: []string{"#", "Item", "Buy Price", "Sell Price"}, + Columns: []string{"Item", "Buy Price", "Sell Price"}, } - for i, si := range cfg.Items { + for _, si := range cfg.Items { def, _ := g.ItemStore.Load(si.ItemID) itemName := si.ItemID if def != nil { @@ -95,7 +138,6 @@ func (g *Game) showShopBrowse(sess *net.Session) { sellStr = fmt.Sprintf("%d cr", si.SellPrice) } t.Rows = append(t.Rows, []string{ - fmt.Sprintf("%d", i+1), g.colorize(sess, "item", itemName), g.colorize(sess, "credits_pickup", buyStr), g.colorize(sess, "credits_pickup", sellStr), @@ -107,55 +149,84 @@ func (g *Game) showShopBrowse(sess *net.Session) { } } -func (g *Game) doShopBuy(sess *net.Session, input string) { +func (g *Game) findShopMatches(input string, cfg *action.ShopConfig) []shopMatch { + var matches []shopMatch + for i := range cfg.Items { + si := &cfg.Items[i] + itemName := si.ItemID + def, _ := g.ItemStore.Load(si.ItemID) + if def != nil { + itemName = def.Name + } + if action.WordPrefixMatch(input, itemName) { + matches = append(matches, shopMatch{si: si, name: itemName, def: def}) + } + } + return matches +} + +func (g *Game) doShopBuy(sess *net.Session, input string, qty int, all bool) { cfg := g.shopConfig(sess) if cfg == nil { return } - idx, err := parseChoiceIndex(input) - if err == nil && idx > 0 && idx <= len(cfg.Items) { - si := cfg.Items[idx-1] - g.buyShopItem(sess, si) + p := sess.Player + + matches := g.findShopMatches(input, cfg) + if len(matches) == 0 { + sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.") return } - var match *action.ShopItem - for i := range cfg.Items { - si := &cfg.Items[i] - def, _ := g.ItemStore.Load(si.ItemID) - itemName := si.ItemID - if def != nil { - itemName = def.Name + if len(matches) > 1 { + sess.WriteLine("That's ambiguous, which one?") + for _, m := range matches { + sess.WriteLine(fmt.Sprintf(" - %s", g.colorize(sess, "item", m.name))) } + return + } - invMatches := g.collectMatches(input, func(yield func(string, int) bool) { - yield(itemName, -1) - }) + si := matches[0].si + def := matches[0].def - if len(invMatches) > 0 { - if match != nil { - sess.WriteLine(fmt.Sprintf("That's ambiguous, which one? (use #, e.g. buy %s 1)", si.ItemID)) - return + actualQty := qty + if all || qty == 0 { + affordable := p.Credits / si.BuyPrice + if def != nil && def.Stackable { + actualQty = affordable + } else { + freeSlots := p.FreeSlots() + actualQty = affordable + if actualQty > freeSlots { + actualQty = freeSlots } - match = si } } - if match != nil { - g.buyShopItem(sess, *match) + if actualQty <= 0 { + if p.Credits < si.BuyPrice { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + } else { + sess.WriteLine("Your inventory is full.") + } return } - sess.WriteLine("That item isn't for sale here. Use 'browse' to see shop inventory.") + g.buyShopItem(sess, *si, actualQty) } -func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem) { - p, _ := sess.Player.(*player.Player) +func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem, qty int) { + p := sess.Player - if p.Credits < si.BuyPrice { - sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) - return + totalCost := si.BuyPrice * qty + if p.Credits < totalCost { + qty = p.Credits / si.BuyPrice + if qty <= 0 { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + return + } + totalCost = si.BuyPrice * qty } def, _ := g.ItemStore.Load(si.ItemID) @@ -168,37 +239,83 @@ func (g *Game) buyShopItem(sess *net.Session, si action.ShopItem) { for i := 0; i < 28; i++ { slot := p.InvSlot(i) if slot != nil && slot.ItemID == si.ItemID { - p.Credits -= si.BuyPrice - slot.Quantity++ + p.Credits -= totalCost + slot.Quantity += qty g.AccountStore.SaveCharacter(p) itemColor := g.itemColorize(sess, def, displayName) - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + } else { + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + } return } } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + p.Credits -= totalCost + p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, qty)) + g.AccountStore.SaveCharacter(p) + + itemColor := g.itemColorize(sess, def, displayName) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + } else { + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + } + return + } + + freeSlots := p.FreeSlots() + if qty > freeSlots { + if freeSlots == 0 { + sess.WriteLine("Your inventory is full.") + return + } + qty = freeSlots + totalCost = si.BuyPrice * qty + if p.Credits < totalCost { + qty = p.Credits / si.BuyPrice + totalCost = si.BuyPrice * qty + } } - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") + if qty <= 0 { + if p.Credits < si.BuyPrice { + sess.WriteLine(fmt.Sprintf("You need %d credits to buy that (you have %d).", si.BuyPrice, p.Credits)) + } else { + sess.WriteLine("Your inventory is full.") + } return } - p.Credits -= si.BuyPrice - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: si.ItemID, Quantity: 1}) + p.Credits -= totalCost + for j := 0; j < qty; j++ { + freeSlot := p.FirstFreeSlot() + p.SetInvSlot(freeSlot, g.newInventorySlot(si.ItemID, 1)) + } g.AccountStore.SaveCharacter(p) itemColor := g.itemColorize(sess, def, displayName) - sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + if qty == 1 { + sess.WriteLine(fmt.Sprintf("You buy a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", si.BuyPrice)))) + } else { + sess.WriteLine(fmt.Sprintf("You buy %d %s for %s credits.", qty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCost)))) + } } -func (g *Game) doShopSell(sess *net.Session, input string) { +func (g *Game) doShopSell(sess *net.Session, input string, qty int, all bool) { cfg := g.shopConfig(sess) if cfg == nil { return } - p, _ := sess.Player.(*player.Player) + p := sess.Player matches := g.findInventoryMatches(input, p) if len(matches) == 0 { @@ -208,6 +325,9 @@ func (g *Game) doShopSell(sess *net.Session, input string) { if len(matches) > 1 { sess.WriteLine("That's ambiguous, which one?") + for _, m := range uniqueItemNames(matches) { + sess.WriteLine(fmt.Sprintf(" - %s", g.colorize(sess, "item", m))) + } return } @@ -232,38 +352,52 @@ func (g *Game) doShopSell(sess *net.Session, input string) { return } - p.RemoveItem(match.ID, 1) - p.Credits += shopItem.SellPrice + totalInInv := p.CountItem(match.ID) + actualQty := qty + if all || qty == 0 { + actualQty = totalInInv + } else if actualQty > totalInInv { + actualQty = totalInInv + } + + if actualQty <= 0 { + sess.WriteLine("You don't have that item.") + return + } + + p.RemoveItem(match.ID, actualQty) + p.Credits += shopItem.SellPrice * actualQty g.AccountStore.SaveCharacter(p) def, _ := g.ItemStore.Load(match.ID) - displayName := match.ID - if def != nil { - displayName = def.Name - } + displayName := match.Name itemColor := g.itemColorize(sess, def, displayName) - sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice)))) + if actualQty == 1 { + sess.WriteLine(fmt.Sprintf("You sell a %s for %s credits.", itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", shopItem.SellPrice)))) + } else { + totalCredits := shopItem.SellPrice * actualQty + sess.WriteLine(fmt.Sprintf("You sell %d %s for %s credits.", actualQty, itemColor, g.colorize(sess, "credits_pickup", fmt.Sprintf("%d", totalCredits)))) + } } func (g *Game) leaveShop(sess *net.Session) { sess.Shop = nil - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Action == nil || p.Action.Type != "talk" { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != "talk" { sess.State = net.StateGame if p != nil { - g.CancelAction(p) + g.cancelAction(p) } g.writePrompt(sess) return } - behaviorID, _ := p.Action.Data["behavior_id"].(string) nodeKey, _ := p.Action.Data["node"].(string) - cfg, err := g.BehaviorStore.LoadTalk(behaviorID) - if err != nil { + cfg, ok := p.Action.Data["talk_cfg"].(*action.TalkConfig) + if !ok || cfg == nil { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) return } @@ -271,7 +405,7 @@ func (g *Game) leaveShop(sess *net.Session) { node, ok := cfg.Nodes[nodeKey] if !ok { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) return } @@ -287,7 +421,7 @@ func (g *Game) leaveShop(sess *net.Session) { } if visible == 0 { sess.State = net.StateGame - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) return } diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index 46f2607..53ff426 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -9,8 +9,8 @@ import ( ) func (g *Game) doSmelt(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) stationDefID, stationName := g.findStation(p.RoomID, []string{"furnace"}) if stationDefID == "" { @@ -64,7 +64,7 @@ func (g *Game) doSmelt(sess *net.Session, input string) { return } - sess.PendingMenu = recipeMenuData(recipes) + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, r := range recipes { @@ -108,5 +108,9 @@ func (g *Game) showSmeltMenu(sess *net.Session, p *player.Player, allRecipes []a names = append(names, e.ItemName) } g.showMenuTable(sess, "What would you like to smelt?", names) - sess.PendingMenu = entryMenuData(entries) + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.Recipe.ID + } + sess.PendingMenu = recipeMenuData(ids) } diff --git a/internal/game/cmd_smith.go b/internal/game/cmd_smith.go index b539640..067d251 100644 --- a/internal/game/cmd_smith.go +++ b/internal/game/cmd_smith.go @@ -11,8 +11,8 @@ import ( ) func (g *Game) doSmith(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) stationDefID, _ := g.findStation(p.RoomID, []string{"anvil"}) if stationDefID == "" { diff --git a/internal/game/cmd_sneak.go b/internal/game/cmd_sneak.go index 97aab8a..b37862d 100644 --- a/internal/game/cmd_sneak.go +++ b/internal/game/cmd_sneak.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doSneak(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player p.Sneaking = !p.Sneaking if p.Sneaking { p.SneakNotified = make(map[string]bool) @@ -29,8 +28,8 @@ func (g *Game) SneakTick() { } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || !p.Sneaking { + p := sess.Player + if p == nil || !p.Sneaking { continue } diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 0dc963e..51bacf1 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doStats(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player totals := g.playerEquipBonuses(p) attackType := "crush" diff --git a/internal/game/cmd_style.go b/internal/game/cmd_style.go index 901d02d..269fa3f 100644 --- a/internal/game/cmd_style.go +++ b/internal/game/cmd_style.go @@ -9,7 +9,7 @@ import ( ) func (g *Game) doStyle(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player styles := []string{"accurate", "aggressive", "defensive", "balanced"} if input == "" { diff --git a/internal/game/cmd_task.go b/internal/game/cmd_task.go index 2dcf3f4..5b67421 100644 --- a/internal/game/cmd_task.go +++ b/internal/game/cmd_task.go @@ -4,11 +4,10 @@ import ( "fmt" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) func (g *Game) doTask(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mobID := getPlayerFlagString(p, "assassin_task_mob") remaining := getPlayerFlagInt(p, "assassin_task_remaining") diff --git a/internal/game/cmd_tech.go b/internal/game/cmd_tech.go index f51ccd9..89d5c2a 100644 --- a/internal/game/cmd_tech.go +++ b/internal/game/cmd_tech.go @@ -10,7 +10,7 @@ import ( ) func (g *Game) doTech(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player techLevel := p.Level(player.Technology) if techLevel < 1 { @@ -109,7 +109,7 @@ func (g *Game) deactivateGroup(p *player.Player, group string) []string { } func (g *Game) doTechList(sess *net.Session) { - p := sess.Player.(*player.Player) + p := sess.Player mode := g.colorMode(sess) techLevel := p.Level(player.Technology) diff --git a/internal/game/cmd_trigger.go b/internal/game/cmd_trigger.go index 7ef4913..89ed397 100644 --- a/internal/game/cmd_trigger.go +++ b/internal/game/cmd_trigger.go @@ -11,7 +11,7 @@ import ( ) func (g *Game) doTrigger(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player input = strings.TrimSpace(input) if input == "" { @@ -40,8 +40,6 @@ func (g *Game) doTrigger(sess *net.Session, input string) { g.triggerCombatMod(sess, p, mod, targetArg) case ModTransport: g.triggerTransport(sess, p, mod) - case ModProcessing: - g.triggerProcessing(sess, p, mod, targetArg) case ModUtility: g.triggerUtility(sess, p, mod, targetArg) case ModEnchant: @@ -65,13 +63,13 @@ func (g *Game) parseTriggerArgs(input string) (*ModDef, string) { func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { if cs := combat.GetCombat(p.Name); cs != nil { - p.AutocastMod = mod.ID + p.AutotriggerMod = mod.ID sess.WriteLine(fmt.Sprintf("You switch to triggering %s.", mod.Name)) return } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } var mobTarget string @@ -105,7 +103,7 @@ func (g *Game) triggerCombatMod(sess *net.Session, p *player.Player, mod *ModDef return } - p.AutocastMod = mod.ID + p.AutotriggerMod = mod.ID g.startCombat(sess, p, mob) } @@ -116,15 +114,13 @@ func (g *Game) triggerTransport(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) - 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)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("+%dxp sci", sciXP))) } @@ -180,7 +176,7 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -201,17 +197,10 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe p.Credits += creditValue - sciXP := int(mod.BaseXP) - var leveledUp []player.SkillName - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - leveledUp = append(leveledUp, player.Science) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) - for _, skill := range leveledUp { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) - } - sess.WriteLine(fmt.Sprintf("You process the %s. You receive %s credits.", itemDef.Name, g.colorize(sess, "credits_pickup", fmt.Sprint(creditValue)))) @@ -222,6 +211,8 @@ func (g *Game) triggerProcessing(sess *net.Session, p *player.Player, mod *ModDe func (g *Game) triggerUtility(sess *net.Session, p *player.Player, mod *ModDef, targetArg string) { switch mod.ID { + case "low_process", "high_process": + g.triggerProcessing(sess, p, mod, targetArg) case "bones_to_nutrients": g.triggerBonesToNutrients(sess, p, mod) case "em_grab": @@ -246,7 +237,7 @@ func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -258,10 +249,8 @@ func (g *Game) triggerBonesToNutrients(sess *net.Session, p *player.Player, mod } } - sciXP := int(mod.BaseXP) * boneCount - 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)))) - } + sciXP := mod.BaseXP * boneCount + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You convert %d bones into nutrient bars.", boneCount)) @@ -301,7 +290,7 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -318,10 +307,8 @@ func (g *Game) triggerEmGrab(sess *net.Session, p *player.Player, mod *ModDef, t freeSlot := p.FirstFreeSlot() p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: matchedID, Quantity: qty}) - 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)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You magnetically pull the %s toward you.", name)) @@ -377,7 +364,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -410,14 +397,10 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) } - 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)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) if recipe.XP > 0 { - if newLevel := p.AddSkillXP(player.SkillName(recipe.Skill), recipe.XP); newLevel > 0 { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(player.SkillName(recipe.Skill)), recipe.Skill))) - } + g.awardSkillXP(sess, p, player.SkillName(recipe.Skill), recipe.XP) } g.AccountStore.SaveCharacter(p) @@ -477,7 +460,7 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -492,13 +475,9 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } 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)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) + g.awardSkillXP(sess, p, player.Construction, 10) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You convert the log into %s.", info.name)) @@ -521,7 +500,7 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -540,14 +519,10 @@ func (g *Game) triggerPlankMake(sess *net.Session, p *player.Player, mod *ModDef 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)))) - } + sciXP := mod.BaseXP * qty + g.awardSkillXP(sess, p, player.Science, sciXP) 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.awardSkillXP(sess, p, player.Construction, conXP) g.AccountStore.SaveCharacter(p) sess.WriteLine(fmt.Sprintf("You convert %d logs into %s for %d credits.", processed, info.name, totalCost)) @@ -594,17 +569,15 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) inv.ItemID = outputID - 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)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) outputDef, _ := g.ItemStore.Load(outputID) @@ -637,7 +610,7 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef } if p.Action != nil { - g.CancelAction(p) + g.cancelAction(p) } g.consumeJunkCost(p, mod) @@ -657,10 +630,8 @@ func (g *Game) triggerChipBolts(sess *net.Session, p *player.Player, mod *ModDef p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: chip.Output, Quantity: chip.Qty}) } - 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)))) - } + sciXP := mod.BaseXP + g.awardSkillXP(sess, p, player.Science, sciXP) g.AccountStore.SaveCharacter(p) inputDef, _ := g.ItemStore.Load(chip.Input) @@ -714,27 +685,21 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob mob.StartRegen() } - sciXP := int(mod.BaseXP) - hpXP := int(mod.BaseXP * 0.33) + sciXP := mod.BaseXP + hpXP := mod.BaseXP / 3 + if hpXP < 1 { + hpXP = 1 + } var gains []xpGain - var leveledUp []player.SkillName - if newLevel := p.AddSkillXP(player.Science, sciXP); newLevel > 0 { - leveledUp = append(leveledUp, player.Science) - } + g.awardSkillXP(sess, p, player.Science, sciXP) gains = append(gains, xpGain{string(player.Science), sciXP}) - if newLevel := p.AddSkillXP(player.Hitpoints, hpXP); newLevel > 0 { - leveledUp = append(leveledUp, player.Hitpoints) - } + g.awardSkillXP(sess, p, player.Hitpoints, hpXP) gains = append(gains, xpGain{string(player.Hitpoints), hpXP}) g.AccountStore.SaveCharacter(p) - for _, skill := range leveledUp { - sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill))) - } - mobName := mobDisplayName(mob, true) prefix := fmt.Sprintf(" %s hits %s for %s damage.", g.colorize(sess, "science_mod", mod.Name), diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 95a7b5b..fbe5945 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -16,8 +16,8 @@ func (g *Game) doUse(sess *net.Session, input string) { return } - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) if g.tryRecharge(sess, p, input) { return @@ -50,11 +50,144 @@ func (g *Game) doUse(sess *net.Session, input string) { } if len(strings.Fields(input)) >= 2 { + if g.useRoomObject(sess, p, input) { + return + } g.doUseItemOnTarget(sess, p, strings.Fields(input)[0], strings.Join(strings.Fields(input)[1:], " ")) return } - g.StartAction(sess, "use", input) + if g.useRoomObject(sess, p, input) { + return + } + + g.startAction(sess, "use", input) +} + +func (g *Game) useRoomObject(sess *net.Session, p *player.Player, input string) bool { + objInstances := g.World.FindObjInstances(p.RoomID, input) + if len(objInstances) == 0 { + return false + } + objSt := &objInstances[0] + def, _ := g.ObjectStore.Load(objSt.DefID) + if def == nil { + return false + } + recipes := g.stationRecipes(p, objSt.DefID) + if len(recipes) > 0 { + g.showStationRecipeMenu(sess, p, recipes, def.Name) + return true + } + bt := def.BehaviorType() + if bt != "" { + g.startAction(sess, bt, def.Name) + return true + } + for _, ui := range def.UseInteractions { + if ui.Item != "" { + continue + } + if ui.Condition != nil && !g.checkCondition(sess, ui.Condition) { + continue + } + if ui.Message != "" { + sess.WriteLine(ui.Message) + } + if ui.Action != nil { + g.applyNodeAction(sess, ui.Action) + } + p.ActionState = &ActionState{Type: ActionUsing, 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 uses the %s.", p.Name, def.Name))) + } + } + } + return true + } + if len(def.UseInteractions) > 0 { + sess.WriteLine(fmt.Sprintf("Use what on the %s?", def.Name)) + return true + } + sess.WriteLine(fmt.Sprintf("You can't use the %s.", def.Name)) + return true +} + +func (g *Game) stationRecipes(p *player.Player, stationDefID string) []action.RecipeDef { + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + return nil + } + var results []action.RecipeDef + for _, r := range allRecipes { + if len(r.Station) == 0 { + continue + } + stationMatch := false + for _, sid := range r.Station { + if sid == stationDefID { + stationMatch = true + break + } + } + if !stationMatch { + continue + } + if !r.HasAllItemsQty(p.CountItem) { + continue + } + skillName := player.SkillName(r.EffectiveSkill()) + if p.Level(skillName) < r.Level { + continue + } + if r.Tool != "" && !g.hasToolType(p, r.Tool) { + continue + } + if stationDefID == "anvil" && !g.hasToolType(p, "hammer") { + continue + } + results = append(results, r) + } + return results +} + +func (g *Game) showStationRecipeMenu(sess *net.Session, p *player.Player, recipes []action.RecipeDef, stationName string) { + if len(recipes) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have anything you can make at the %s.", stationName)) + return + } + if len(recipes) == 1 { + g.promptHowMany(sess, recipes[0].ID) + return + } + needTypes := false + for i := range recipes { + for j := i + 1; j < len(recipes); j++ { + if recipes[i].Type != recipes[j].Type { + needTypes = true + break + } + } + if needTypes { + break + } + } + menu := recipeMenuData(recipeDefIDs(recipes)) + sess.PendingMenu = menu + sess.State = net.StateRecipeChoice + var names []string + for _, r := range recipes { + name := g.recipeName(sess, &r) + if needTypes { + typeLabel := strings.ToUpper(r.Type[:1]) + r.Type[1:] + names = append(names, fmt.Sprintf("%s (%s)", name, typeLabel)) + } else { + names = append(names, name) + } + } + g.showMenuTable(sess, fmt.Sprintf("What would you like to make at the %s?", stationName), names) } func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, itemBName string) { @@ -79,7 +212,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, objSt := &objInstances[0] def, _ := g.ObjectStore.Load(objSt.DefID) if def == nil { - g.StartAction(sess, "use", itemBName) + g.startAction(sess, "use", itemBName) return } @@ -109,7 +242,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, g.promptHowMany(sess, filtered[0].ID) return } - sess.PendingMenu = recipeMenuData(filtered) + sess.PendingMenu = recipeMenuData(recipeDefIDs(filtered)) sess.State = net.StateRecipeChoice var names []string for _, r := range filtered { @@ -131,7 +264,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, g.promptHowMany(sess, recipes[0].ID) return } - sess.PendingMenu = recipeMenuData(recipes) + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) sess.State = net.StateRecipeChoice var names []string for _, r := range recipes { @@ -157,7 +290,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, return } - g.StartAction(sess, "use", itemBName) + g.startAction(sess, "use", itemBName) return } @@ -213,12 +346,12 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, if len(craftRecipes) > 0 { var available []action.RecipeDef for _, r := range craftRecipes { - if g.canDoCraftRecipe(p, &r) { + if g.canDoGenericRecipe(p, &r, player.Crafting) { available = append(available, r) } } if len(available) == 1 { - g.startCraftRecipe(sess, p, &available[0], 0) + g.startGenericRecipe(sess, p, &available[0], 0, "last_craft") return } if len(available) > 1 { diff --git a/internal/game/cmd_walk.go b/internal/game/cmd_walk.go index 2104782..eca608f 100644 --- a/internal/game/cmd_walk.go +++ b/internal/game/cmd_walk.go @@ -11,7 +11,7 @@ import ( ) func (g *Game) doWalk(sess *net.Session, args []string) { - p := sess.Player.(*player.Player) + p := sess.Player if len(args) == 0 { sess.WriteLine("Walk where?") return @@ -87,7 +87,7 @@ func (g *Game) startWalk(sess *net.Session, p *player.Player, dirs []string) { return } - g.CancelAction(p) + g.cancelAction(p) p.WalkSequence = dirs } @@ -104,7 +104,7 @@ func (g *Game) advanceWalk(sess *net.Session, p *player.Player) { p.WalkSequence = p.WalkSequence[1:] oldRoom := p.RoomID - g.doMove(sess, dir) + g.doMove(sess, dir, 2.0) if p.MoveTicks == 0 && p.RoomID == oldRoom { p.WalkSequence = nil diff --git a/internal/game/cmd_wear.go b/internal/game/cmd_wear.go index 692a3e4..36765e8 100644 --- a/internal/game/cmd_wear.go +++ b/internal/game/cmd_wear.go @@ -26,7 +26,7 @@ func (g *Game) checkRequirements(p *player.Player, def *object.ItemDef) string { } func (g *Game) doWear(sess *net.Session, input string) { - p := sess.Player.(*player.Player) + p := sess.Player lower := strings.ToLower(strings.TrimSpace(input)) if lower == "all" { @@ -39,7 +39,7 @@ func (g *Game) doWear(sess *net.Session, input string) { return } - g.CancelAction(p) + g.cancelAction(p) qty, itemName := parseQty(input) @@ -168,8 +168,8 @@ func (g *Game) unequipAmmo(p *player.Player) { } func (g *Game) doWearAll(sess *net.Session) { - p := sess.Player.(*player.Player) - g.CancelAction(p) + p := sess.Player + g.cancelAction(p) bestPerSlot := make(map[object.EquipSlot]struct { itemID string diff --git a/internal/game/color.go b/internal/game/color.go index c3b3d83..962728a 100644 --- a/internal/game/color.go +++ b/internal/game/color.go @@ -5,11 +5,10 @@ import ( "thehouseoficarus/internal/config" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" - "thehouseoficarus/internal/player" ) func (g *Game) colorMode(sess *net.Session) string { - if p, ok := sess.Player.(*player.Player); ok && p != nil { + if p := sess.Player; p != nil { return p.OptionString("color") } return "none" diff --git a/internal/game/construction.go b/internal/game/construction.go new file mode 100644 index 0000000..06713cd --- /dev/null +++ b/internal/game/construction.go @@ -0,0 +1,74 @@ +package game + +import ( + "fmt" + + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" +) + +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/death.go b/internal/game/death.go new file mode 100644 index 0000000..d2c1b73 --- /dev/null +++ b/internal/game/death.go @@ -0,0 +1,66 @@ +package game + +import ( + "sort" + + "thehouseoficarus/internal/player" +) + +func (g *Game) dropItemsOnDeath(p *player.Player) { + roomID := p.RoomID + + if p.Credits > 0 { + g.World.AddGroundItem(roomID, "credits", p.Credits) + p.Credits = 0 + } + + var items []deathDrop + + for slot, inv := range p.Inventory { + if inv == nil || inv.Quantity <= 0 { + continue + } + val := 0 + if def, err := g.ItemStore.Load(inv.ItemID); err == nil { + val = def.Value * inv.Quantity + } + items = append(items, deathDrop{ + itemID: inv.ItemID, + quantity: inv.Quantity, + totalVal: val, + invSlot: slot, + }) + } + + for eqSlot, itemID := range p.Equipment { + val := 0 + if def, err := g.ItemStore.Load(itemID); err == nil { + val = def.Value + } + items = append(items, deathDrop{ + itemID: itemID, + quantity: 1, + totalVal: val, + isEquip: true, + equipSlot: eqSlot, + }) + } + + if len(items) <= 3 { + return + } + + sort.Slice(items, func(i, j int) bool { + return items[i].totalVal > items[j].totalVal + }) + + for i := 3; i < len(items); i++ { + it := items[i] + if it.isEquip { + delete(p.Equipment, it.equipSlot) + } else { + p.SetInvSlot(it.invSlot, nil) + } + g.World.AddGroundItem(roomID, it.itemID, it.quantity) + } +} diff --git a/internal/game/equip_stats.go b/internal/game/equip_stats.go index 559671f..02b8348 100644 --- a/internal/game/equip_stats.go +++ b/internal/game/equip_stats.go @@ -29,3 +29,13 @@ func (g *Game) playerEquipBonuses(p *player.Player) object.ItemStats { } return totals } + +func (g *Game) playerWeaponSpeed(p *player.Player) float64 { + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.Speed > 0 { + return def.Speed + } + } + return 5.0 +} diff --git a/internal/game/game.go b/internal/game/game.go index 03300a4..b8eadce 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -11,6 +11,7 @@ import ( "thehouseoficarus/internal/combat" "thehouseoficarus/internal/config" "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/game/hacking" "thehouseoficarus/internal/net" "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" @@ -39,25 +40,23 @@ type Game struct { ItemStore *object.ItemStore AccountStore *player.AccountStore MobStore *world.MobStore - BehaviorStore *action.Store RecipeStore *action.RecipeStore CourseStore *CourseStore Hub *net.Hub Ticks *engine.Engine WorldFlags map[string]any ColorConfig *config.ColorsConfig - dataDir string + DataDir string restTimers map[string]uint64 charsMu sync.Mutex loggedInChars map[string]*net.Session - combatPadWidth int freeQueue map[string][]QueuedCommand activeQueue map[string]*QueuedCommand consumeQueue map[string]*QueuedCommand pendingDepletions []pendingDepletion guardWatchTimers map[string]int farmTickCounter int - hackingStates map[string]*HackingSession + hackingStates map[string]*hacking.Session } func New(dataDir string, colorConfig *config.ColorsConfig) *Game { @@ -67,13 +66,12 @@ func New(dataDir string, colorConfig *config.ColorsConfig) *Game { ItemStore: object.NewItemStore(dataDir), AccountStore: player.NewAccountStore(dataDir), 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, - dataDir: dataDir, + DataDir: dataDir, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), freeQueue: make(map[string][]QueuedCommand), @@ -81,16 +79,17 @@ 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), + hackingStates: make(map[string]*hacking.Session), } g.CourseStore.LoadAll() + g.LoadMods() return g } func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { - if p, ok := sess.Player.(*player.Player); ok { + if p := sess.Player; p != nil { p.DeactivateAllTechs() g.charsMu.Lock() delete(g.loggedInChars, p.Name) @@ -150,44 +149,6 @@ func (g *Game) HandleSession(sess *net.Session, input string) { } } -func classifyCommand(cmd string) CommandClass { - switch cmd { - case "say", "score", "sc", "inventory", "i", "inv", - "look", "l", "exits", "help", - "map", "option", "options", "alias", "unalias", - "description", "desc", "queued", "color", "colors", - "colortable", "prompt", "style", "stats", - "tech", "t", "sneak", - "autocast", "auto", "mods", "modlist", - "task", "inspect": - return ClassInstant - case "equip", "eq", "equipment", "wear", "wield", "remove", "unwear", "unwield": - return ClassFree - 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", "mix", - "id", "identify", - "steal", "thieve", - "trigger", "cast", - "plant", "harvest", "rake", "water", "cure", - "bank", "construct", "make": - return ClassActive - 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 -} - func (g *Game) writePrompt(sess *net.Session) { sess.Write("\r\n" + g.promptStr(sess)) } @@ -240,7 +201,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { return } - p, _ := sess.Player.(*player.Player) + p := sess.Player if p == nil { return } @@ -276,363 +237,60 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } } -func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawInput string) { - p, _ := sess.Player.(*player.Player) - - switch cmd { - case "get", "take", "grab", "pick": - if len(args) == 0 { - sess.WriteLine("Get what?") - } else if args[0] == "all" { - if len(args) == 1 { - g.doGetAll(sess) - } else { - g.doGetAllNamed(sess, strings.Join(args[1:], " ")) - } - } else { - g.doGet(sess, strings.Join(args, " ")) - } - case "drop": - if len(args) == 0 { - sess.WriteLine("Drop what?") - } else if args[0] == "all" { - if len(args) == 1 { - g.doDropAll(sess) - } else { - g.doDropAllNamed(sess, strings.Join(args[1:], " ")) - } - } else { - g.doDrop(sess, strings.Join(args, " ")) - } - case "attack", "kill": - if len(args) == 0 { - target := g.resolveDefaultMob(p.RoomID) - if target == "" { - sess.WriteLine("Attack what?") - break - } - g.doAttack(sess, target) - return - } else { - g.doAttack(sess, strings.Join(args, " ")) - return - } - case "style": - if len(args) == 0 { - g.doStyle(sess, "") - } else { - g.doStyle(sess, args[0]) - } - case "look", "l": - if len(args) == 0 { - g.doLook(sess) - } else { - g.doLookTarget(sess, strings.Join(args, " ")) - } - case "north", "n", "south", "s", "east", "e", "west", "w", "up", "u", "down", "d": - g.doMove(sess, cmd) - case "say": - if len(args) == 0 { - sess.WriteLine("Say what?") - } else { - msgStart := strings.Index(strings.ToLower(rawInput), "say ") + 4 - if msgStart >= 4 && msgStart < len(rawInput) { - g.doSay(sess, rawInput[msgStart:]) - } else { - g.doSay(sess, strings.Join(args, " ")) - } - } - case "sc", "score": - g.doScore(sess) - case "task": - g.doTask(sess) - case "stats": - g.doStats(sess) - case "tech", "t": - g.doTech(sess, strings.Join(args, " ")) - case "i", "inv", "inventory": - g.doInventory(sess) - case "quit": - g.doQuit(sess) - return - case "description", "desc": - g.doDescription(sess) - case "option", "options": - g.doOption(sess, strings.Join(args, " ")) - case "color", "colors": - g.doColor(sess, strings.Join(args, " ")) - case "colortable": - g.doColortable(sess) - case "prompt": - if len(args) == 0 { - g.doPrompt(sess, "") - } else { - msgStart := strings.Index(strings.ToLower(rawInput), "prompt ") + 7 - if msgStart >= 7 && msgStart < len(rawInput) { - g.doPrompt(sess, rawInput[msgStart:]) - } else { - g.doPrompt(sess, strings.Join(args, " ")) - } - } - case "exits": - g.doExits(sess) - case "map": - g.doMap(sess) - case "queued": - g.doQueued(sess) - case "help": - if len(args) == 0 { - g.doHelp(sess, "") - } else { - g.doHelp(sess, strings.Join(args, " ")) - } - case "mine", "chop", "fish", "cut", "pull", "push": - g.CancelAction(p) - if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, cmd) - if target == "" { - sess.WriteLine(fmt.Sprintf("%s what?", cmd)) - break - } - g.StartAction(sess, cmd, target) - return - } else { - g.StartAction(sess, cmd, strings.Join(args, " ")) - return - } - case "use": - if g.tryFinishingBlow(sess, strings.Join(args, " ")) { - return - } - g.doUse(sess, strings.Join(args, " ")) - return - case "cook": - g.doCook(sess, strings.Join(args, " ")) - return - case "smelt": - g.doSmelt(sess, strings.Join(args, " ")) - return - case "smith": - g.doSmith(sess, strings.Join(args, " ")) - return - case "craft": - g.doCraft(sess, strings.Join(args, " ")) - return - 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 - case "clean": - g.doClean(sess, strings.Join(args, " ")) - return - case "mix": - g.doMix(sess, strings.Join(args, " ")) - return - case "id", "identify": - g.doIdentify(sess, strings.Join(args, " ")) - return - case "autocast", "auto": - g.doAutocast(sess, strings.Join(args, " ")) - case "mods", "modlist": - g.doMods(sess) - case "sneak": - g.doSneak(sess) - case "trigger", "cast": - g.doTrigger(sess, strings.Join(args, " ")) - return - case "talk", "speak", "ask": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Talk to whom?") - } else { - g.StartAction(sess, "talk", strings.Join(args, " ")) - return - } - case "burn": - g.CancelAction(p) - g.doBurn(sess, p, strings.Join(args, " ")) - return - case "stoke": - g.CancelAction(p) - g.doStoke(sess, p, strings.Join(args, " ")) - return - case "alias": - g.doAlias(sess, args) - return - case "unalias": - g.doUnalias(sess, args) - return - case "search": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Search what?") - } else { - g.doSearch(sess, strings.Join(args, " ")) - } - return - case "steal", "thieve": - g.CancelAction(p) - if len(args) == 0 { - g.doSteal(sess, "") - } else { - g.doSteal(sess, strings.Join(args, " ")) - } - return - case "plant": - g.CancelAction(p) - if len(args) == 0 { - sess.WriteLine("Plant what?") - } else { - g.doPlant(sess, strings.Join(args, " ")) - } - return - case "harvest": - g.CancelAction(p) - if len(args) == 0 { - g.doHarvest(sess, "") - } else { - g.doHarvest(sess, strings.Join(args, " ")) - } - return - case "rake": - g.CancelAction(p) - if len(args) == 0 { - g.doRake(sess, "") - } else { - g.doRake(sess, strings.Join(args, " ")) - } - return - case "water": - g.CancelAction(p) - if len(args) == 0 { - g.doWater(sess, "") - } else { - g.doWater(sess, strings.Join(args, " ")) - } - return - case "cure": - g.CancelAction(p) - if len(args) == 0 { - g.doCure(sess, "") - } else { - g.doCure(sess, strings.Join(args, " ")) - } - return - case "inspect": - if len(args) == 0 { - g.doInspect(sess, "") - } else { - g.doInspect(sess, strings.Join(args, " ")) - } - 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 - case "remove", "unwear", "unwield": - 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 { - case "gather", "toggle": - if len(args) == 0 { - target := g.resolveDefaultTarget(p.RoomID, cmd) - if target == "" { - sess.WriteLine(fmt.Sprintf("%s what?", cmd)) - break - } - g.StartAction(sess, cmd, target) - return - } - g.StartAction(sess, cmd, strings.Join(args, " ")) - return - case "talk": - if len(args) == 0 { - sess.WriteLine("Talk to whom?") - } else { - g.StartAction(sess, "talk", strings.Join(args, " ")) - return - } - } - } else { - sess.WriteLine("Unknown command.") - } - } -} - func (g *Game) ProcessQueuedCommands() { if g.Hub == nil { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.ActionState == nil { - continue - } - as, ok := p.ActionState.(*ActionState) - if !ok { + p := sess.Player + if p == nil { continue } - switch as.Type { - case ActionGathering, ActionCombating, ActionUsing, ActionTalking, - ActionToggling, ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, - ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, - ActionTraversing: - default: - if as.Type != ActionMoving || p.MoveTicks <= 0 { + + if p.ActionState != nil { + as, ok := p.ActionState.(*ActionState) + if !ok { p.ActionState = nil + } else { + switch as.Type { + case ActionGathering, ActionCombating, ActionUsing, ActionTalking, + ActionBurning, ActionStoking, ActionResting, ActionWalking, ActionProducing, + ActionStealing, ActionPlanting, ActionHarvesting, ActionRaking, ActionWatering, ActionCuring, + ActionTraversing: + default: + if as.Type != ActionMoving || p.MoveTicks <= 0 { + p.ActionState = nil + } + } } } - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil { - continue - } if p.BackgroundAction == nil { p.BackgroundActionState = nil } - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil { - continue - } cmds := g.freeQueue[p.Name] for _, qc := range cmds { g.cancelRest(p.Name) - parts := strings.Fields(qc.Command + " " + qc.Args) + raw := qc.Command + if qc.Args != "" { + raw += " " + qc.Args + } + parts := strings.Fields(raw) if len(parts) > 0 { - g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) + g.executeCommand(qc.Session, parts[0], parts[1:], raw) } g.writePrompt(qc.Session) } delete(g.freeQueue, p.Name) + + if len(p.WalkSequence) > 0 { + g.advanceWalk(sess, p) + if len(p.WalkSequence) == 0 && p.MoveTicks == 0 { + g.writePrompt(sess) + } + } } var actives []QueuedCommand @@ -644,16 +302,20 @@ func (g *Game) ProcessQueuedCommands() { }) for _, qc := range actives { - parts := strings.Fields(qc.Command + " " + qc.Args) - p, ok := qc.Session.Player.(*player.Player) - if !ok || p == nil { + raw := qc.Command + if qc.Args != "" { + raw += " " + qc.Args + } + parts := strings.Fields(raw) + p := qc.Session.Player + if p == nil { continue } if p.MoveTicks > 0 { p.ClearMoveState() } if len(parts) > 0 { - g.executeCommand(qc.Session, parts[0], parts[1:], qc.Command+" "+qc.Args) + g.executeCommand(qc.Session, parts[0], parts[1:], raw) } isBusy := p.Action != nil || len(p.WalkSequence) > 0 || combat.GetCombat(p.Name) != nil || p.MoveTicks > 0 _, isResting := g.restTimers[p.Name] @@ -663,48 +325,12 @@ func (g *Game) ProcessQueuedCommands() { } g.activeQueue = make(map[string]*QueuedCommand) - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.WalkSequence) == 0 { - continue - } - g.advanceWalk(sess, p) - if len(p.WalkSequence) == 0 && p.MoveTicks == 0 { - g.writePrompt(sess) - } - } - g.flushPendingDepletions() } type pendingDepletion struct { instanceKey string - behaviorID string + objDefID string targetName string playerNames []string } - -func (g *Game) MoveTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.MoveTicks <= 0 { - continue - } - p.MoveTicks-- - if p.MoveTicks > 0 { - if combat.GetCombat(p.Name) != nil && p.OptionBool("run_countdown") { - if p.MoveTicks > 1 { - sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) - } else { - sess.WriteLine("Running next tick!") - } - } - } else { - g.completeMove(sess, p) - g.writePrompt(sess) - } - } -} diff --git a/internal/game/hacking.go b/internal/game/hacking.go index dfefd5c..3457728 100644 --- a/internal/game/hacking.go +++ b/internal/game/hacking.go @@ -4,67 +4,14 @@ import ( "fmt" "strings" + "thehouseoficarus/internal/game/hacking" "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 { + p := sess.Player + if p == nil { sess.State = net.StateGame return } @@ -105,36 +52,33 @@ func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, w return } - tDef := terminalDefs[hs.TerminalID] + tDef := hacking.Terminals[hs.TerminalID] var xp int if completed && won { - xp = hackingXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) - sess.WriteLine(fmt.Sprintf("\nConnection terminated. Contract complete.")) + xp = hacking.CalcXP(tDef.BaseXPWin, hs.Level, hs.ReqLevel) + sess.WriteLine("\nConnection terminated. Contract complete.") } else if completed { - xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) - sess.WriteLine(fmt.Sprintf("\nConnection lost.")) + xp = hacking.CalcXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + sess.WriteLine("\nConnection lost.") } else if hs.Started { - xp = hackingXP(tDef.BaseXPLose, hs.Level, hs.ReqLevel) + xp = hacking.CalcXP(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 { + if b, ok := hs.Minigame.(hacking.XPBonuser); ok { bonus := b.BonusXP() if bonus > 0 { - xp += hackingXP(bonus, hs.Level, hs.ReqLevel) + xp += hacking.CalcXP(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))) - } + g.awardSkillXP(sess, p, player.Hacking, xp) if p.OptionBool("xp_drops") { sess.WriteLine(g.colorize(sess, "xp", fmt.Sprintf("(+%dxp %s)", xp, player.SkillAbbr[player.Hacking]))) @@ -147,11 +91,3 @@ func (g *Game) endHacking(sess *net.Session, p *player.Player, completed bool, w 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/hacking.go b/internal/game/hacking/hacking.go new file mode 100644 index 0000000..f022ee1 --- /dev/null +++ b/internal/game/hacking/hacking.go @@ -0,0 +1,63 @@ +package hacking + +type Minigame interface { + Init(level int) string + HandleInput(input string) (output string, done bool, won bool) + Name() string +} + +type XPBonuser interface { + BonusXP() int +} + +type Session struct { + Minigame Minigame + TerminalID string + Level int + ReqLevel int + Started bool +} + +type TerminalDef struct { + ObjectID string + Level int + Minigame func() Minigame + BaseXPWin int + BaseXPLose int + Name string +} + +var Terminals = map[string]TerminalDef{ + "terminal_basic": { + ObjectID: "terminal_basic", + Level: 1, + Minigame: func() Minigame { return NewWumpusGame() }, + BaseXPWin: 50, + BaseXPLose: 10, + Name: "Hunt the Wumpus", + }, + "terminal_advanced": { + ObjectID: "terminal_advanced", + Level: 20, + Minigame: func() Minigame { return NewMastermindGame() }, + BaseXPWin: 150, + BaseXPLose: 30, + Name: "Code Breaker", + }, + "terminal_secure": { + ObjectID: "terminal_secure", + Level: 40, + Minigame: func() Minigame { return NewLiarsDiceGame() }, + BaseXPWin: 300, + BaseXPLose: 50, + Name: "Signal Bluff", + }, +} + +func CalcXP(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/hacking_liars_dice.go b/internal/game/hacking/hacking_liars_dice.go new file mode 100644 index 0000000..bcfbd40 --- /dev/null +++ b/internal/game/hacking/hacking_liars_dice.go @@ -0,0 +1,395 @@ +package hacking + +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/hacking_mastermind.go b/internal/game/hacking/hacking_mastermind.go new file mode 100644 index 0000000..8d4eb1c --- /dev/null +++ b/internal/game/hacking/hacking_mastermind.go @@ -0,0 +1,177 @@ +package hacking + +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/hacking_wumpus.go b/internal/game/hacking/hacking_wumpus.go new file mode 100644 index 0000000..91b9243 --- /dev/null +++ b/internal/game/hacking/hacking_wumpus.go @@ -0,0 +1,286 @@ +package hacking + +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/hacking_liars_dice.go b/internal/game/hacking_liars_dice.go deleted file mode 100644 index 12f76c1..0000000 --- a/internal/game/hacking_liars_dice.go +++ /dev/null @@ -1,395 +0,0 @@ -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 deleted file mode 100644 index 3ab7cca..0000000 --- a/internal/game/hacking_mastermind.go +++ /dev/null @@ -1,177 +0,0 @@ -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 deleted file mode 100644 index 600b1be..0000000 --- a/internal/game/hacking_wumpus.go +++ /dev/null @@ -1,286 +0,0 @@ -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/help.go b/internal/game/help.go index f9d822c..03e2b8c 100644 --- a/internal/game/help.go +++ b/internal/game/help.go @@ -7,7 +7,6 @@ import ( "strings" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" "gopkg.in/yaml.v3" ) @@ -26,36 +25,54 @@ type cmdEntry struct { var commandList = []cmdEntry{ {"alias", "Instant", "Create command shortcuts"}, {"attack / kill", "Active", "Attack a mob"}, + {"autotrigger / auto", "Instant", "Set a module to auto-trigger"}, + {"bank", "Active", "Open bank interface"}, {"burn", "Active", "Start a fire"}, {"chop / cut", "Active", "Chop trees (Woodcutting)"}, + {"clean", "Free", "Clean grimy herbs (Pharmacy)"}, {"color / colors", "Instant", "Customize display colors"}, {"colortable", "Instant", "Display color reference chart"}, + {"construct / make", "Active", "Build furniture (Construction)"}, {"cook", "Active", "Cook raw food on a fire or range"}, + {"craft", "Active", "Craft jewelry (Crafting)"}, {"description / desc", "Instant", "Set your character description"}, + {"drink", "Free", "Drink a potion"}, {"drop", "Active", "Drop items to the ground"}, {"eat", "Free", "Eat food to restore hitpoints"}, {"equipment / eq", "Instant", "Show equipped items"}, {"exits", "Instant", "List available exits"}, + {"farm", "Active", "Farm crops (Farming)"}, {"fish", "Active", "Fish at fishing spots (Fishing)"}, + {"fletch", "Free", "Fletch logs into bows (Fletching)"}, {"get / take / pick", "Active", "Pick up items from the ground"}, {"help", "Instant", "Show help topics"}, + {"id / identify", "Active", "Identify herbs (Pharmacy)"}, {"inventory / i / inv", "Instant", "Show your inventory"}, + {"jack / jackin", "Active", "Jack into a terminal (Hacking)"}, {"look / l", "Instant", "Look around or examine things"}, {"map", "Instant", "Display an ASCII map of the area"}, {"mine", "Active", "Mine rocks (Mining)"}, + {"mix", "Active", "Mix potions (Pharmacy)"}, + {"mods / modlist", "Instant", "List available science modules"}, {"north / south / east / west / up / down", "Active", "Move in a direction"}, {"option / options", "Instant", "View or change settings"}, {"prompt", "Instant", "Set custom command prompt"}, - {"pull / push", "Active", "Toggle levers and switches"}, + {"pull / push", "Active", "Interact with objects"}, {"queued", "Instant", "Show pending tick actions"}, {"quit", "Active", "Rest and disconnect"}, {"remove / unwear / unwield", "Free", "Unequip items"}, {"say", "Instant", "Chat with players in your room"}, {"score / sc", "Instant", "View your stats and skills"}, {"search", "Active", "Search items for loot"}, + {"smelt", "Active", "Smelt ore into bars (Smithing)"}, + {"smith", "Active", "Smith bars into items (Smithing)"}, + {"sneak", "Instant", "Toggle sneak mode"}, + {"steal", "Active", "Steal from mobs (Thieving)"}, {"stoke", "Active", "Add logs to a fire"}, {"style", "Instant", "Change combat style"}, {"talk / speak / ask", "Active", "Talk to NPCs"}, + {"tech", "Instant", "Toggle technology abilities"}, + {"trigger", "Active", "Trigger a science module"}, {"unalias", "Instant", "Remove command shortcuts"}, {"use", "Active", "Use an object (crafting)"}, {"walk", "Active", "Pathfind to a room or multi-step walk"}, @@ -90,7 +107,7 @@ func LoadHelp(dataDir string) ([]HelpDef, error) { func (g *Game) doHelp(sess *net.Session, topic string) { if topic == "" { unicode := true - if p, _ := sess.Player.(*player.Player); p != nil { + if p := sess.Player; p != nil { unicode = p.OptionBool("unicode") } @@ -114,7 +131,7 @@ func (g *Game) doHelp(sess *net.Session, topic string) { return } - helps, err := LoadHelp(g.dataDir) + helps, err := LoadHelp(g.DataDir) if err != nil || len(helps) == 0 { sess.WriteLine("No help available.") return diff --git a/internal/game/login_account.go b/internal/game/login_account.go index 176a1ee..87f8c62 100644 --- a/internal/game/login_account.go +++ b/internal/game/login_account.go @@ -17,7 +17,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) { } actualName, err := g.AccountStore.FindAccount(name) if err == nil { - sess.Account = &net.AccountEntry{Name: actualName} + sess.Account = &player.Account{Name: actualName} sess.State = net.StatePassword sess.Conn.SetEcho(false) sess.Write("Password: ") @@ -27,7 +27,7 @@ func (g *Game) handleAccountName(sess *net.Session, input string) { sess.Write("Account name: ") return } - sess.Account = &net.AccountEntry{Name: name} + sess.Account = &player.Account{Name: name} sess.State = net.StateNewAccountPass sess.Conn.SetEcho(false) sess.WriteLine("\r\nOh, a new visitor to this rock!") @@ -63,7 +63,7 @@ func (g *Game) handlePassword(sess *net.Session, input string) { return } sess.Conn.SetEcho(true) - sess.Account = &net.AccountEntry{ + sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, Characters: acc.Characters, @@ -144,7 +144,7 @@ func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { } sess.Conn.SetEcho(true) - sess.Account = &net.AccountEntry{ + sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, Aliases: make(map[string]string), diff --git a/internal/game/messages.go b/internal/game/messages.go new file mode 100644 index 0000000..1c9e85a --- /dev/null +++ b/internal/game/messages.go @@ -0,0 +1,13 @@ +package game + +const ( + MsgErrLoadRecipes = "Error loading recipes." + MsgNoCombat = "You can't do that during combat!" + MsgNoDontHave = "You don't have any '%s'." + MsgNeverMind = "Never mind." + MsgSomethingWrongObject = "Something is wrong with this object." + MsgSomethingWrongThat = "Something is wrong with that." + MsgInventoryFull = "Your inventory is too full!" + MsgAmbiguous = "That's ambiguous, which one?" + MsgWhichOne = "Which one?" +) diff --git a/internal/game/player_flags.go b/internal/game/player_flags.go new file mode 100644 index 0000000..6595cc4 --- /dev/null +++ b/internal/game/player_flags.go @@ -0,0 +1,55 @@ +package game + +import "thehouseoficarus/internal/player" + +func getPlayerFlagInt(p *player.Player, key string) int { + if p.Flags == nil { + return 0 + } + val, ok := p.Flags[key] + if !ok { + return 0 + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} + +func getPlayerFlagString(p *player.Player, key string) string { + if p.Flags == nil { + return "" + } + val, ok := p.Flags[key] + if !ok { + return "" + } + s, _ := val.(string) + return s +} + +func setPlayerFlag(p *player.Player, key string, val any) { + p.EnsureFlags() + p.Flags[key] = val +} + +func intFromFlag(flags map[string]any, key string) int { + val, ok := flags[key] + if !ok { + return 0 + } + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + } + return 0 +} diff --git a/internal/game/production_core.go b/internal/game/production_core.go new file mode 100644 index 0000000..42f64e1 --- /dev/null +++ b/internal/game/production_core.go @@ -0,0 +1,606 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/color" + "thehouseoficarus/internal/engine" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" + "thehouseoficarus/internal/player" + +) + +func (g *Game) startProduction(sess *net.Session, p *player.Player, recipe *action.RecipeDef, actionType, displayVerb, startMsg, endMsg string, count int) { + g.cancelAction(p) + g.cancelBackgroundAction(p) + + skill := recipe.EffectiveSkill() + if skill != "" { + skillLevel := p.Level(player.SkillName(skill)) + if skillLevel < recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", recipe.Level, skill)) + g.reprompt(sess) + return + } + } + + if !recipe.HasAllItemsQty(p.CountItem) { + sess.WriteLine("You don't have the required materials.") + g.reprompt(sess) + return + } + + outDef, _ := g.ItemStore.Load(recipe.Output) + + wait := recipe.Wait + if wait <= 0 { + wait = 4 + } + + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + + p.Action = &action.Action{ + Type: actionType, + TargetID: recipe.ID, + TargetName: outputName, + Data: map[string]any{ + "recipe_id": recipe.ID, + "phase": 0, + "wait": wait, + "start_msg": startMsg, + "end_msg": endMsg, + "remaining": count, + }, + WaitLeft: engine.ToTicks(1), + } + + 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) { + _, stationName := g.findStation(p.RoomID, recipe.Station) + if stationName == "" { + stationName = "inventory" + } + + firstItem := recipe.FirstItemName(func(id string) (string, bool) { + def, err := g.ItemStore.Load(id) + if err != nil { + return id, false + } + return def.Name, true + }) + + info, ok := productionTypes[recipe.Type] + if !ok { + info = productionTypeInfo{recipe.Type, recipe.Type} + } + actionType := info.ActionType + displayVerb := info.DisplayVerb + + var startMsg, endMsg string + if stationName != "inventory" { + startMsg = fmt.Sprintf("You start %s %s on the %s.", displayVerb, firstItem, stationName) + } else { + startMsg = fmt.Sprintf("You start %s %s.", displayVerb, firstItem) + } + endMsg = fmt.Sprintf("You've finished %s.", displayVerb) + + g.startProduction(sess, p, recipe, actionType, displayVerb, startMsg, endMsg, count) +} + +func (g *Game) loadProductionRecipe(recipeID string) *action.RecipeDef { + all, err := g.RecipeStore.LoadAll() + if err == nil { + for _, r := range all { + if r.ID == recipeID { + return &r + } + } + } + if strings.HasPrefix(recipeID, "combine_") { + itemID := strings.TrimPrefix(recipeID, "combine_") + if itemDef, err := g.ItemStore.Load(itemID); err == nil && len(itemDef.MadeFrom) > 0 { + return g.buildCombineRecipe(itemDef) + } + } + return nil +} + +func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool { + recipeID := p.Action.Data["recipe_id"].(string) + phase := p.Action.Data["phase"].(int) + wait := p.Action.Data["wait"].(float64) + startMsg, _ := p.Action.Data["start_msg"].(string) + endMsg, _ := p.Action.Data["end_msg"].(string) + remaining, _ := p.Action.Data["remaining"].(int) + + recipe := g.loadProductionRecipe(recipeID) + if recipe == nil { + g.cancelAction(p) + return false + } + + if phase == 0 { + if startMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", startMsg)) + } + p.Action.Data["phase"] = 1 + p.Action.WaitLeft = engine.ToTicks(wait) + return true + } + + skill := recipe.EffectiveSkill() + skillLevel := p.Level(player.SkillName(skill)) + chance := 1.0 + if recipe.Success != nil { + chance = action.SuccessChance(*recipe.Success, skillLevel, recipe.Level) + } + + if rand.Float64() < chance { + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + + byproducts := g.collectByproducts(p, recipe) + + placed := false + outDef, _ := g.ItemStore.Load(recipe.Output) + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == recipe.Output { + recipe.ConsumeAll(p.HasItem, p.RemoveItem) + slot.Quantity += outputQty + placed = true + break + } + } + } + if !placed { + recipe.ConsumeAll(p.HasItem, p.RemoveItem) + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is too full!") + g.cancelAction(p) + return false + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Output, Quantity: outputQty}) + } + + for _, bp := range byproducts { + if slot := p.FirstFreeSlot(); slot >= 0 { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: bp, Quantity: 1}) + } + } + + if recipe.XP > 0 { + if newLevel := p.AddSkillXP(player.SkillName(skill), recipe.XP); newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) + } + } + g.AccountStore.SaveCharacter(p) + + msg := recipe.Message + if msg == "" { + outputName := recipe.Output + if outDef != nil { + outputName = outDef.Name + } + msg = fmt.Sprintf("You produce %s.", outputName) + } + if p.OptionBool("xp_drops") && recipe.XP > 0 { + abbr := player.SkillAbbr[player.SkillName(skill)] + msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", recipe.XP, abbr)) + } + sess.WriteLine(msg) + } else { + recipe.ConsumeAll(p.HasItem, p.RemoveItem) + + if recipe.Fail != "" { + freeSlot := p.FirstFreeSlot() + if freeSlot >= 0 { + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: recipe.Fail, Quantity: 1}) + } + } + g.AccountStore.SaveCharacter(p) + + msg := recipe.FailMessage + if msg == "" { + msg = "You fail and the materials are lost." + } + sess.WriteLine(g.colorize(sess, "damage", msg)) + } + + if remaining > 0 { + remaining-- + p.Action.Data["remaining"] = remaining + if remaining <= 0 { + if endMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) + } + g.cancelAction(p) + return false + } + } + + if g.canContinueProduction(p, recipe) { + p.Action.WaitLeft = engine.ToTicks(wait) + return true + } + + if endMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", endMsg)) + } + g.cancelAction(p) + return false +} + +func (g *Game) collectByproducts(p *player.Player, recipe *action.RecipeDef) []string { + var byproducts []string + for _, e := range recipe.Consume { + if len(e.Byproducts) == 0 { + continue + } + for i, id := range e.Items { + if p.HasItem(id) && i < len(e.Byproducts) && e.Byproducts[i] != "" { + byproducts = append(byproducts, e.Byproducts[i]) + break + } + } + } + return byproducts +} + +func (g *Game) canContinueProduction(p *player.Player, recipe *action.RecipeDef) bool { + if !recipe.HasAllItemsQty(p.CountItem) { + return false + } + outputQty := recipe.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + outDef, _ := g.ItemStore.Load(recipe.Output) + if outDef != nil && outDef.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == recipe.Output { + return true + } + } + } + return p.FirstFreeSlot() >= 0 +} + +func (g *Game) handleRecipeChoice(sess *net.Session, input string) { + input = strings.TrimSpace(input) + + menuData := sess.PendingMenu + if len(menuData) == 0 { + sess.State = net.StateGame + g.reprompt(sess) + return + } + + if input == "" { + sess.PendingMenu = nil + sess.State = net.StateGame + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + if choice, err := strconv.Atoi(input); err == nil { + sess.PendingMenu = nil + sess.State = net.StateGame + if choice <= 0 || choice > len(menuData) { + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + g.dispatchMenuEntry(sess, menuData[choice-1]) + return + } + + lower := strings.ToLower(input) + matchIdx := -1 + for i, entry := range menuData { + name := g.menuEntryName(entry) + if name == "" { + continue + } + if strings.ToLower(name) == lower || action.WordPrefixMatch(input, name) { + if matchIdx >= 0 { + sess.WriteLine("That's ambiguous.") + return + } + matchIdx = i + } + } + if matchIdx >= 0 { + sess.PendingMenu = nil + sess.State = net.StateGame + g.dispatchMenuEntry(sess, menuData[matchIdx]) + return + } + + sess.PendingMenu = nil + sess.State = net.StateGame + sess.WriteLine("Never mind.") + g.reprompt(sess) +} + +func (g *Game) menuEntryName(entry map[string]string) string { + if rid, ok := entry["recipe_id"]; ok { + all, _ := g.RecipeStore.LoadAll() + for _, r := range all { + if r.ID == rid { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return def.Name + } + return r.Output + } + } + } + if rid, ok := entry["fletch_recipe_id"]; ok { + all, _ := g.RecipeStore.LoadAll() + for _, r := range all { + if r.ID == rid { + if def, err := g.ItemStore.Load(r.Output); err == nil { + return def.Name + } + return r.Output + } + } + } + if barID, ok := entry["bar_id"]; ok { + if def, _ := g.ItemStore.Load(barID); def != nil { + return def.Name + } + return barID + } + if cid, ok := entry["combine_item"]; ok { + if def, _ := g.ItemStore.Load(cid); def != nil { + return def.Name + } + return cid + } + return "" +} + +func (g *Game) dispatchMenuEntry(sess *net.Session, entry map[string]string) { + if cid, ok := entry["combine_item"]; ok { + g.promptHowMany(sess, "combine_"+cid) + return + } + if barID, ok := entry["bar_id"]; ok { + p := sess.Player + allRecipes, _ := g.RecipeStore.LoadAll() + g.showSmithTable(sess, p, barID, allRecipes) + return + } + if rid, ok := entry["fletch_recipe_id"]; ok { + p := sess.Player + allRecipes, _ := g.RecipeStore.LoadAll() + for _, r := range allRecipes { + if r.ID == rid { + g.startFletchAction(sess, p, &r, 0) + return + } + } + g.reprompt(sess) + return + } + if rid, ok := entry["recipe_id"]; ok { + g.promptHowMany(sess, rid) + return + } + g.reprompt(sess) +} + +func (g *Game) formatRecipeMaterials(r *action.RecipeDef) string { + var parts []string + for _, e := range r.Consume { + itemName := e.Items[0] + if def, err := g.ItemStore.Load(e.Items[0]); err == nil { + itemName = def.Name + } + if e.Qty > 1 { + parts = append(parts, fmt.Sprintf("%s x%d", itemName, e.Qty)) + } else { + parts = append(parts, itemName) + } + } + return strings.Join(parts, ", ") +} + +func (g *Game) showProductionTable(sess *net.Session, p *player.Player, recipes []action.RecipeDef, title, skill, lastFlagKey, promptVerb string, background bool) { + sort.Slice(recipes, func(i, j int) bool { + return recipes[i].Level < recipes[j].Level + }) + + mode := g.colorMode(sess) + skillLevel := p.Level(player.SkillName(skill)) + dimSpec := color.Parse("240") + + tbl := &Table{ + Title: title, + Columns: []string{"#", "Product", "Materials", "Level"}, + } + + for i, r := range recipes { + outDef, _ := g.ItemStore.Load(r.Output) + productName := r.Output + if outDef != nil { + productName = outDef.Name + } + + outputQty := r.OutputQty + if outputQty <= 0 { + outputQty = 1 + } + if outDef != nil && outDef.Stackable && outputQty > 1 { + productName = fmt.Sprintf("%s x%d", productName, outputQty) + } + + matStr := g.formatRecipeMaterials(&r) + levelStr := fmt.Sprint(r.Level) + numStr := fmt.Sprintf("%d", i+1) + + canMake := skillLevel >= r.Level && r.HasAllItemsQty(p.CountItem) + if canMake { + productName = g.itemColorize(sess, outDef, productName) + } else { + productName = color.Render(mode, dimSpec, productName) + matStr = color.Render(mode, dimSpec, matStr) + levelStr = color.Render(mode, dimSpec, levelStr) + numStr = color.Render(mode, dimSpec, numStr) + } + + tbl.Rows = append(tbl.Rows, []string{numStr, productName, matStr, levelStr}) + } + + unicode := p.OptionBool("unicode") + sess.WriteLine("") + for _, line := range tbl.Render(unicode) { + sess.WriteLine(line) + } + + lastRecipeID, _ := p.Flags[lastFlagKey].(string) + hint := "" + if lastRecipeID != "" { + for _, r := range recipes { + if r.ID == lastRecipeID { + if outDef, err := g.ItemStore.Load(r.Output); err == nil { + hint = outDef.Name + } + break + } + } + } + + if hint != "" { + sess.Write(fmt.Sprintf("%s what (enter for all %s): ", promptVerb, hint)) + } else { + sess.Write(fmt.Sprintf("%s what: ", promptVerb)) + } + + sess.PendingMenu = recipeMenuData(recipeDefIDs(recipes)) + sess.PendingSkill = skill + sess.PendingLastFlag = lastFlagKey + sess.PendingBackground = background + sess.State = net.StateProductChoice +} + +func (g *Game) handleProductChoice(sess *net.Session, input string) { + p := sess.Player + menuData := sess.PendingMenu + skill := sess.PendingSkill + lastFlagKey := sess.PendingLastFlag + background := sess.PendingBackground + sess.PendingMenu = nil + sess.PendingSkill = "" + sess.PendingLastFlag = "" + sess.PendingBackground = false + sess.State = net.StateGame + + input = strings.TrimSpace(input) + + allRecipes, err := g.RecipeStore.LoadAll() + if err != nil { + g.reprompt(sess) + return + } + + var recipes []action.RecipeDef + for _, entry := range menuData { + rid := entry["recipe_id"] + for _, r := range allRecipes { + if r.ID == rid { + recipes = append(recipes, r) + break + } + } + } + + if len(recipes) == 0 { + g.reprompt(sess) + return + } + + sort.Slice(recipes, func(i, j int) bool { + return recipes[i].Level < recipes[j].Level + }) + + lastRecipeID, _ := p.Flags[lastFlagKey].(string) + + sel, errMsg := g.resolveRecipeByInput(input, recipes, lastRecipeID) + switch errMsg { + case "ambiguous": + sess.WriteLine("That's ambiguous.") + g.reprompt(sess) + return + case "never_mind": + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + skillLevel := p.Level(player.SkillName(skill)) + if skillLevel < sel.Recipe.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to make that.", sel.Recipe.Level, skill)) + g.reprompt(sess) + return + } + if !sel.Recipe.HasAllItemsQty(p.CountItem) { + sess.WriteLine("You don't have the materials for that.") + g.reprompt(sess) + return + } + + p.EnsureFlags() + p.Flags[lastFlagKey] = sel.Recipe.ID + g.AccountStore.SaveCharacter(p) + + if background { + g.startFletchAction(sess, p, sel.Recipe, sel.Count) + } else { + g.startProductionFromRecipe(sess, p, sel.Recipe, sel.Count) + } +} + +func (g *Game) hasToolType(p *player.Player, toolType string) bool { + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + if def, err := g.ItemStore.Load(itemID); err == nil && def.ToolType == toolType { + return true + } + } + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + if def, err := g.ItemStore.Load(slot.ItemID); err == nil && def.ToolType == toolType { + return true + } + } + return false +} diff --git a/internal/game/production_menu.go b/internal/game/production_menu.go new file mode 100644 index 0000000..329110b --- /dev/null +++ b/internal/game/production_menu.go @@ -0,0 +1,209 @@ +package game + +import ( + "fmt" + "strconv" + "strings" + + "thehouseoficarus/internal/action" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/object" +) + +type recipeEntry struct { + ItemName string + Recipe action.RecipeDef +} + +type productionTypeInfo struct { + ActionType string + DisplayVerb string +} + +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"}, + "construction": {"construct", "constructing"}, +} + +var productionActionTypes map[string]bool + +func init() { + productionActionTypes = make(map[string]bool) + for _, pt := range productionTypes { + productionActionTypes[pt.ActionType] = true + } +} + +func recipeMenuData(recipeIDs []string) []map[string]string { + out := make([]map[string]string, len(recipeIDs)) + for i, id := range recipeIDs { + out[i] = map[string]string{"recipe_id": id} + } + return out +} + +func recipeDefIDs(defs []action.RecipeDef) []string { + ids := make([]string, len(defs)) + for i, d := range defs { + ids[i] = d.ID + } + return ids +} + +func entryMenuData(entries []recipeEntry) []map[string]string { + out := make([]map[string]string, len(entries)) + for i, e := range entries { + out[i] = map[string]string{"recipe_id": e.Recipe.ID} + } + return out +} + +func (g *Game) promptHowMany(sess *net.Session, recipeID string) { + sess.PendingRecipeID = recipeID + sess.State = net.StateHowMany + sess.Write("How many (return for all)?: ") +} + +type recipeSelection struct { + Recipe *action.RecipeDef + Count int +} + +func (g *Game) resolveRecipeByInput(input string, recipes []action.RecipeDef, lastRecipeID string) (sel recipeSelection, errMsg string) { + if input == "" { + if lastRecipeID == "" { + return sel, "never_mind" + } + for i := range recipes { + if recipes[i].ID == lastRecipeID { + sel.Recipe = &recipes[i] + return sel, "" + } + } + return sel, "never_mind" + } + + qty, productName := parseQty(input) + productName = strings.TrimSpace(productName) + + if idx, err := strconv.Atoi(productName); err == nil && idx > 0 && idx <= len(recipes) { + sel.Recipe = &recipes[idx-1] + sel.Count = qty + return sel, "" + } + + matchCount := 0 + for i := range recipes { + outDef, _ := g.ItemStore.Load(recipes[i].Output) + name := recipes[i].Output + if outDef != nil { + name = outDef.Name + } + if action.WordPrefixMatch(productName, name) { + if matchCount == 0 { + sel.Recipe = &recipes[i] + sel.Count = qty + } + matchCount++ + } + } + if matchCount > 1 { + return sel, "ambiguous" + } + if sel.Recipe == nil { + return sel, "never_mind" + } + return sel, "" +} + +func (g *Game) showMenuTable(sess *net.Session, title string, names []string) { + p := sess.Player + tbl := &Table{Title: title} + for i, name := range names { + tbl.Rows = append(tbl.Rows, []string{fmt.Sprintf("%d", i+1), name}) + } + unicode := p.OptionBool("unicode") + sess.WriteLine("") + for _, line := range tbl.Render(unicode) { + sess.WriteLine(line) + } +} + +func (g *Game) handleHowMany(sess *net.Session, input string) { + p := sess.Player + recipeID := sess.PendingRecipeID + sess.PendingRecipeID = "" + sess.State = net.StateGame + + input = strings.TrimSpace(input) + + var count int + if input == "" { + count = 0 + } else if n, err := strconv.Atoi(input); err == nil && n > 0 { + count = n + } else { + sess.WriteLine("Never mind.") + g.reprompt(sess) + return + } + + var recipe *action.RecipeDef + if strings.HasPrefix(recipeID, "combine_") { + itemID := strings.TrimPrefix(recipeID, "combine_") + itemDef, err := g.ItemStore.Load(itemID) + if err != nil || len(itemDef.MadeFrom) == 0 { + g.reprompt(sess) + return + } + recipe = g.buildCombineRecipe(itemDef) + } else { + all, err := g.RecipeStore.LoadAll() + if err != nil { + g.reprompt(sess) + return + } + for _, r := range all { + if r.ID == recipeID { + recipe = &r + break + } + } + } + + if recipe == nil { + g.reprompt(sess) + return + } + + g.startProductionFromRecipe(sess, p, recipe, count) +} + +func (g *Game) buildCombineRecipe(def *object.ItemDef) *action.RecipeDef { + var consume []action.ConsumeEntry + for _, mf := range def.MadeFrom { + consume = append(consume, action.ConsumeEntry{ + Items: mf.Items, + Qty: mf.Qty, + Byproducts: mf.Byproducts, + }) + } + wait := def.Ticks + if wait <= 0 { + wait = 2 + } + return &action.RecipeDef{ + ID: "combine_" + def.ID, + Type: "combine", + Wait: wait, + Consume: consume, + Output: def.ID, + Message: fmt.Sprintf("You create %s.", def.Name), + } +} diff --git a/internal/game/prompt.go b/internal/game/prompt.go index c3768fe..2ecf367 100644 --- a/internal/game/prompt.go +++ b/internal/game/prompt.go @@ -12,7 +12,7 @@ import ( func (g *Game) promptStr(sess *net.Session) string { prompt := "> " - if p, ok := sess.Player.(*player.Player); ok && p != nil { + if p := sess.Player; p != nil { if p.Prompt != "" { prompt = p.Prompt } @@ -28,8 +28,8 @@ func (g *Game) expandPromptColors(sess *net.Session, text string) string { } func (g *Game) expandPromptVars(sess *net.Session, text string) string { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil { + p := sess.Player + if p == nil { return text } diff --git a/internal/game/science.go b/internal/game/science.go index bf9a961..8c7f0af 100644 --- a/internal/game/science.go +++ b/internal/game/science.go @@ -2,9 +2,13 @@ package game import ( "fmt" + "os" + "path/filepath" "sort" "strings" + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/object" "thehouseoficarus/internal/player" ) @@ -20,22 +24,51 @@ const ( ) type ModDef struct { - ID string - Name string - Level int - MaxHit int - BaseXP float64 - JunkCost map[string]int - Category ModCategory - Element string - TargetType string - Destination int + ID string `yaml:"id"` + Name string `yaml:"name"` + Level int `yaml:"level"` + MaxHit int `yaml:"max_hit"` + BaseXP int `yaml:"base_xp"` + JunkCost map[string]int `yaml:"junk_cost"` + Category ModCategory `yaml:"category"` + Element string `yaml:"element"` + Destination int `yaml:"destination"` } var AllMods []*ModDef var modByID map[string]*ModDef +func (g *Game) LoadMods() error { + dir := filepath.Join(g.DataDir, "modules") + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + AllMods = nil + modByID = make(map[string]*ModDef) + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + var m ModDef + if err := yaml.Unmarshal(data, &m); err != nil { + continue + } + AllMods = append(AllMods, &m) + modByID[m.ID] = AllMods[len(AllMods)-1] + } + modByID = make(map[string]*ModDef, len(AllMods)) + for _, m := range AllMods { + modByID[m.ID] = m + } + return nil +} + func GetMod(id string) *ModDef { return modByID[id] } @@ -162,6 +195,7 @@ func (g *Game) consumeJunkCost(p *player.Player, mod *ModDef) bool { func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { cost := g.effectiveJunkCost(p, mod) + delete(cost, "scrap_metal") if len(cost) == 0 { return "free" } @@ -172,6 +206,7 @@ func (g *Game) junkCostString(p *player.Player, mod *ModDef) string { if def != nil { name = def.Name } + name = strings.TrimSuffix(name, "junk") if qty > 1 { parts = append(parts, fmt.Sprintf("%d %s", qty, name)) } else { @@ -193,143 +228,4 @@ func (g *Game) totalEquipScienceAttack(p *player.Player) int { return total } -func init() { - AllMods = []*ModDef{ - {ID: "bio_strike", Name: "Bio Strike", Level: 1, MaxHit: 4, BaseXP: 5.5, - JunkCost: map[string]int{"biojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_bolt", Name: "Bio Bolt", Level: 17, MaxHit: 9, BaseXP: 13.5, - JunkCost: map[string]int{"biojunk": 2, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_blast", Name: "Bio Blast", Level: 41, MaxHit: 13, BaseXP: 25.5, - JunkCost: map[string]int{"biojunk": 3, "chaosjunk": 1, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_wave", Name: "Bio Wave", Level: 62, MaxHit: 17, BaseXP: 36.0, - JunkCost: map[string]int{"biojunk": 5, "deathjunk": 1, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "bio_surge", Name: "Bio Surge", Level: 81, MaxHit: 21, BaseXP: 44.0, - JunkCost: map[string]int{"biojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "bio", TargetType: "mob"}, - {ID: "hydro_strike", Name: "Hydro Strike", Level: 5, MaxHit: 6, BaseXP: 7.5, - JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_bolt", Name: "Hydro Bolt", Level: 23, MaxHit: 10, BaseXP: 16.5, - JunkCost: map[string]int{"hydrojunk": 3, "ecojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_blast", Name: "Hydro Blast", Level: 47, MaxHit: 14, BaseXP: 28.5, - JunkCost: map[string]int{"hydrojunk": 5, "ecojunk": 3, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_wave", Name: "Hydro Wave", Level: 65, MaxHit: 18, BaseXP: 37.5, - JunkCost: map[string]int{"hydrojunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - {ID: "hydro_surge", Name: "Hydro Surge", Level: 85, MaxHit: 22, BaseXP: 46.0, - JunkCost: map[string]int{"hydrojunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "hydro", TargetType: "mob"}, - - {ID: "eco_strike", Name: "Eco Strike", Level: 9, MaxHit: 7, BaseXP: 9.5, - JunkCost: map[string]int{"ecojunk": 2, "biojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_bolt", Name: "Eco Bolt", Level: 29, MaxHit: 11, BaseXP: 19.5, - JunkCost: map[string]int{"ecojunk": 3, "biojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_blast", Name: "Eco Blast", Level: 53, MaxHit: 15, BaseXP: 31.5, - JunkCost: map[string]int{"ecojunk": 4, "biojunk": 3, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_wave", Name: "Eco Wave", Level: 70, MaxHit: 19, BaseXP: 40.0, - JunkCost: map[string]int{"ecojunk": 7, "biojunk": 5, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - {ID: "eco_surge", Name: "Eco Surge", Level: 90, MaxHit: 23, BaseXP: 48.5, - JunkCost: map[string]int{"ecojunk": 10, "biojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "eco", TargetType: "mob"}, - - {ID: "solar_strike", Name: "Solar Strike", Level: 13, MaxHit: 8, BaseXP: 11.5, - JunkCost: map[string]int{"solarjunk": 3, "ecojunk": 2, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_bolt", Name: "Solar Bolt", Level: 35, MaxHit: 12, BaseXP: 22.5, - JunkCost: map[string]int{"solarjunk": 4, "ecojunk": 3, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_blast", Name: "Solar Blast", Level: 59, MaxHit: 16, BaseXP: 34.5, - JunkCost: map[string]int{"solarjunk": 5, "ecojunk": 4, "chaosjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_wave", Name: "Solar Wave", Level: 75, MaxHit: 20, BaseXP: 42.5, - JunkCost: map[string]int{"solarjunk": 7, "ecojunk": 5, "deathjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - {ID: "solar_surge", Name: "Solar Surge", Level: 95, MaxHit: 24, BaseXP: 51.0, - JunkCost: map[string]int{"solarjunk": 10, "ecojunk": 7, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModCombat, Element: "solar", TargetType: "mob"}, - - {ID: "low_process", Name: "Low Level Processing", Level: 21, MaxHit: 0, BaseXP: 31.0, - JunkCost: map[string]int{"naturejunk": 3, "solarjunk": 1, "scrap_metal": 1}, - Category: ModProcessing, Element: "", TargetType: "inventory"}, - {ID: "high_process", Name: "High Level Processing", Level: 55, MaxHit: 0, BaseXP: 65.0, - JunkCost: map[string]int{"naturejunk": 5, "solarjunk": 1, "scrap_metal": 1}, - Category: ModProcessing, Element: "", TargetType: "inventory"}, - - {ID: "bones_to_nutrients", Name: "Bones to Nutrients", Level: 15, MaxHit: 0, BaseXP: 25.0, - JunkCost: map[string]int{"naturejunk": 2, "ecojunk": 2, "scrap_metal": 1}, - Category: ModUtility, Element: "", TargetType: "self"}, - {ID: "em_grab", Name: "Electromagnetic Grab", Level: 33, MaxHit: 0, BaseXP: 43.0, - JunkCost: map[string]int{"lawjunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModUtility, Element: "", TargetType: "ground_item"}, - {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}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 1}, - {ID: "transport_forge", Name: "Transport: Forge", Level: 31, MaxHit: 0, BaseXP: 35.0, - JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 12}, - {ID: "transport_mine", Name: "Transport: Mining Pit", Level: 37, MaxHit: 0, BaseXP: 40.0, - JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "solarjunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 6}, - {ID: "transport_forest", Name: "Transport: Forest", Level: 45, MaxHit: 0, BaseXP: 48.0, - JunkCost: map[string]int{"lawjunk": 1, "ecojunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 22}, - {ID: "transport_scavenge", Name: "Transport: Scavenging Post", Level: 51, MaxHit: 0, BaseXP: 52.0, - JunkCost: map[string]int{"lawjunk": 1, "naturejunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 9}, - {ID: "transport_deep_mine", Name: "Transport: Deep Mine", Level: 61, MaxHit: 0, BaseXP: 60.0, - JunkCost: map[string]int{"lawjunk": 2, "ecojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 7}, - {ID: "transport_fishing", Name: "Transport: Fishing Dock", Level: 55, MaxHit: 0, BaseXP: 56.0, - JunkCost: map[string]int{"lawjunk": 1, "hydrojunk": 1, "biojunk": 1, "scrap_metal": 1}, - Category: ModTransport, Element: "", TargetType: "self", Destination: 10}, - - {ID: "enchant_1", Name: "Enchant Level 1", Level: 7, MaxHit: 0, BaseXP: 17.5, - JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "enchant_2", Name: "Enchant Level 2", Level: 27, MaxHit: 0, BaseXP: 37.0, - JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "enchant_3", Name: "Enchant Level 3", Level: 49, MaxHit: 0, BaseXP: 59.0, - JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "enchant_4", Name: "Enchant Level 4", Level: 57, MaxHit: 0, BaseXP: 67.0, - JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - - {ID: "chip_sapphire", Name: "Chip Sapphire Bolts", Level: 4, MaxHit: 0, BaseXP: 9.0, - JunkCost: map[string]int{"cosmicjunk": 1, "hydrojunk": 1, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "chip_emerald", Name: "Chip Emerald Bolts", Level: 27, MaxHit: 0, BaseXP: 37.0, - JunkCost: map[string]int{"cosmicjunk": 1, "biojunk": 3, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "chip_ruby", Name: "Chip Ruby Bolts", Level: 49, MaxHit: 0, BaseXP: 59.0, - JunkCost: map[string]int{"cosmicjunk": 1, "solarjunk": 5, "bloodjunk": 1, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - {ID: "chip_diamond", Name: "Chip Diamond Bolts", Level: 57, MaxHit: 0, BaseXP: 67.0, - JunkCost: map[string]int{"cosmicjunk": 1, "ecojunk": 10, "scrap_metal": 1}, - Category: ModEnchant, Element: "", TargetType: "inventory"}, - } - - modByID = make(map[string]*ModDef, len(AllMods)) - for _, m := range AllMods { - modByID[m.ID] = m - } -} diff --git a/internal/game/tick.go b/internal/game/tick.go index 4ab7e13..ec7eda9 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -17,8 +17,8 @@ func (g *Game) DisconnectTick() { if !sess.Disconnecting { continue } - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { g.Hub.Remove(sess) continue } @@ -38,8 +38,8 @@ func (g *Game) RegenTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { continue } if p.HP <= 0 || p.HP >= p.MaxHP() { @@ -65,7 +65,7 @@ func (g *Game) RegenTick() { p.HP = p.MaxHP() p.RegenerateTick = 0 } else { - p.RegenerateTick = 100 + p.RegenerateTick = player.RegenTickInterval } } } @@ -118,9 +118,9 @@ func (g *Game) WanderTick() { objMoves := g.World.FlushObjMoves() for _, om := range objMoves { for _, sess := range g.Hub.PlayersInRoom(om.FromRoom) { - if p, ok := sess.Player.(*player.Player); ok && p.Action != nil { + if p := sess.Player; p != nil && p.Action != nil { if p.Action.TargetID == om.DefID { - g.CancelAction(p) + g.cancelAction(p) g.writePrompt(sess) } } @@ -135,8 +135,8 @@ func (g *Game) WanderTick() { for _, m := range moves { for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok { + p := sess.Player + if p == nil { continue } if p.RoomID == m.fromRoom && p.OptionBool("mob_leave") { @@ -162,8 +162,8 @@ func (g *Game) WanderTick() { func (g *Game) SharedDepletionTick() { activeKeys := make(map[string]bool) for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || p.Action == nil || p.Action.Type != "gather" { + p := sess.Player + if p == nil || p.Action == nil || p.Action.Type != "gather" { continue } if key, ok := p.Action.Data["instance_key"].(string); ok { @@ -218,8 +218,8 @@ func (g *Game) TechTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || len(p.ActiveTechs) == 0 { + p := sess.Player + if p == nil || len(p.ActiveTechs) == 0 { continue } @@ -281,8 +281,8 @@ func (g *Game) ConsumeTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil { + p := sess.Player + if p == nil { continue } if p.ConsumeCooldown > 0 { @@ -296,8 +296,8 @@ func (g *Game) VisualTick() { return } for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok || p == nil || !p.OptionBool("visual_ticks") { + p := sess.Player + if p == nil || !p.OptionBool("visual_ticks") { continue } text := p.OptionString("visual_tick_text") @@ -318,3 +318,28 @@ func (g *Game) VisualTick() { } } } + +func (g *Game) MoveTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p := sess.Player + if p == nil || p.MoveTicks <= 0 { + continue + } + p.MoveTicks-- + if p.MoveTicks > 0 { + if combat.GetCombat(p.Name) != nil && p.OptionBool("run_countdown") { + if p.MoveTicks > 1 { + sess.WriteLine(fmt.Sprintf("Running in %d ticks...", p.MoveTicks)) + } else { + sess.WriteLine("Running next tick!") + } + } + } else { + g.completeMove(sess, p) + g.writePrompt(sess) + } + } +} diff --git a/internal/game/utils.go b/internal/game/utils.go index 6a294c5..9148ea3 100644 --- a/internal/game/utils.go +++ b/internal/game/utils.go @@ -112,3 +112,10 @@ func (g *Game) newInventorySlot(itemID string, qty int) *player.InventorySlot { } return slot } + +func (g *Game) awardSkillXP(sess *net.Session, p *player.Player, skill player.SkillName, xp int) { + newLevel := p.AddSkillXP(skill, xp) + if newLevel > 0 { + sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, skill))) + } +} diff --git a/internal/net/server.go b/internal/net/server.go index 7c7a18c..f82f82b 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -6,10 +6,10 @@ import ( "log" "net" "net/http" - "strings" - "thehouseoficarus/internal/color" + "thehouseoficarus/internal/action" "thehouseoficarus/internal/config" + "thehouseoficarus/internal/player" ) type SessionState int @@ -46,8 +46,8 @@ const ( type Session struct { Conn Conn State SessionState - Account *AccountEntry - Player interface{} + Account *player.Account + Player *player.Player PendingChar string PendingPass string PendingMenu []map[string]string @@ -57,17 +57,10 @@ type Session struct { PendingBackground bool Disconnecting bool DisconnectTicks int - Shop interface{} + Shop *action.ShopConfig } -type AccountEntry struct { - Name string - PasswordHash string - Characters []string - Aliases map[string]string - Colors map[string]string - Options map[string]any -} + type Server struct { config *config.Config @@ -281,53 +274,6 @@ func (s *Server) serveHTTPS() { } } -func artColor(r rune) string { - switch r { - case '@': - return color.FgCode("ansi", 4) - case '!', ':', '.': - return color.StyleCode("dim") + color.FgCode("ansi", 4) - case ',': - return color.FgCode("ansi", 5) - case '*': - return color.FgCode("ansi", 5) - case '+', '|', '-': - return color.FgCode("ansi", 3) - } - return "" -} - -func colorizeRunes(s string) string { - var sb strings.Builder - current := "" - for _, r := range s { - want := artColor(r) - if want != current { - if current != "" { - sb.WriteString(color.Reset) - } - if want != "" { - sb.WriteString(want) - } - current = want - } - sb.WriteRune(r) - } - if current != "" { - sb.WriteString(color.Reset) - } - return sb.String() -} - -func colorizeArt(art string) string { - welcomeText := "welcome to the house of icarus" - before, after, found := strings.Cut(art, welcomeText) - if found { - return colorizeRunes(before) + color.FgCode("ansi", 7) + welcomeText + color.Reset + colorizeRunes(after) - } - return colorizeRunes(art) -} - func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { defer func() { if r := recover(); r != nil { @@ -339,34 +285,7 @@ func (s *Server) handleSession(sess *Session, handler func(*Session, string)) { sess.Conn.Write([]byte("\033[2J\033[H")) - welcomeart := colorizeArt(` - | * - * + -+- - . , | -. , . , - * - @@@@@@@ @@@ @@@ @@@@@@@@ @@@ @@@ @@@@@@ @@@ @@@ @@@@@@ @@@@@@@@ + - @@! @@! @@@ @@! + @@! @@@ @@! @@@ @@! @@@ !@@ @@! - @!! @!@!@!@! @!!!:! @!@!@!@! @!@ !@! @!@ !@! !@@!! @!!!:! * - !!: !!: !!! !!: . !!: !!! !!: !!! !!: !!! !:! !!: - : : : : : :: ::: : : : : :. : :.:: : ::.: : : :: ::: . - . + - * welcome to the house of icarus , , - , . - @@@@@@ @@@@@@@@ @@@ @@@@@@@ @@@@@@ @@@@@@@ @@@ @@@ @@@@@@ - @@! @@@ @@! , @@! !@@ @@! @@@ @@! @@@ @@! @@@ !@@ - | @!@ !@! @!!!:! !!@ !@! @!@!@!@! @!@!!@! @!@ !@! !@@!! - -+- !!: !!! !!: !!: :!! !!: !!! !!: :!! !!: !!! !:! - | : :. : : . : :: :: : : : : : : : :.:: : ::.: : - , - . + , . - . , - * . , * - + -`) - - - sess.Write(welcomeart) + sess.Write(WelcomeBanner()) sess.Write("What's your account name? ") for { diff --git a/internal/net/welcome.go b/internal/net/welcome.go new file mode 100644 index 0000000..e17c82e --- /dev/null +++ b/internal/net/welcome.go @@ -0,0 +1,82 @@ +package net + +import ( + "strings" + + "thehouseoficarus/internal/color" +) + +func artColor(r rune) string { + switch r { + case '@': + return color.FgCode("ansi", 4) + case '!', ':', '.': + return color.StyleCode("dim") + color.FgCode("ansi", 4) + case ',': + return color.FgCode("ansi", 5) + case '*': + return color.FgCode("ansi", 5) + case '+', '|', '-': + return color.FgCode("ansi", 3) + } + return "" +} + +func colorizeRunes(s string) string { + var sb strings.Builder + current := "" + for _, r := range s { + want := artColor(r) + if want != current { + if current != "" { + sb.WriteString(color.Reset) + } + if want != "" { + sb.WriteString(want) + } + current = want + } + sb.WriteRune(r) + } + if current != "" { + sb.WriteString(color.Reset) + } + return sb.String() +} + +func colorizeArt(art string) string { + welcomeText := "welcome to the house of icarus" + before, after, found := strings.Cut(art, welcomeText) + if found { + return colorizeRunes(before) + color.FgCode("ansi", 7) + welcomeText + color.Reset + colorizeRunes(after) + } + return colorizeRunes(art) +} + +func WelcomeBanner() string { + return colorizeArt(` + | * + * + -+- + . , | +. , . , + * + @@@@@@@ @@@ @@@ @@@@@@@@ @@@ @@@ @@@@@@ @@@ @@@ @@@@@@ @@@@@@@@ + + @@! @@! @@@ @@! + @@! @@@ @@! @@@ @@! @@@ !@@ @@! + @!! @!@!@!@! @!!!:! @!@!@!@! @!@ !@! @!@ !@! !@@!! @!!!:! * + !!: !!: !!! !!: . !!: !!! !!: !!! !!: !!! :!; !!: + : : : : : :: ::: : : : : :. : :.:: : ::.: : : :: ::: . + . + + * welcome to the house of icarus , , + , . + @@@@@@ @@@@@@@@ @@@ @@@@@@@ @@@@@@ @@@@@@@ @@@ @@@ @@@@@@ + @@! @@@ @@! , @@! !@@ @@! @@@ @@! @@@ @@! @@@ !@@ + | @!@ !@! @!!!:! !!@ @!@ @!@!@!@! @!@!!@! @!@ !@! !@@!! + -+- !!: !!! !!: !!: :!! !!: !!! !!: :!! !!: !!! :!; + | : :. : : . : :: :: : : : : : : : :.:: : ::.: : + , + . + , . + . , + * . , * + + +`) +} diff --git a/internal/object/item.go b/internal/object/item.go index 4959a22..7c682a4 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -3,7 +3,7 @@ package object import ( "strings" - "thehouseoficarus/internal/world" + "thehouseoficarus/internal/action" ) type EquipSlot string @@ -105,5 +105,5 @@ func (d *ItemDef) MatchesName(input string) bool { if strings.ToLower(d.Name) == lower { return true } - return world.WordPrefixMatch(lower, d.Name) + return action.WordPrefixMatch(lower, d.Name) } diff --git a/internal/object/object.go b/internal/object/object.go index 8c4c752..4d8db69 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -10,18 +10,38 @@ type UseInteraction struct { } type ObjectDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Color string `yaml:"color"` - BehaviorID string `yaml:"behavior"` - Hidden bool `yaml:"hidden"` - InRoomDescription string `yaml:"inroom_description"` - RemovalItem string `yaml:"removal_item"` - Description string `yaml:"description"` - UseInteractions []UseInteraction `yaml:"use_interactions"` - StealTable string `yaml:"steal_table"` - StealLevel int `yaml:"steal_level"` - StealXP int `yaml:"steal_xp"` - StealSpeed float64 `yaml:"steal_speed"` - GuardMob string `yaml:"guard_mob"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Color string `yaml:"color"` + Hidden bool `yaml:"hidden"` + InRoomDescription string `yaml:"inroom_description"` + RemovalItem string `yaml:"removal_item"` + Description string `yaml:"description"` + UseInteractions []UseInteraction `yaml:"use_interactions"` + StealTable string `yaml:"steal_table"` + StealLevel int `yaml:"steal_level"` + StealXP int `yaml:"steal_xp"` + StealSpeed float64 `yaml:"steal_speed"` + GuardMob string `yaml:"guard_mob"` + + Gather *action.GatherConfig `yaml:"gather,omitempty"` + Talk *action.TalkConfig `yaml:"talk,omitempty"` + Use *action.UseConfig `yaml:"use,omitempty"` +} + +func (d *ObjectDef) IsGatherable() bool { return d.Gather != nil } +func (d *ObjectDef) IsTalkable() bool { return d.Talk != nil } +func (d *ObjectDef) IsUsable() bool { return d.Use != nil } +func (d *ObjectDef) IsInteractable() bool { return d.Gather != nil || d.Talk != nil || d.Use != nil } + +func (d *ObjectDef) BehaviorType() string { + switch { + case d.Gather != nil: + return "gather" + case d.Talk != nil: + return "talk" + case d.Use != nil: + return "use" + } + return "" } diff --git a/internal/player/player.go b/internal/player/player.go index 88cf7f8..3842348 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -49,15 +49,15 @@ var SkillAbbr = map[SkillName]string{ Ranged: "rng", Science: "sci", Technology: "tec", - Fishing: "fis", + Fishing: "fsh", Cooking: "cok", - Woodcutting: "wct", + Woodcutting: "wdc", Firemaking: "fmk", Mining: "min", Smithing: "smt", Crafting: "cft", Fletching: "flt", - Pharmacy: "pha", + Pharmacy: "phr", Thieving: "thv", Agility: "agl", Construction: "con", @@ -179,7 +179,7 @@ type Player struct { MoveDirection string `yaml:"-"` MoveTarget int `yaml:"-"` VisualTickCurrent int `yaml:"-"` - AutocastMod string `yaml:"-"` + AutotriggerMod string `yaml:"-"` Battery float64 `yaml:"battery"` ActiveTechs map[string]bool `yaml:"-"` QuickTech string `yaml:"quick_tech,omitempty"` @@ -262,6 +262,12 @@ func (p *Player) OptionInt(name string) int { return def.Default.(int) } +func (p *Player) EnsureFlags() { + if p.Flags == nil { + p.Flags = make(map[string]any) + } +} + func (p *Player) InvSlot(i int) *InventorySlot { if p.Inventory == nil { return nil @@ -353,9 +359,11 @@ func (p *Player) MaxHP() int { return p.Level(Hitpoints) } +const RegenTickInterval = 100 + func (p *Player) StartRegen() { if p.RegenerateTick == 0 { - p.RegenerateTick = 100 + p.RegenerateTick = RegenTickInterval } } diff --git a/internal/world/ground.go b/internal/world/ground.go new file mode 100644 index 0000000..6c3ecf3 --- /dev/null +++ b/internal/world/ground.go @@ -0,0 +1,189 @@ +package world + +import ( + "strings" +) + +const DropDespawnTicks = 1000 +const ReserveTicks = 100 + +type GroundItemInfo struct { + ItemID string + Quantity int + ReservedFor string + ReserveTimer int + DespawnTimer int + IsSpawn bool +} + +type groundEntry struct { + itemID string + quantity int + isSpawn bool + respawnTimer int // >0 = counting down to respawn + respawnQty int + respawnDelay int + despawnTimer int // >0 = counting down to despawn (dropped items) + reservedFor string + reserveTimer int // >0 = counting down reservation +} + +func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo { + w.mu.Lock() + defer w.mu.Unlock() + var out []GroundItemInfo + for _, e := range w.groundItems[roomID] { + if e.quantity <= 0 { + continue + } + info := GroundItemInfo{ + ItemID: e.itemID, + Quantity: e.quantity, + DespawnTimer: e.despawnTimer, + IsSpawn: e.isSpawn, + } + if e.reserveTimer > 0 && e.reservedFor != "" { + info.ReservedFor = e.reservedFor + info.ReserveTimer = e.reserveTimer + } + out = append(out, info) + } + return out +} + +func (w *World) GroundItems(roomID int) map[string]int { + w.mu.Lock() + defer w.mu.Unlock() + out := make(map[string]int) + for _, e := range w.groundItems[roomID] { + if e.quantity > 0 { + out[e.itemID] += e.quantity + } + } + return out +} + +func (w *World) AddReservedGroundItem(roomID int, itemID string, qty int, owner string) { + w.mu.Lock() + defer w.mu.Unlock() + e := &groundEntry{ + itemID: itemID, + quantity: qty, + despawnTimer: DropDespawnTicks, + reservedFor: owner, + reserveTimer: ReserveTicks, + } + w.groundItems[roomID] = append(w.groundItems[roomID], e) +} + +func (w *World) AddGroundItem(roomID int, itemID string, qty int) { + w.mu.Lock() + defer w.mu.Unlock() + e := &groundEntry{ + itemID: itemID, + quantity: qty, + despawnTimer: DropDespawnTicks, + } + w.groundItems[roomID] = append(w.groundItems[roomID], e) +} + +func (w *World) RemoveReservedGroundItem(roomID int, itemID string, qty int, owner string) (int, bool) { + w.mu.Lock() + defer w.mu.Unlock() + removed := 0 + remaining := qty + for _, e := range w.groundItems[roomID] { + if remaining <= 0 { + break + } + if !strings.EqualFold(e.itemID, itemID) { + continue + } + if e.quantity <= 0 { + continue + } + if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { + return removed, false + } + take := e.quantity + if take > remaining { + take = remaining + } + e.quantity -= take + removed += take + remaining -= take + if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { + e.respawnTimer = e.respawnDelay + } + } + return removed, true +} + +func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { + w.mu.Lock() + defer w.mu.Unlock() + + removed := 0 + remaining := qty + + for _, e := range w.groundItems[roomID] { + if remaining <= 0 { + break + } + if !strings.EqualFold(e.itemID, itemID) { + continue + } + if e.quantity <= 0 { + continue + } + take := e.quantity + if take > remaining { + take = remaining + } + e.quantity -= take + removed += take + remaining -= take + + if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { + e.respawnTimer = e.respawnDelay + } + } + return removed +} + +func (w *World) tickGroundItemsLocked() { + for _, entries := range w.groundItems { + for _, e := range entries { + if e.respawnTimer > 0 { + e.respawnTimer-- + if e.respawnTimer <= 0 { + e.quantity = e.respawnQty + w.mergeNearbyItemsLocked(entries, e) + } + } + if e.despawnTimer > 0 { + e.despawnTimer-- + if e.despawnTimer <= 0 { + e.quantity = 0 + } + } + if e.reserveTimer > 0 { + e.reserveTimer-- + if e.reserveTimer <= 0 { + e.reservedFor = "" + } + } + } + } +} + +func (w *World) mergeNearbyItemsLocked(entries []*groundEntry, target *groundEntry) { + for _, other := range entries { + if other != target && other.quantity > 0 && strings.EqualFold(other.itemID, target.itemID) && + (other.reserveTimer <= 0 || other.reservedFor == "") && + other.despawnTimer <= 0 { + target.quantity += other.quantity + other.quantity = 0 + } + } +} diff --git a/internal/world/mob.go b/internal/world/mob.go index ce9eddd..f7988ce 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -12,7 +12,7 @@ import ( "gopkg.in/yaml.v3" ) -type DropTable struct { +type MobDropTable struct { Remains string `yaml:"remains"` Loot []action.DropEntry `yaml:"loot"` } @@ -21,7 +21,6 @@ type MobDef struct { ID string `yaml:"id"` Name string `yaml:"name"` Description string `yaml:"description"` - BehaviorID string `yaml:"behavior"` IdleDescriptions []string `yaml:"idle_descriptions"` CombatDescriptions []string `yaml:"combat_descriptions"` Attack int `yaml:"attack"` @@ -35,7 +34,7 @@ type MobDef struct { Protected bool `yaml:"protected"` Unique bool `yaml:"unique"` RespawnTicks float64 `yaml:"respawn_ticks"` - Drops DropTable `yaml:"drops"` + Drops MobDropTable `yaml:"drops"` AttackBonus int `yaml:"attack_bonus"` StrengthBonus int `yaml:"strength_bonus"` @@ -56,13 +55,16 @@ type MobDef struct { AssassinLevel int `yaml:"assassin_level"` FinishingBlow string `yaml:"finishing_blow"` DamageWithout string `yaml:"damage_without"` + + Talk *action.TalkConfig `yaml:"talk,omitempty"` } +func (d *MobDef) IsTalkable() bool { return d.Talk != nil } + type MobInstance struct { InstanceID string DefID string Name string - BehaviorID string HP int MaxHP int Attack int @@ -77,7 +79,7 @@ type MobInstance struct { RespawnTicks float64 RoomID int HomeRoomID int - Drops DropTable + Drops MobDropTable IdleDescription string WanderRooms []int WanderInterval float64 @@ -103,41 +105,24 @@ type MobInstance struct { AssassinLevel int FinishingBlow string DamageWithout string + + TalkConfig *action.TalkConfig } +func (m *MobInstance) IsTalkable() bool { return m.TalkConfig != nil } + const ( MatchNone = 0 MatchPrefix = 1 MatchExact = 2 ) -func WordPrefixMatch(input, name string) bool { - inputWords := strings.Fields(strings.ToLower(input)) - if len(inputWords) == 0 { - return false - } - nameWords := strings.Fields(strings.ToLower(name)) - for _, iw := range inputWords { - found := false - for _, nw := range nameWords { - if strings.HasPrefix(nw, iw) || strings.HasPrefix(iw, nw) { - found = true - break - } - } - if !found { - return false - } - } - return true -} - func (m *MobInstance) MatchQuality(input string) int { lower := strings.ToLower(input) if strings.ToLower(m.Name) == lower { return MatchExact } - if WordPrefixMatch(input, m.Name) { + if action.WordPrefixMatch(input, m.Name) { return MatchPrefix } return MatchNone @@ -281,7 +266,6 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { InstanceID: instID, DefID: defID, Name: dw.def.Name, - BehaviorID: dw.def.BehaviorID, HP: dw.def.HP, MaxHP: dw.def.HP, Attack: dw.def.Attack, @@ -315,6 +299,7 @@ func (s *MobStore) SeedMobs(roomID int, entries []RoomMob) { AssassinLevel: dw.def.AssassinLevel, FinishingBlow: dw.def.FinishingBlow, DamageWithout: dw.def.DamageWithout, + TalkConfig: dw.def.Talk, } inst.IdleDescription = pickIdleDescription(dw.def.IdleDescriptions) s.instances[instID] = inst diff --git a/internal/world/objects.go b/internal/world/objects.go new file mode 100644 index 0000000..568ea40 --- /dev/null +++ b/internal/world/objects.go @@ -0,0 +1,332 @@ +package world + +import ( + "fmt" + "math/rand" + "sort" + "strings" + + "thehouseoficarus/internal/action" +) + +type ObjState struct { + Depleted bool + DepleteTimer float64 + DefID string + Name string + Index int + RoomID int + JustRespawned bool + WanderRooms []int + WanderInterval float64 + WanderCounter int + SharedMax float64 + SharedTimer int + Quality float64 +} + +type ObjMove struct { + DefID string + Name string + FromRoom int + ToRoom int +} + +func (w *World) ObjStateKey(roomID int, defID string, index int) string { + return fmt.Sprintf("%d:%s:%d", roomID, defID, index) +} + +func (w *World) EnsureObjectStates(roomID int, defIDs []string) { + w.mu.Lock() + defer w.mu.Unlock() + w.ensureObjectStatesLocked(roomID, defIDs) +} + +func (w *World) ensureObjectStatesLocked(roomID int, defIDs []string) { + if w.objStates == nil { + w.objStates = make(map[string]*ObjState) + } + counts := make(map[string]int) + for _, defID := range defIDs { + idx := counts[defID] + counts[defID]++ + key := w.ObjStateKey(roomID, defID, idx) + if _, exists := w.objStates[key]; !exists { + w.objStates[key] = &ObjState{ + DefID: defID, + Name: defID, + Index: idx, + RoomID: roomID, + } + } + } +} + +func (w *World) AddObjInstance(roomID int, defID string, quality float64) { + w.mu.Lock() + defer w.mu.Unlock() + if w.objStates == nil { + w.objStates = make(map[string]*ObjState) + } + idx := 0 + for { + key := w.ObjStateKey(roomID, defID, idx) + if _, exists := w.objStates[key]; !exists { + w.objStates[key] = &ObjState{ + DefID: defID, + Name: defID, + Index: idx, + RoomID: roomID, + Quality: quality, + } + return + } + idx++ + } +} + +func (w *World) RemoveObjInstance(roomID int, defID string) { + w.mu.Lock() + defer w.mu.Unlock() + idx := 0 + for { + key := w.ObjStateKey(roomID, defID, idx) + if _, exists := w.objStates[key]; !exists { + return + } + delete(w.objStates, key) + idx++ + } +} + +func (w *World) AllFireStates() []*ObjState { + w.mu.Lock() + defer w.mu.Unlock() + var out []*ObjState + for _, st := range w.objStates { + if st.DefID == "fire" && st.Quality > 0 { + out = append(out, st) + } + } + return out +} + +func (w *World) GetObjState(roomID int, defID string, index int) *ObjState { + w.mu.Lock() + defer w.mu.Unlock() + if w.objStates == nil { + return nil + } + return w.objStates[w.ObjStateKey(roomID, defID, index)] +} + +func (w *World) GetObjStateByKey(key string) *ObjState { + w.mu.Lock() + defer w.mu.Unlock() + if w.objStates == nil { + return nil + } + return w.objStates[key] +} + +func (w *World) AllObjInstances(roomID int) []ObjState { + w.mu.Lock() + defer w.mu.Unlock() + var out []ObjState + for _, st := range w.objStates { + if st.RoomID == roomID { + out = append(out, *st) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].DefID != out[j].DefID { + return out[i].DefID < out[j].DefID + } + return out[i].Index < out[j].Index + }) + return out +} + +func (w *World) FindObjInstances(roomID int, name string) []ObjState { + w.mu.Lock() + defer w.mu.Unlock() + var out []ObjState + lower := strings.ToLower(name) + for _, st := range w.objStates { + if st.RoomID != roomID { + continue + } + if !wordMatchesObj(lower, st.DefID, st.Name) { + continue + } + out = append(out, ObjState{ + DefID: st.DefID, + Name: st.Name, + Index: st.Index, + RoomID: st.RoomID, + Depleted: st.Depleted, + DepleteTimer: st.DepleteTimer, + SharedMax: st.SharedMax, + SharedTimer: st.SharedTimer, + Quality: st.Quality, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].DefID != out[j].DefID { + return out[i].DefID < out[j].DefID + } + return out[i].Index < out[j].Index + }) + return out +} + +func wordMatchesObj(lower, defID, objName string) bool { + if action.WordPrefixMatch(lower, objName) { + return true + } + inputWords := strings.Fields(strings.ToLower(lower)) + + if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) { + return true + } + for _, part := range strings.Split(defID, "_") { + for _, iw := range inputWords { + pl := strings.ToLower(part) + if strings.HasPrefix(pl, iw) || strings.HasPrefix(iw, pl) { + return true + } + } + } + return false +} + +func (w *World) SetObjName(roomID int, defID string, name string) { + w.mu.Lock() + defer w.mu.Unlock() + for _, st := range w.objStates { + if st.RoomID == roomID && st.DefID == defID { + st.Name = name + } + } +} + +func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) { + w.mu.Lock() + defer w.mu.Unlock() + for _, st := range w.objStates { + if st.RoomID == roomID && st.DefID == defID { + st.WanderRooms = rooms + st.WanderInterval = interval + } + } +} + +func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) { + w.mu.Lock() + defer w.mu.Unlock() + for _, st := range w.objStates { + if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 { + st.SharedMax = max + st.SharedTimer = int(max) + } + } +} + +func (w *World) AllSharedObjStates() []*ObjState { + w.mu.Lock() + defer w.mu.Unlock() + var out []*ObjState + for _, st := range w.objStates { + if st.SharedMax > 0 { + out = append(out, st) + } + } + return out +} + +func (w *World) SetObjDepleted(roomID int, defID string, index int, delay float64) { + w.mu.Lock() + defer w.mu.Unlock() + key := w.ObjStateKey(roomID, defID, index) + if st, ok := w.objStates[key]; ok { + st.Depleted = true + st.DepleteTimer = delay + } +} + +func (w *World) FlushObjRespawns() []ObjState { + w.mu.Lock() + defer w.mu.Unlock() + var out []ObjState + for _, st := range w.objStates { + if st.JustRespawned { + out = append(out, *st) + st.JustRespawned = false + } + } + return out +} + +func (w *World) TickObjWander() { + w.mu.Lock() + defer w.mu.Unlock() + var moves []struct { + st *ObjState + toRoom int + } + for _, st := range w.objStates { + if len(st.WanderRooms) == 0 || st.WanderInterval <= 0 { + continue + } + st.WanderCounter++ + if float64(st.WanderCounter) < st.WanderInterval { + continue + } + st.WanderCounter = 0 + toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))] + if toRoom == st.RoomID { + continue + } + moves = append(moves, struct { + st *ObjState + toRoom int + }{st, toRoom}) + } + for _, m := range moves { + oldKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index) + fromRoom := m.st.RoomID + m.st.RoomID = m.toRoom + newKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index) + delete(w.objStates, oldKey) + w.objStates[newKey] = m.st + w.objMoves = append(w.objMoves, ObjMove{ + DefID: m.st.DefID, + Name: m.st.Name, + FromRoom: fromRoom, + ToRoom: m.toRoom, + }) + } +} + +func (w *World) FlushObjMoves() []ObjMove { + w.mu.Lock() + defer w.mu.Unlock() + out := w.objMoves + w.objMoves = nil + return out +} + +func (w *World) tickObjStatesLocked() { + for _, st := range w.objStates { + if st.Depleted && st.DepleteTimer > 0 { + st.DepleteTimer-- + if st.DepleteTimer <= 0 { + st.Depleted = false + st.JustRespawned = true + if st.SharedMax > 0 { + st.SharedTimer = int(st.SharedMax) + } + } + } + } +} diff --git a/internal/world/world.go b/internal/world/world.go index f918293..3094c54 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -2,40 +2,14 @@ package world import ( "fmt" - "math/rand" "os" "path/filepath" - "sort" "strings" "sync" "gopkg.in/yaml.v3" ) -const DropDespawnTicks = 1000 -const ReserveTicks = 100 - -type GroundItemInfo struct { - ItemID string - Quantity int - ReservedFor string - ReserveTimer int - DespawnTimer int - IsSpawn bool -} - -type groundEntry struct { - itemID string - quantity int - isSpawn bool - respawnTimer int // >0 = counting down to respawn - respawnQty int - respawnDelay int - despawnTimer int // >0 = counting down to despawn (dropped items) - reservedFor string - reserveTimer int // >0 = counting down reservation -} - type World struct { dataDir string mu sync.Mutex @@ -45,255 +19,6 @@ type World struct { objMoves []ObjMove } -type ObjMove struct { - DefID string - Name string - FromRoom int - ToRoom int -} - -type ObjState struct { - Depleted bool - DepleteTimer float64 - DefID string - Name string - Index int - RoomID int - JustRespawned bool - WanderRooms []int - WanderInterval float64 - WanderCounter int - SharedMax float64 - SharedTimer int - Quality float64 -} - -func (w *World) ObjStateKey(roomID int, defID string, index int) string { - return fmt.Sprintf("%d:%s:%d", roomID, defID, index) -} - -func (w *World) objStateKey(roomID int, defID string, index int) string { - return w.ObjStateKey(roomID, defID, index) -} - -func (w *World) EnsureObjectStates(roomID int, defIDs []string) { - w.mu.Lock() - defer w.mu.Unlock() - w.ensureObjectStatesLocked(roomID, defIDs) -} - -func (w *World) ensureObjectStatesLocked(roomID int, defIDs []string) { - if w.objStates == nil { - w.objStates = make(map[string]*ObjState) - } - counts := make(map[string]int) - for _, defID := range defIDs { - idx := counts[defID] - counts[defID]++ - key := w.objStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - w.objStates[key] = &ObjState{ - DefID: defID, - Name: defID, - Index: idx, - RoomID: roomID, - } - } - } -} - -func (w *World) AddObjInstance(roomID int, defID string, quality float64) { - w.mu.Lock() - defer w.mu.Unlock() - if w.objStates == nil { - w.objStates = make(map[string]*ObjState) - } - idx := 0 - for { - key := w.objStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - w.objStates[key] = &ObjState{ - DefID: defID, - Name: defID, - Index: idx, - RoomID: roomID, - Quality: quality, - } - return - } - idx++ - } -} - -func (w *World) RemoveObjInstance(roomID int, defID string) { - w.mu.Lock() - defer w.mu.Unlock() - idx := 0 - for { - key := w.objStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - return - } - delete(w.objStates, key) - idx++ - } -} - -func (w *World) AllFireStates() []*ObjState { - w.mu.Lock() - defer w.mu.Unlock() - var out []*ObjState - for _, st := range w.objStates { - if st.DefID == "fire" && st.Quality > 0 { - out = append(out, st) - } - } - return out -} - -func (w *World) GetObjState(roomID int, defID string, index int) *ObjState { - w.mu.Lock() - defer w.mu.Unlock() - if w.objStates == nil { - return nil - } - return w.objStates[w.objStateKey(roomID, defID, index)] -} - -func (w *World) GetObjStateByKey(key string) *ObjState { - w.mu.Lock() - defer w.mu.Unlock() - if w.objStates == nil { - return nil - } - return w.objStates[key] -} - -func (w *World) AllObjInstances(roomID int) []ObjState { - w.mu.Lock() - defer w.mu.Unlock() - var out []ObjState - for _, st := range w.objStates { - if st.RoomID == roomID { - out = append(out, *st) - } - } - sort.Slice(out, func(i, j int) bool { - if out[i].DefID != out[j].DefID { - return out[i].DefID < out[j].DefID - } - return out[i].Index < out[j].Index - }) - return out -} - -func (w *World) FindObjInstances(roomID int, name string) []ObjState { - w.mu.Lock() - defer w.mu.Unlock() - var out []ObjState - lower := strings.ToLower(name) - for _, st := range w.objStates { - if st.RoomID != roomID { - continue - } - if !wordMatchesObj(lower, st.DefID, st.Name) { - continue - } - out = append(out, ObjState{ - DefID: st.DefID, - Name: st.Name, - Index: st.Index, - RoomID: st.RoomID, - Depleted: st.Depleted, - DepleteTimer: st.DepleteTimer, - SharedMax: st.SharedMax, - SharedTimer: st.SharedTimer, - Quality: st.Quality, - }) - } - sort.Slice(out, func(i, j int) bool { - if out[i].DefID != out[j].DefID { - return out[i].DefID < out[j].DefID - } - return out[i].Index < out[j].Index - }) - return out -} - -func wordMatchesObj(lower, defID, objName string) bool { - if WordPrefixMatch(lower, objName) { - return true - } - inputWords := strings.Fields(strings.ToLower(lower)) - - if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) { - return true - } - for _, part := range strings.Split(defID, "_") { - for _, iw := range inputWords { - pl := strings.ToLower(part) - if strings.HasPrefix(pl, iw) || strings.HasPrefix(iw, pl) { - return true - } - } - } - return false -} - -func (w *World) SetObjName(roomID int, defID string, name string) { - w.mu.Lock() - defer w.mu.Unlock() - for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID { - st.Name = name - } - } -} - -func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) { - w.mu.Lock() - defer w.mu.Unlock() - for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID { - st.WanderRooms = rooms - st.WanderInterval = interval - } - } -} - -func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) { - w.mu.Lock() - defer w.mu.Unlock() - for _, st := range w.objStates { - if st.RoomID == roomID && st.DefID == defID && st.SharedMax == 0 { - st.SharedMax = max - st.SharedTimer = int(max) - } - } -} - -func (w *World) AllSharedObjStates() []*ObjState { - w.mu.Lock() - defer w.mu.Unlock() - var out []*ObjState - for _, st := range w.objStates { - if st.SharedMax > 0 { - out = append(out, st) - } - } - return out -} - -func (w *World) SetObjDepleted(roomID int, defID string, index int, delay float64) { - w.mu.Lock() - defer w.mu.Unlock() - key := w.objStateKey(roomID, defID, index) - if st, ok := w.objStates[key]; ok { - st.Depleted = true - st.DepleteTimer = delay - } -} - func New(dataDir string) *World { return &World{ dataDir: dataDir, @@ -342,129 +67,6 @@ func (w *World) ResolveExit(input string) ExitDir { return "" } -func (w *World) GroundItemsDetailed(roomID int) []GroundItemInfo { - w.mu.Lock() - defer w.mu.Unlock() - var out []GroundItemInfo - for _, e := range w.groundItems[roomID] { - if e.quantity <= 0 { - continue - } - info := GroundItemInfo{ - ItemID: e.itemID, - Quantity: e.quantity, - DespawnTimer: e.despawnTimer, - IsSpawn: e.isSpawn, - } - if e.reserveTimer > 0 && e.reservedFor != "" { - info.ReservedFor = e.reservedFor - info.ReserveTimer = e.reserveTimer - } - out = append(out, info) - } - return out -} - -func (w *World) GroundItems(roomID int) map[string]int { - w.mu.Lock() - defer w.mu.Unlock() - out := make(map[string]int) - for _, e := range w.groundItems[roomID] { - if e.quantity > 0 { - out[e.itemID] += e.quantity - } - } - return out -} - -func (w *World) AddReservedGroundItem(roomID int, itemID string, qty int, owner string) { - w.mu.Lock() - defer w.mu.Unlock() - e := &groundEntry{ - itemID: itemID, - quantity: qty, - despawnTimer: DropDespawnTicks, - reservedFor: owner, - reserveTimer: ReserveTicks, - } - w.groundItems[roomID] = append(w.groundItems[roomID], e) -} - -func (w *World) AddGroundItem(roomID int, itemID string, qty int) { - w.mu.Lock() - defer w.mu.Unlock() - e := &groundEntry{ - itemID: itemID, - quantity: qty, - despawnTimer: DropDespawnTicks, - } - w.groundItems[roomID] = append(w.groundItems[roomID], e) -} - -func (w *World) RemoveReservedGroundItem(roomID int, itemID string, qty int, owner string) (int, bool) { - w.mu.Lock() - defer w.mu.Unlock() - removed := 0 - remaining := qty - for _, e := range w.groundItems[roomID] { - if remaining <= 0 { - break - } - if !strings.EqualFold(e.itemID, itemID) { - continue - } - if e.quantity <= 0 { - continue - } - if e.reserveTimer > 0 && e.reservedFor != "" && e.reservedFor != owner { - return removed, false - } - take := e.quantity - if take > remaining { - take = remaining - } - e.quantity -= take - removed += take - remaining -= take - if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { - e.respawnTimer = e.respawnDelay - } - } - return removed, true -} - -func (w *World) RemoveGroundItem(roomID int, itemID string, qty int) int { - w.mu.Lock() - defer w.mu.Unlock() - - removed := 0 - remaining := qty - - for _, e := range w.groundItems[roomID] { - if remaining <= 0 { - break - } - if !strings.EqualFold(e.itemID, itemID) { - continue - } - if e.quantity <= 0 { - continue - } - take := e.quantity - if take > remaining { - take = remaining - } - e.quantity -= take - removed += take - remaining -= take - - if e.isSpawn && e.quantity <= 0 && e.respawnDelay > 0 { - e.respawnTimer = e.respawnDelay - } - } - return removed -} - func (w *World) SeedGroundItems(roomID int) { w.mu.Lock() if w.seeded[roomID] { @@ -512,110 +114,6 @@ func (w *World) SeedGroundItems(roomID int) { func (w *World) Tick() { w.mu.Lock() defer w.mu.Unlock() - - for _, entries := range w.groundItems { - for _, e := range entries { - if e.respawnTimer > 0 { - e.respawnTimer-- - if e.respawnTimer <= 0 { - e.quantity = e.respawnQty - for _, other := range entries { - if other != e && other.quantity > 0 && strings.EqualFold(other.itemID, e.itemID) && - (other.reserveTimer <= 0 || other.reservedFor == "") && - other.despawnTimer <= 0 { - e.quantity += other.quantity - other.quantity = 0 - } - } - } - } - if e.despawnTimer > 0 { - e.despawnTimer-- - if e.despawnTimer <= 0 { - e.quantity = 0 - } - } - if e.reserveTimer > 0 { - e.reserveTimer-- - if e.reserveTimer <= 0 { - e.reservedFor = "" - } - } - } - } - - for _, st := range w.objStates { - if st.Depleted && st.DepleteTimer > 0 { - st.DepleteTimer-- - if st.DepleteTimer <= 0 { - st.Depleted = false - st.JustRespawned = true - if st.SharedMax > 0 { - st.SharedTimer = int(st.SharedMax) - } - } - } - } -} - -func (w *World) FlushObjRespawns() []ObjState { - w.mu.Lock() - defer w.mu.Unlock() - var out []ObjState - for _, st := range w.objStates { - if st.JustRespawned { - out = append(out, *st) - st.JustRespawned = false - } - } - return out -} - -func (w *World) TickObjWander() { - w.mu.Lock() - defer w.mu.Unlock() - var moves []struct { - st *ObjState - toRoom int - } - for _, st := range w.objStates { - if len(st.WanderRooms) == 0 || st.WanderInterval <= 0 { - continue - } - st.WanderCounter++ - if float64(st.WanderCounter) < st.WanderInterval { - continue - } - st.WanderCounter = 0 - toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))] - if toRoom == st.RoomID { - continue - } - moves = append(moves, struct { - st *ObjState - toRoom int - }{st, toRoom}) - } - for _, m := range moves { - oldKey := w.objStateKey(m.st.RoomID, m.st.DefID, m.st.Index) - fromRoom := m.st.RoomID - m.st.RoomID = m.toRoom - newKey := w.objStateKey(m.st.RoomID, m.st.DefID, m.st.Index) - delete(w.objStates, oldKey) - w.objStates[newKey] = m.st - w.objMoves = append(w.objMoves, ObjMove{ - DefID: m.st.DefID, - Name: m.st.Name, - FromRoom: fromRoom, - ToRoom: m.toRoom, - }) - } -} - -func (w *World) FlushObjMoves() []ObjMove { - w.mu.Lock() - defer w.mu.Unlock() - out := w.objMoves - w.objMoves = nil - return out + w.tickGroundItemsLocked() + w.tickObjStatesLocked() } diff --git a/worldbuilding_guide/README.md b/worldbuilding_guide/README.md index 97cf43b..0bea82c 100644 --- a/worldbuilding_guide/README.md +++ b/worldbuilding_guide/README.md @@ -10,7 +10,7 @@ The House of Icarus is data-driven. Everything — rooms, items, mobs, objects, | [Items](items.md) | Item definitions, stats, equip slots, weapon types, tool types, firemaking, food/healing | | [Mobs](mobs.md) | Mob definitions, combat stats, drops, behavior, idle descriptions | | [Objects](objects.md) | Interactive objects, behaviors, hidden objects, in-room descriptions | -| [Behaviors](behaviors.md) | Gather, use, talk, toggle behaviors with full YAML reference | +| [Behaviors](behaviors.md) | Gather, use, talk behaviors with full YAML reference | | [Recipes](recipes.md) | Recipe system for cooking, crafting, smithing — station and item-on-item | | [Conditions](conditions.md) | All condition types: flag, player_flag, has_item, all_of, any_of, not | | [Drop Tables](drops.md) | Shared weighted drop tables for mob loot and search tables | @@ -19,7 +19,6 @@ The House of Icarus is data-driven. Everything — rooms, items, mobs, objects, | [Quests](quests.md) | Complete multi-room quest example with talk behaviors | | [Wandering Objects](wandering_objects.md) | Objects that teleport between rooms on a timer | | [Wandering Mobs](wandering_mobs.md) | Mobs that wander through legal exits | -| [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 | diff --git a/worldbuilding_guide/behaviors.md b/worldbuilding_guide/behaviors.md index aa46073..1934f5b 100644 --- a/worldbuilding_guide/behaviors.md +++ b/worldbuilding_guide/behaviors.md @@ -1,112 +1,167 @@ ## Behaviors +Behaviors are **inline configs** placed directly inside object YAML (`gather:`, `talk:`, +`use:`) or mob YAML (`talk:`). There is no separate `data/behaviors/` +directory. Each section below shows the keys you can use under each behavior type. + ### Gather (mining, fishing, woodcutting) -Mining — per-drop depletion: +Mining — per-drop depletion on a rock object: ```yaml -id: mine_copper -type: gather -skill: mining -level: 1 -xp: 17 # XP awarded per successful gather -base_wait: 8 # ticks between attempts -tool: pickaxe # requires item with tool_type: pickaxe -success: - base: 0.40 # 40% base chance - per_level: 0.01 # +1% per level above requirement - cap: 0.95 # 95% max -gather_message: "You swing your pickaxe at the rock..." -fail_message: "You chip away but get nothing useful." -drops: - - item_id: copper_ore - weight: 90 # 90% chance when roll succeeds - depletes: true # rock becomes depleted after this drop - message: "You manage to mine some {178}copper ore{/}." - - table: gem_table # reference a shared drop table - weight: 10 - depletes: false # gem drops don't deplete the rock - message: "You spot a glint of something valuable!" -respawn_timer: 50 # ticks until rock respawns -respawn_message: "You see more ore in the rock." -respawn_broadcast: "A glint of copper catches your eye from some {name}." +id: copper_rock +name: copper rock +color: "178" +gather: + skill: mining + level: 1 + xp: 17 # XP awarded per successful gather + base_wait: 8 # ticks between attempts + tools: # requires item with matching tool_type + - pickaxe + success: + base: 0.40 # 40% base chance + per_level: 0.01 # +1% per level above requirement + cap: 0.95 # 95% max + gather_message: "You swing your pickaxe at the rock..." + fail_message: "You chip away but get nothing useful." + drops: + - item_id: copper_ore + weight: 90 # 90% chance when roll succeeds + depletes: true # rock becomes depleted after this drop + message: "You manage to mine some {178}copper ore{/}." + - table: gem_table # reference a shared drop table + weight: 10 + depletes: false # gem drops don't deplete the rock + message: "You spot a glint of something valuable!" + respawn_timer: 50 # ticks until rock respawns + respawn_broadcast: "A glint of copper catches your eye from some {name}." ``` -Drop messages support inline color tags: `{<0-255>}text{/}`. Use the item's color index to match its display color. Gradients also work: `{g:196,82}text{/}`. +Drop messages support inline color tags: `{<0-255>}text{/}`. Use the item's color +index to match its display color. Gradients also work: `{g:196,82}text{/}`. -Non-depleting gather (fishing): +Non-depleting gather (fishing on a fishing spot object): ```yaml -id: fish_trout -type: gather -skill: fishing -level: 1 -xp: 10 -base_wait: 4 -tool: fishing_rod -success: - base: 0.30 - per_level: 0.01 - cap: 0.90 -gather_message: "You cast your line into the water..." -fail_message: "Nothing seems to bite." -drops: - - item_id: raw_trout - weight: 100 - depletes: false # never depletes - message: "You catch a {69}raw trout{/}!" +id: bait_fishing_spot +name: fishing spot +gather: + skill: fishing + level: 1 + xp: 10 + base_wait: 4 + tools: + - fishing_rod + bait: fishing_bait + success: + base: 0.30 + per_level: 0.01 + cap: 0.90 + gather_message: "You cast your line into the water..." + fail_message: "Nothing seems to bite." + drops: + - item_id: raw_trout + weight: 100 + depletes: false # never depletes + message: "You catch a {69}raw trout{/}!" ``` Woodcutting with shared depletion and bird's nests: ```yaml -id: chop_oak -type: gather -skill: woodcutting -level: 15 -xp: 37 -base_wait: 6 -tool: axe -success: - base: 0.40 - per_level: 0.01 - cap: 0.90 -gather_message: "You swing your axe at the oak tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: oak_logs - weight: 100 - depletes: false # depletion is timer-based (deplete_timer) - message: "You get some {113}oak logs{/}." -respawn_timer: 14 # ticks until tree respawns after being cut down -deplete_timer: 45 # max ticks before next gather depletes (counts down while chopping) -nest_chance: 256 # 1/256 chance for a bird's nest on each successful gather -respawn_message: "A new oak sapling grows in its place." -respawn_broadcast: "An {name} grows back." +id: oak_tree +name: oak tree +color: "113" +gather: + skill: woodcutting + level: 15 + xp: 37 + base_wait: 6 + tools: + - axe + success: + base: 0.40 + per_level: 0.01 + cap: 0.90 + gather_message: "You swing your axe at the oak tree..." + fail_message: "You swing but get no logs." + drops: + - item_id: oak_logs + weight: 100 + depletes: false # depletion is timer-based (deplete_timer) + message: "You get some {113}oak logs{/}." + respawn_timer: 14 # ticks until tree respawns after being cut down + deplete_timer: 45 # max ticks before next gather depletes (counts down while chopping) + nest_chance: 256 # 1/256 chance for a bird's nest on each successful gather + respawn_broadcast: "An {name} grows back." ``` Regular tree — always depletes on first gather, no shared timer, no nests: ```yaml -id: chop_tree -type: gather -skill: woodcutting -level: 1 -xp: 25 -base_wait: 4 -tool: axe -success: - base: 0.50 - per_level: 0.01 - cap: 0.95 -gather_message: "You swing your axe at the tree..." -fail_message: "You swing but get no logs." -drops: - - item_id: logs - weight: 100 - depletes: true # regular tree depletes on first successful gather - message: "You get some {107}logs{/}." -respawn_timer: 80 -respawn_message: "A new tree grows in its place." -respawn_broadcast: "A {name} grows back." +id: tree +name: tree +gather: + skill: woodcutting + level: 1 + xp: 25 + base_wait: 4 + tools: + - axe + success: + base: 0.50 + per_level: 0.01 + cap: 0.95 + gather_message: "You swing your axe at the tree..." + fail_message: "You swing but get no logs." + drops: + - item_id: logs + weight: 100 + depletes: true # regular tree depletes on first successful gather + message: "You get some {107}logs{/}." + respawn_timer: 80 + respawn_broadcast: "A {name} grows back." ``` +#### GatherConfig Fields + +| Field | Type | Description | +|---|---|---| +| `skill` | string | Skill name for level check | +| `level` | int | Required level to gather | +| `xp` | int | XP per successful gather | +| `base_wait` | float64 | Base ticks per action cycle | +| `tools` | []string | Required tool_type list (e.g. `[pickaxe]`) | +| `bait` | string | Required bait item (for fishing) | +| `success` | SuccessFormula | Base + per_level, capped | +| `gather_message` | string | Custom "You gather..." message | +| `depleted_message` | string | Custom "The rock is depleted" message | +| `exhausted_message` | string | Custom "The tree falls" message | +| `fail_message` | string | Custom failure message | +| `drops` | []DropEntry | Weighted drop entries | +| `respawn_timer` | float64 | Ticks until depleted object respawns | +| `respawn_broadcast` | string | Broadcast message when object respawns | +| `deplete_timer` | float64 | Ticks for shared depletion (trees) | +| `nest_chance` | int | 1/N chance for bird's nest alongside normal drop | + +#### SuccessFormula + +| Field | Type | Description | +|---|---|---| +| `base` | float64 | Base success chance (0.0-1.0) | +| `per_level` | float64 | Chance increase per level above requirement | +| `cap` | float64 | Maximum success chance | + +#### DropEntry + +| Field | Type | Description | +|---|---|---| +| `item_id` | string | Item ID to drop | +| `table` | string | Reference to a shared drop table in `data/drops/` | +| `weight` | int | Relative drop weight | +| `depletes` | bool | Resource depletes on this drop | +| `quantity` | int | Amount to drop (1-3 for random) | +| `message` | string | Player message on drop | +| `level` | int | Skill level required for this drop | +| `xp` | int | Bonus XP on this drop | + #### Shared depletion explained When `deplete_timer > 0`, the tree has a shared despawn timer: @@ -131,60 +186,63 @@ toggle is on, the output includes the XP gain: `(+37xp wct)`. ### Talk (dialog trees) -Full conversation with conditions, actions, and player flag tracking: +Talk configs go under the `talk:` key on objects or mobs. Full conversation with +conditions, actions, and player flag tracking: + ```yaml -id: guard_talk -type: talk -nodes: - start: - message: "\"Halt! This area is restricted.\"" - options: - - text: "\"What's behind that gate?\"" - goto: about_gate - - text: "\"I have copper ore.\"" # only shows if player has ore - goto: trade_ore - condition: - has_item: copper_ore - - text: "\"I have a pass.\"" # only shows if player earned a pass - goto: has_pass - condition: - player_flag: got_pass - value: true - - text: "\"Goodbye.\"" - end: true - - about_gate: - message: "\"Bring me some copper ore and I'll stamp you a pass.\"" - action: - set_player_flags: # player-local: only this player - talked_to_guard: true - options: - - text: "\"I'll be back.\"" - end: true - - text: "\"I have some right here.\"" - goto: trade_ore - condition: - has_item: copper_ore - - trade_ore: - message: "\"Good quality ore.\" He stamps a pass and hands it to you." - action: - take_item: copper_ore # removes 1 copper ore - give_item: pass_stub # gives pass stub - set_player_flags: - got_pass: true # player now "has a pass" - options: - - text: "\"Thanks.\"" - end: true - - has_pass: - message: "\"Alright, I'll open the gate for you.\"" - action: - set_flags: # WORLD flag: gate opens for everyone - gate_open: true - options: - - text: "\"Thanks.\"" - end: true +id: guard +name: Guard +talk: + nodes: + start: + message: "\"Halt! This area is restricted.\"" + options: + - text: "\"What's behind that gate?\"" + goto: about_gate + - text: "\"I have copper ore.\"" + goto: trade_ore + condition: + has_item: copper_ore + - text: "\"I have a pass.\"" + goto: has_pass + condition: + player_flag: got_pass + value: true + - text: "\"Goodbye.\"" + end: true + + about_gate: + message: "\"Bring me some copper ore and I'll stamp you a pass.\"" + action: + set_player_flags: + talked_to_guard: true + options: + - text: "\"I'll be back.\"" + end: true + - text: "\"I have some right here.\"" + goto: trade_ore + condition: + has_item: copper_ore + + trade_ore: + message: "\"Good quality ore.\" He stamps a pass and hands it to you." + action: + take_item: copper_ore + give_item: pass_stub + set_player_flags: + got_pass: true + options: + - text: "\"Thanks.\"" + end: true + + has_pass: + message: "\"Alright, I'll open the gate for you.\"" + action: + set_flags: + gate_open: true + options: + - text: "\"Thanks.\"" + end: true ``` #### Node action reference @@ -203,19 +261,21 @@ nodes: | `skip_task` | Cancels current assassin task, costs 30 reputation, resets streak | | `extend_task` | Adds 50% more kills to current task, costs 30 reputation | | `reputation_cost` | Deducts reputation from the player's `assassin_reputation` flag (fails node if insufficient) | +| `sawmill` | Opens sawmill plank conversion interface | -All fields in a single action are processed together — you can give an item, take an item, set flags, and heal all in one node. +All fields in a single action are processed together — you can give an item, take an item, +set flags, and heal all in one node. Example — quest completion: ```yaml action: - take_item: dragon_head - give_item: dragon_slayer_medal - set_player_flags: - dragon_quest: complete - dragon_slain: true - heal: 99 - teleport: 1 # return to town + take_item: dragon_head + give_item: dragon_slayer_medal + set_player_flags: + dragon_quest: complete + dragon_slain: true + heal: 99 + teleport: 1 ``` #### Shop (buy/sell interface) @@ -225,15 +285,15 @@ items, buy with credits, and sell items back. Define a shop on a talk node: ```yaml action: - shop: - message: "What would you like to buy or sell?" - items: - - item_id: fishing_rod - buy_price: 10 - sell_price: 2 - - item_id: fishing_bait - buy_price: 2 - sell_price: 0 + shop: + message: "What would you like to buy or sell?" + items: + - item_id: fishing_rod + buy_price: 10 + sell_price: 2 + - item_id: fishing_bait + buy_price: 2 + sell_price: 0 ``` | Field | Description | @@ -249,85 +309,95 @@ When the player leaves the shop, they return to the talk node's options. Full example — General Store: ```yaml -id: general_store -type: talk -nodes: - start: - message: "\"Welcome to the General Store!\"" - options: - - text: "\"I'd like to browse.\"" - goto: shop - - text: "\"Goodbye.\"" - end: true - shop: - message: "\"Take your time.\"" - action: - shop: - message: "What would you like to buy or sell?" - items: - - item_id: fishing_rod - buy_price: 10 - sell_price: 2 - - item_id: hammer - buy_price: 20 - sell_price: 5 - options: - - text: "\"I'm done.\"" - goto: start +id: general_store_clerk +name: General Store Clerk +talk: + nodes: + start: + message: "\"Welcome to the General Store!\"" + options: + - text: "\"I'd like to browse.\"" + goto: shop + - text: "\"Goodbye.\"" + end: true + shop: + message: "\"Take your time.\"" + action: + shop: + message: "What would you like to buy or sell?" + items: + - item_id: fishing_rod + buy_price: 10 + sell_price: 2 + - item_id: hammer + buy_price: 20 + sell_price: 5 + options: + - text: "\"I'm done.\"" + goto: start ``` Node options on the shop node are shown when the player leaves the shop. Use `goto: start` to loop back to the main greeting. -### Toggle (levers, switches, gates) +### Object Interactions (levers, switches, gates) + +Interaction configs go under the `use_interactions:` key on objects. +Entries with no `item` field are bare interactions triggered by "use" +or "push"/"pull": -Simple toggle that sets a world flag: ```yaml -id: iron_gate_toggle -type: toggle -message: "You push the heavy iron gate open." -set_flags: - gate_open: true -check: # only works when gate is closed - flag: gate_open - value: true - not: true +id: iron_gate +name: iron gate +hidden: true +description: "A heavy iron gate set into the north wall." +use_interactions: + - condition: + flag: gate_open + value: true + not: true + message: "You push the heavy iron gate open." + action: + set_flags: + gate_open: true ``` -Lever that toggles between two states: +Item-specific interaction: ```yaml -id: bridge_lever -type: toggle -message: "You pull the lever. Mechanisms groan somewhere in the distance." -set_flags: - bridge_extended: true -check: - flag: bridge_extended - value: true - not: true +id: bookshelf +name: bookshelf +use_interactions: + - item: dusty_tome + message: "The bookshelf slides aside, revealing a secret passage!" + action: + set_flags: + secret_passage_open: true ``` ### Use (crafting stations) +Use configs go under the `use:` key on objects (e.g. furnaces, ranges): + ```yaml -id: smelt_copper -type: use -message: "You place the ore in the furnace..." -wait: 4 # ticks between crafts -consume: # items consumed per craft - copper_ore: 1 -reward: # item produced - item_id: copper_bar - quantity: 1 -fail_message: "The ore crumbles to dust." -success: - base: 0.60 - per_level: 0.01 - cap: 0.95 -skill: smithing -level: 1 -xp: 15 # XP awarded per successful craft +id: furnace +name: furnace +color: "208" +use: + message: "You place the ore in the furnace..." + wait: 4 + consume: + copper_ore: 1 + reward: + item_id: copper_bar + quantity: 1 + fail_message: "The ore crumbles to dust." + success: + base: 0.60 + per_level: 0.01 + cap: 0.95 + skill: smithing + level: 1 + xp: 15 ``` --- - diff --git a/worldbuilding_guide/conditions.md b/worldbuilding_guide/conditions.md index 773230b..972dadc 100644 --- a/worldbuilding_guide/conditions.md +++ b/worldbuilding_guide/conditions.md @@ -1,6 +1,6 @@ ## Conditions Reference -Conditions are used in talk options, exit gates, on-enter scripts, and toggle checks. +Conditions are used in talk options, exit gates, on-enter scripts, and use_interactions checks. ### Simple conditions diff --git a/worldbuilding_guide/construction.md b/worldbuilding_guide/construction.md index 799057a..d4ba840 100644 --- a/worldbuilding_guide/construction.md +++ b/worldbuilding_guide/construction.md @@ -125,7 +125,24 @@ Located in room 16 (Construction Site). Talk behavior sells a house plot for 10 ```yaml id: estate_broker name: estate broker -behavior: estate_broker_talk +talk: + nodes: + start: + message: "\"Interested in a house?\"" + options: + - text: "\"How much?\"" + goto: offer + - text: "\"No thanks.\"" + end: true + offer: + message: "\"10 credits for a plot.\"" + action: + cost: 10 + set_player_flags: + owns_house_local_neighborhood: "local_neighborhood" + options: + - text: "\"Deal!\"" + end: true unique: true ``` @@ -196,10 +213,8 @@ This enables automatic support through the unified production system (`advancePr | `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/mobs/estate_broker.yaml` | Estate broker mob (talk inline) | +| `data/mobs/sawmill_operator.yaml` | Sawmill operator mob (talk inline) | | `data/rooms/16.yaml` | Construction Site (updated) | | `data/rooms/170.yaml` | Local Neighborhood | | `data/rooms/171.yaml` | Lumberyard | diff --git a/worldbuilding_guide/doors.md b/worldbuilding_guide/doors.md index afe4556..1e9fb7a 100644 --- a/worldbuilding_guide/doors.md +++ b/worldbuilding_guide/doors.md @@ -8,20 +8,15 @@ A button in room 3 opens a door in room 7. Anyone can press it. Once pressed, th ```yaml id: door_button name: stone button -behavior: button_toggle hidden: true -``` - -**Button behavior** (`data/behaviors/button_toggle.yaml`): -```yaml -id: button_toggle -type: toggle -message: "You press the stone button. You hear grinding stone in the distance." -set_flags: - secret_door_open: true # WORLD flag -check: - flag: secret_door_open - not: true # only works when door is closed +use_interactions: + - condition: + flag: secret_door_open + not: true + message: "You press the stone button. You hear grinding stone in the distance." + action: + set_flags: + secret_door_open: true ``` **Room 3** — contains the button: diff --git a/worldbuilding_guide/hidden_objects.md b/worldbuilding_guide/hidden_objects.md index a191051..39030d8 100644 --- a/worldbuilding_guide/hidden_objects.md +++ b/worldbuilding_guide/hidden_objects.md @@ -6,9 +6,17 @@ Objects with `hidden: true` don't appear in the room's object listing. Players d # data/objects/secret_lever.yaml id: secret_lever name: stone lever -behavior: secret_toggle hidden: true description: "A cleverly concealed lever behind a loose stone." +use_interactions: + - condition: + flag: secret_passage_open + value: true + not: true + message: "You pull the lever. A grinding sound echoes from the east." + action: + set_flags: + secret_passage_open: true ``` Room description hints at it: diff --git a/worldbuilding_guide/index.md b/worldbuilding_guide/index.md index 9f721cb..83b3782 100644 --- a/worldbuilding_guide/index.md +++ b/worldbuilding_guide/index.md @@ -6,9 +6,12 @@ | An item (sword, ore, key) | `data/items/.yaml` | | A mob (NPC, monster) | `data/mobs/.yaml` | | 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` | +| A science module | `data/modules/.yaml` | | An agility course | `data/courses/.yaml` | | A player house | `data/rooms/player_housing/.yaml` | + +Behaviors (gather, talk, use) are defined **inline** in object and mob YAML +under `gather:`, `talk:`, or `use:` keys. There is no separate behavior directory. diff --git a/worldbuilding_guide/mobs.md b/worldbuilding_guide/mobs.md index 3826dda..70afa6a 100644 --- a/worldbuilding_guide/mobs.md +++ b/worldbuilding_guide/mobs.md @@ -74,13 +74,19 @@ ranged_defense: 3 ### Protected/Unique Mobs -Mob with a behavior — can be talked to, toggled, etc: +Mob with talk behavior — can be talked to via `talk:` inline config: ```yaml id: "guard" name: "Guard" -behavior: guard_talk # links to data/behaviors/guard_talk.yaml -unique: true # displays as "Guard" not "a guard" -protected: true # cannot be attacked +talk: # inline talk config (no separate behavior file) + nodes: + start: + message: "\"Halt! Who goes there?\"" + options: + - text: "\"Just passing through.\"" + end: true +unique: true # displays as "Guard" not "a guard" +protected: true # cannot be attacked attack: 5 strength: 5 defense: 5 diff --git a/worldbuilding_guide/objects.md b/worldbuilding_guide/objects.md index 1c1aebb..a7da5e6 100644 --- a/worldbuilding_guide/objects.md +++ b/worldbuilding_guide/objects.md @@ -1,39 +1,116 @@ ## Objects -Object defined — links to a behavior: +Objects interact with the world through **inline behavior configs** under +`gather:`, `talk:`, or `use:` keys. There is no separate behavior +directory — everything goes directly in the object's YAML. + +Gathering object (mining): ```yaml id: copper_rock name: copper rock color: "178" # xterm-256 color index (0-255) -behavior: mine_copper +gather: + skill: mining + level: 1 + xp: 17 + base_wait: 8 + tools: + - pickaxe + success: + base: 0.40 + per_level: 0.01 + cap: 0.95 + gather_message: "You swing your pickaxe at the rock..." + fail_message: "You chip away but get nothing useful." + drops: + - item_id: copper_ore + weight: 90 + depletes: true + message: "You manage to mine some {178}copper ore{/}." + - table: gem_table + weight: 10 + depletes: false + message: "You spot a glint of something valuable!" + respawn_timer: 50 + respawn_broadcast: "A glint of copper catches your eye from some {name}." ``` -Tree object: +Tree object (woodcutting with shared depletion): ```yaml id: oak_tree name: oak tree color: "113" -behavior: chop_oak +gather: + skill: woodcutting + level: 15 + xp: 37 + base_wait: 6 + tools: + - axe + success: + base: 0.40 + per_level: 0.01 + cap: 0.90 + gather_message: "You swing your axe at the oak tree..." + fail_message: "You swing but get no logs." + drops: + - item_id: oak_logs + weight: 100 + depletes: false + message: "You get some {113}oak logs{/}." + respawn_timer: 14 + deplete_timer: 45 + nest_chance: 256 + respawn_broadcast: "An {name} grows back." ``` -Color accepts xterm-256 indices with optional modifiers (`bold`, `dim`, `underline`) and gradients (`g:196,82`). In ANSI mode, extended colors downgrade to the nearest ANSI color. +Color accepts xterm-256 indices with optional modifiers (`bold`, `dim`, `underline`) +and gradients (`g:196,82`). In ANSI mode, extended colors downgrade to the nearest +ANSI color. -Hidden object — doesn't appear in room's object list, only discoverable via description or experimentation: +Object interaction (gate, lever — uses `use_interactions:` key): ```yaml id: iron_gate name: iron gate -behavior: iron_gate_toggle hidden: true description: "A heavy iron gate set into the north wall." +use_interactions: + - condition: + flag: gate_open + value: true + not: true + message: "You push the heavy iron gate open." + action: + set_flags: + gate_open: true ``` -Decorative object (no behavior): +Decorative object (no behavior keys — just a name/description): ```yaml id: lumby_fountain name: town fountain description: "Clear water sparkles in the sunlight." ``` +Talk object (NPC conversations — uses `talk:` key): +```yaml +id: tool_shed +name: Tool Shed +description: "A small shed with an open window." +talk: + nodes: + start: + message: "\"Welcome to the tool shed!\"" + options: + - text: "\"What do you have?\"" + goto: shop + - text: "\"Goodbye.\"" + end: true +``` + +See the [Talk section of behaviors](behaviors.md#talk-dialog-trees) for the full +dialog tree format. See [Stealable Objects](#stealable-objects) for theft mechanics. + --- ## Use Interactions @@ -90,7 +167,7 @@ use_interactions: | Field | Type | Description | | ----------- | ---------- | ------------------------------------------------ | | `item` | string | Item ID that triggers this interaction | -| `condition` | Condition | Optional condition (same as exits/talk/toggle) | +| `condition` | Condition | Optional condition (same as exits/talk/use_interactions) | | `message` | string | Message shown to the player | | `action` | NodeAction | Optional actions (same as talk node actions) | diff --git a/worldbuilding_guide/quests.md b/worldbuilding_guide/quests.md index 443aa6b..b5f18fa 100644 --- a/worldbuilding_guide/quests.md +++ b/worldbuilding_guide/quests.md @@ -4,9 +4,45 @@ ```yaml id: "quest_giver" name: "Elder" -behavior: rat_quest_talk unique: true protected: true +talk: + nodes: + start: + message: "\"Rats! Rats everywhere in the cellar. Clear them out and I'll reward you.\"" + options: + - text: "\"I'll handle it.\"" + goto: accept_quest + condition: + player_flag: rat_quest + not: true + - text: "\"I killed the rats.\"" + goto: turn_in + condition: + player_flag: rat_quest + value: started + - text: "\"Goodbye.\"" + end: true + + accept_quest: + message: "\"Good lad. The cellar is west of here. Come back when they're dead.\"" + action: + set_player_flags: + rat_quest: started + options: + - text: "\"On my way.\"" + end: true + + turn_in: + message: "\"You did it! The village owes you a debt. Here — take this.\"" + action: + give_item: rusty_sword + set_player_flags: + rat_quest: complete + heal: 10 + options: + - text: "\"Thanks!\"" + end: true attack: 1 strength: 1 defense: 1 @@ -14,52 +50,12 @@ hp: 20 speed: 5 aggressive: false idle_descriptions: - - "mutters about the rat infestation" + - "mutters about the rat infestation" ``` -### 2. Dialog behavior (`data/behaviors/rat_quest_talk.yaml`) -```yaml -id: rat_quest_talk -type: talk -nodes: - start: - message: "\"Rats! Rats everywhere in the cellar. Clear them out and I'll reward you.\"" - options: - - text: "\"I'll handle it.\"" - goto: accept_quest - condition: - player_flag: rat_quest - not: true - - text: "\"I killed the rats.\"" - goto: turn_in - condition: - player_flag: rat_quest - value: started - - text: "\"Goodbye.\"" - end: true - - accept_quest: - message: "\"Good lad. The cellar is west of here. Come back when they're dead.\"" - action: - set_player_flags: - rat_quest: started - options: - - text: "\"On my way.\"" - end: true - - turn_in: - message: "\"You did it! The village owes you a debt. Here — take this.\"" - action: - give_item: rusty_sword - set_player_flags: - rat_quest: complete - heal: 10 - options: - - text: "\"Thanks!\"" - end: true -``` +Talk configs are inline on the mob under the `talk:` key. No separate behavior file needed. -### 3. Rat mobs (`data/mobs/rat.yaml`) +### 2. Rat mobs (`data/mobs/rat.yaml`) ```yaml id: "rat" name: "giant rat" @@ -74,7 +70,7 @@ drops: remains: "rat bones" ``` -### 4. Cellar room (`data/rooms/20.yaml`) +### 3. Cellar room (`data/rooms/20.yaml`) ```yaml id: 20 name: "Cellar" @@ -89,7 +85,7 @@ mobs: - "rat" ``` -### 5. Room 1 with quest giver (`data/rooms/1.yaml`) +### 4. Room 1 with quest giver (`data/rooms/1.yaml`) ```yaml id: 1 name: "Town Square" diff --git a/worldbuilding_guide/recipes.md b/worldbuilding_guide/recipes.md index 799dab0..81a1240 100644 --- a/worldbuilding_guide/recipes.md +++ b/worldbuilding_guide/recipes.md @@ -168,7 +168,7 @@ Item-on-item combinations are defined directly on the output item via the `made_ # data/items/bread_dough.yaml id: bread_dough name: bread dough -color: "fg=bright_yellow" +color: "222" made_from: - items: [pot_of_flour] qty: 1 @@ -185,7 +185,7 @@ Items with `heal_value` and `eat_message` can be consumed via the `eat` command. ```yaml id: bread name: bread -color: "fg=bright_yellow" +color: "222" description: "A fresh loaf of bread, still warm from the oven." value: 5 heal_value: 5 diff --git a/worldbuilding_guide/tips.md b/worldbuilding_guide/tips.md index 451b9bc..307ea1e 100644 --- a/worldbuilding_guide/tips.md +++ b/worldbuilding_guide/tips.md @@ -10,7 +10,7 @@ 5. **Objects are for fixtures, mobs are for living things.** If it has HP and can die, it's a mob. If it's a rock, lever, door, or crafting station, it's an object. Both can have behaviors. -6. **The `behavior` field on mobs** lets you talk to them directly — `talk guard` finds the guard mob, no duplicate object entry needed. +6. **The `talk` field on mobs** lets you talk to them directly — `talk guard` finds the guard mob, no duplicate object entry needed. 7. **Exits accept both `int` and `map` formats.** `north: 2` is shorthand for `north: {room: 2}`. Add `condition` and `blocked_message` only when needed. Mob room entries accept both `"man"` and `{id: man, ...}`. diff --git a/worldbuilding_guide/wandering_objects.md b/worldbuilding_guide/wandering_objects.md index 9a47ed2..f1f91c5 100644 --- a/worldbuilding_guide/wandering_objects.md +++ b/worldbuilding_guide/wandering_objects.md @@ -5,7 +5,23 @@ Fishing spots that move between rooms: # data/objects/fishing_spot.yaml id: fishing_spot name: fishing spot -behavior: fish_trout +gather: + skill: fishing + level: 1 + xp: 10 + base_wait: 4 + tools: + - fishing_rod + bait: fishing_bait + success: + base: 0.30 + per_level: 0.01 + cap: 0.90 + gather_message: "You cast your line into the water..." + drops: + - item_id: raw_trout + weight: 100 + message: "You catch a raw trout!" ``` ```yaml -- cgit v1.2.3