diff options
101 files changed, 5054 insertions, 2409 deletions
diff --git a/cmd/mud/main.go b/cmd/mud/main.go index 1c1e1eb..d1e4ef5 100644 --- a/cmd/mud/main.go +++ b/cmd/mud/main.go @@ -39,6 +39,8 @@ func main() { g.RegenTick() g.DisconnectTick() g.WanderTick() + g.AdvanceActions() + g.BroadcastRespawns() return true }) diff --git a/data/behaviors/fish_trout.yaml b/data/behaviors/fish_trout.yaml new file mode 100644 index 0000000..fd0e37a --- /dev/null +++ b/data/behaviors/fish_trout.yaml @@ -0,0 +1,17 @@ +id: fish_trout +type: gather +skill: fishing +level: 1 +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 + message: "You catch a trout!" diff --git a/data/behaviors/guard_talk.yaml b/data/behaviors/guard_talk.yaml new file mode 100644 index 0000000..e83f2e2 --- /dev/null +++ b/data/behaviors/guard_talk.yaml @@ -0,0 +1,52 @@ +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: + 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_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_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 new file mode 100644 index 0000000..e009ca7 --- /dev/null +++ b/data/behaviors/iron_gate_toggle.yaml @@ -0,0 +1,9 @@ +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_copper.yaml b/data/behaviors/mine_copper.yaml new file mode 100644 index 0000000..1f2f884 --- /dev/null +++ b/data/behaviors/mine_copper.yaml @@ -0,0 +1,24 @@ +id: mine_copper +type: gather +skill: mining +level: 1 +base_wait: 8 +tool: 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 copper ore." + - table: gem_table + weight: 10 + depletes: false + message: "You spot a glint of something valuable!" +deplete_delay: 50 +respawn_message: "You see more ore in the rock." +respawn_broadcast: "A glint of copper catches your eye from some {name}." diff --git a/data/drops/gem_table.yaml b/data/drops/gem_table.yaml new file mode 100644 index 0000000..9410ba1 --- /dev/null +++ b/data/drops/gem_table.yaml @@ -0,0 +1,10 @@ +id: gem_table +drops: + - item_id: uncut_sapphire + weight: 47 + - item_id: uncut_emerald + weight: 16 + - item_id: uncut_ruby + weight: 4 + - item_id: uncut_diamond + weight: 1 diff --git a/data/help/fishing.yaml b/data/help/fishing.yaml new file mode 100644 index 0000000..60f2d6a --- /dev/null +++ b/data/help/fishing.yaml @@ -0,0 +1,14 @@ +name: "fishing" +category: "Skills" +description: | + Fish from fishing spots. + + Usage: fish <spot>, fish <N.spot> + + Fishing requires a tool matching the spot's tool type (e.g. fishing_rod). + Better rods have higher tool_speed, reducing the wait between casts. + + Each cast has a chance to catch fish based on your fishing level. + Fishing spots never deplete — you fish until your inventory fills or + the spot wanders away. Fishing spots move between predefined rooms, + simulating wandering fish schools. diff --git a/data/help/gathering.yaml b/data/help/gathering.yaml new file mode 100644 index 0000000..57c317e --- /dev/null +++ b/data/help/gathering.yaml @@ -0,0 +1,22 @@ +name: "gathering" +category: "Skills" +description: | + Gather resources from objects in the world. + + Usage: mine <object> - Mine ore from rocks (requires pickaxe) + chop <object> - Chop logs from trees (requires axe) + fish <object> - Fish from fishing spots (requires rod/net) + + Gathering skills use a tick-based action system: + 1. You begin the action (e.g. "You swing your pickaxe...") + 2. After a wait (reduced by tool speed), the game rolls for success + 3. Success picks a weighted random drop from the object's drop table + 4. Depleting drops remove the resource temporarily; rare drops don't + + Tools: Items with tool_type and tool_speed. The best matching tool in + your inventory or toolbelt is used automatically. Higher tool_speed + means faster gathering. + + Numbered targeting: "mine 1.rock" targets a specific instance when + multiple objects of the same name are present. "look" shows instance + indices and depletion status. diff --git a/data/help/look.yaml b/data/help/look.yaml index 17faa45..b1d7a6d 100644 --- a/data/help/look.yaml +++ b/data/help/look.yaml @@ -6,10 +6,18 @@ description: | Usage: look, l look <target> - Without a target, shows the room name, description, exits, objects, - mobs, other players, and ground items. Damaged mobs show current HP - (e.g. "man (level 1) [3/7hp]"). + Without a target, shows: room name, description, objects (with instance + indices and depletion status), mobs (with HP and combat level), ground + items (with reservation info), exits, and other players. - With a target, shows details about a mob, object, item (ground or - inventory), or another player. Looking at items shows their value. - Looking at players shows their stats, equipment, and description. + Players are shown with their current action (e.g. "Bob is here, mining + a copper rock [1]") or combat status ("Bob is here (fighting a man [2])"). + Instance indices appear when multiple objects or mobs of the same type + are present. + + With a target, shows details about: + - Mobs: stats and combat level + - Objects: description and depletion timers (per instance) + - Items (ground or inventory): description and value + - Players: stats, equipment, and description + - Directions: shows the adjacent room as if you were there diff --git a/data/help/mining.yaml b/data/help/mining.yaml new file mode 100644 index 0000000..b883465 --- /dev/null +++ b/data/help/mining.yaml @@ -0,0 +1,21 @@ +name: "mining" +category: "Skills" +description: | + Mine ore from rocks. + + Usage: mine <rock>, mine <N.rock> + mine 1.rock, mine 2.rock + + Mining requires a pickaxe in your inventory or toolbelt. Better pickaxes + have higher tool_speed, which reduces the wait between swings. + + Each swing has a chance to yield ore based on your mining level. If you + get ore, the rock depletes and respawns after a delay. Other players + mining the same rock are interrupted when it depletes. + + If you get a gem or other rare drop, the rock does not deplete and you + continue mining. Type "mine rock" again during a wait to restart the + swing cycle immediately. + + Use numbered targeting (e.g. "mine 2.rock") when multiple rocks of the + same type are present. "look rock" shows which rocks are depleted. diff --git a/data/help/movement.yaml b/data/help/movement.yaml index 9b4f81c..239220f 100644 --- a/data/help/movement.yaml +++ b/data/help/movement.yaml @@ -6,8 +6,8 @@ description: | Usage: north, south, east, west, up, down Aliases: n, s, e, w, u, d - You cannot move for 15 ticks after combat starts. - Combat is interrupted if you leave the room. + You cannot move for 15 ticks after combat starts. Moving interrupts + combat and any active gathering or crafting action. With "description" toggled on, moving shows the full room description instead of just the room name. Use "toggle" to change this setting. diff --git a/data/help/talk.yaml b/data/help/talk.yaml new file mode 100644 index 0000000..4a59f68 --- /dev/null +++ b/data/help/talk.yaml @@ -0,0 +1,11 @@ +name: "talk" +category: "Communication" +description: | + Talk to NPCs. + + Usage: talk <npc>, speak <npc>, ask <npc> + + Starts a conversation with an NPC. You'll be presented with numbered + choices. Type the number to select an option. + + Type "bye", "exit", or "end" to leave the conversation at any time. diff --git a/data/help/use.yaml b/data/help/use.yaml new file mode 100644 index 0000000..41ceb34 --- /dev/null +++ b/data/help/use.yaml @@ -0,0 +1,13 @@ +name: "use" +category: "Skills" +description: | + Use a crafting station or interactive object. + + Usage: use <object> + + Some objects can be used for crafting (furnaces, anvils, spinning wheels, + ranges, etc.). Using an object typically consumes items from your + inventory and produces something new, with an optional skill check. + + You need the required materials in your inventory. The action continues + until you run out of materials or inventory space. diff --git a/data/items/bronze_pickaxe.yaml b/data/items/bronze_pickaxe.yaml index a64395e..260652b 100644 --- a/data/items/bronze_pickaxe.yaml +++ b/data/items/bronze_pickaxe.yaml @@ -1,7 +1,16 @@ -id: "bronze_pickaxe" -name: "bronze pickaxe" -aliases: ["pickaxe", "pick", "bronze"] -description: "A basic bronze pickaxe for mining." -value: 8 +id: bronze_pickaxe +name: bronze pickaxe +aliases: ["pick", "pickaxe"] +description: "A sturdy bronze pickaxe, good for mining." +value: 10 stackable: false +equip_slot: main_hand +weapon_type: melee +stats: + attack_bonus: 2 + strength_bonus: 1 + defense_bonus: 0 +speed: 5 toolbelt: true +tool_type: pickaxe +tool_speed: 2 diff --git a/data/items/copper_ore.yaml b/data/items/copper_ore.yaml index 5f11316..7192b92 100644 --- a/data/items/copper_ore.yaml +++ b/data/items/copper_ore.yaml @@ -1,6 +1,6 @@ -id: "copper_ore" -name: "copper ore" -aliases: ["copper", "ore"] -description: "A chunk of copper ore." -value: 3 -stackable: false +id: copper_ore +name: copper ore +aliases: ["ore", "copper"] +description: "A chunk of copper-bearing rock. Can be smelted into a bronze bar." +value: 1 +stackable: true diff --git a/data/items/fishing_rod.yaml b/data/items/fishing_rod.yaml new file mode 100644 index 0000000..5441ba6 --- /dev/null +++ b/data/items/fishing_rod.yaml @@ -0,0 +1,9 @@ +id: fishing_rod +name: fishing rod +aliases: ["rod", "fishing"] +description: "A simple fishing rod with a hook and line." +value: 5 +stackable: false +toolbelt: true +tool_type: fishing_rod +tool_speed: 0 diff --git a/data/items/pass_stub.yaml b/data/items/pass_stub.yaml new file mode 100644 index 0000000..a7456ef --- /dev/null +++ b/data/items/pass_stub.yaml @@ -0,0 +1,5 @@ +id: pass_stub +name: pass stub +description: "A crumpled slip of paper stamped with the guard's seal. Grants one-time access past the gate." +value: 0 +stackable: false diff --git a/data/items/raw_trout.yaml b/data/items/raw_trout.yaml new file mode 100644 index 0000000..2a44c72 --- /dev/null +++ b/data/items/raw_trout.yaml @@ -0,0 +1,6 @@ +id: raw_trout +name: raw trout +aliases: ["trout", "fish", "raw"] +description: "A freshly caught trout. Needs cooking." +value: 3 +stackable: true diff --git a/data/items/uncut_diamond.yaml b/data/items/uncut_diamond.yaml new file mode 100644 index 0000000..3dcea70 --- /dev/null +++ b/data/items/uncut_diamond.yaml @@ -0,0 +1,6 @@ +id: uncut_diamond +name: uncut diamond +aliases: ["diamond", "gem", "uncut"] +description: "An uncut diamond. Can be cut with crafting." +value: 200 +stackable: true diff --git a/data/items/uncut_emerald.yaml b/data/items/uncut_emerald.yaml new file mode 100644 index 0000000..a9f3a66 --- /dev/null +++ b/data/items/uncut_emerald.yaml @@ -0,0 +1,6 @@ +id: uncut_emerald +name: uncut emerald +aliases: ["emerald", "gem", "uncut"] +description: "An uncut emerald. Can be cut with crafting." +value: 50 +stackable: true diff --git a/data/items/uncut_ruby.yaml b/data/items/uncut_ruby.yaml new file mode 100644 index 0000000..0e9d36d --- /dev/null +++ b/data/items/uncut_ruby.yaml @@ -0,0 +1,6 @@ +id: uncut_ruby +name: uncut ruby +aliases: ["ruby", "gem", "uncut"] +description: "An uncut ruby. Can be cut with crafting." +value: 100 +stackable: true diff --git a/data/items/uncut_sapphire.yaml b/data/items/uncut_sapphire.yaml new file mode 100644 index 0000000..70039d8 --- /dev/null +++ b/data/items/uncut_sapphire.yaml @@ -0,0 +1,6 @@ +id: uncut_sapphire +name: uncut sapphire +aliases: ["sapphire", "gem", "uncut"] +description: "An uncut sapphire. Can be cut with crafting." +value: 25 +stackable: true diff --git a/data/mobs/guard.yaml b/data/mobs/guard.yaml new file mode 100644 index 0000000..3203ec1 --- /dev/null +++ b/data/mobs/guard.yaml @@ -0,0 +1,18 @@ +id: "guard" +name: "Guard" +description: "A stern-looking guard in weathered armor." +behavior: guard_talk +unique: true +protected: true +attack: 5 +strength: 5 +defense: 5 +hp: 30 +speed: 5 +aggressive: false +respawn_ticks: 60 +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" diff --git a/data/mobs/man.yaml b/data/mobs/man.yaml index 79de657..30a3105 100644 --- a/data/mobs/man.yaml +++ b/data/mobs/man.yaml @@ -21,9 +21,8 @@ hp: 7 speed: 5 aggressive: false respawn_ticks: 30 -wander: 3 -wander_tick: 10 -wander_chance: 0.25 +wander_rooms: [1, 2, 3] +wander_interval: 10 drops: remains: "bones" loot: diff --git a/data/objects/copper_rock.yaml b/data/objects/copper_rock.yaml new file mode 100644 index 0000000..c16bc53 --- /dev/null +++ b/data/objects/copper_rock.yaml @@ -0,0 +1,3 @@ +id: copper_rock +name: copper rock +behavior: mine_copper diff --git a/data/objects/copper_rock_1.yaml b/data/objects/copper_rock_1.yaml deleted file mode 100644 index 257601a..0000000 --- a/data/objects/copper_rock_1.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: "copper_rock_1" -name: "copper rock" -behavior: "rock" -props: - skill: "mining" - level: 1 - yields: "copper_ore" - respawn_ticks: 30 diff --git a/data/objects/copper_rock_2.yaml b/data/objects/copper_rock_2.yaml deleted file mode 100644 index 5805398..0000000 --- a/data/objects/copper_rock_2.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: "copper_rock_2" -name: "copper rock" -behavior: "rock" -props: - skill: "mining" - level: 1 - yields: "copper_ore" - respawn_ticks: 30 diff --git a/data/objects/fishing_spot.yaml b/data/objects/fishing_spot.yaml new file mode 100644 index 0000000..73ad3a2 --- /dev/null +++ b/data/objects/fishing_spot.yaml @@ -0,0 +1,3 @@ +id: fishing_spot +name: fishing spot +behavior: fish_trout diff --git a/data/objects/iron_gate.yaml b/data/objects/iron_gate.yaml new file mode 100644 index 0000000..bd7eb0d --- /dev/null +++ b/data/objects/iron_gate.yaml @@ -0,0 +1,5 @@ +id: iron_gate +name: iron gate +behavior: iron_gate_toggle +props: + description: "A heavy iron gate. It looks like it can be pushed open." diff --git a/data/objects/lumby_fountain.yaml b/data/objects/lumby_fountain.yaml index 4d877c1..a6a5d1a 100644 --- a/data/objects/lumby_fountain.yaml +++ b/data/objects/lumby_fountain.yaml @@ -1,5 +1,5 @@ -id: "lumby_fountain" -name: "town fountain" -behavior: "none" +id: lumby_fountain +name: town fountain +behavior: "" props: description: "Clear water sparkles in the sunlight." diff --git a/data/objects/oak_tree_1.yaml b/data/objects/oak_tree_1.yaml deleted file mode 100644 index a7eb331..0000000 --- a/data/objects/oak_tree_1.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: "oak_tree_1" -name: "oak tree" -behavior: "tree" -props: - skill: "woodcutting" - level: 15 - yields: "oak_logs" - respawn_ticks: 50 diff --git a/data/objects/oak_tree_2.yaml b/data/objects/oak_tree_2.yaml deleted file mode 100644 index 7f9f9a4..0000000 --- a/data/objects/oak_tree_2.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: "oak_tree_2" -name: "oak tree" -behavior: "tree" -props: - skill: "woodcutting" - level: 15 - yields: "oak_logs" - respawn_ticks: 50 diff --git a/data/objects/oak_tree_3.yaml b/data/objects/oak_tree_3.yaml deleted file mode 100644 index ac40380..0000000 --- a/data/objects/oak_tree_3.yaml +++ /dev/null @@ -1,8 +0,0 @@ -id: "oak_tree_3" -name: "oak tree" -behavior: "tree" -props: - skill: "woodcutting" - level: 15 - yields: "oak_logs" - respawn_ticks: 50 diff --git a/data/players/accounts/ad.yaml b/data/players/accounts/ad.yaml new file mode 100644 index 0000000..63ba445 --- /dev/null +++ b/data/players/accounts/ad.yaml @@ -0,0 +1,4 @@ +name: ad +password_hash: sha256:lUcF6TakL84+ULEQvs/hGg:af878475057cc72b964120b80a2767c7ff73c86c7828515ef200e2030909da48 +characters: + - ad diff --git a/data/players/accounts/be.yaml b/data/players/accounts/be.yaml new file mode 100644 index 0000000..5438be8 --- /dev/null +++ b/data/players/accounts/be.yaml @@ -0,0 +1,4 @@ +name: be +password_hash: sha256:mIampXXmSVbClzM7QvgnRw:c98405d73de4ca491ee8afe81cf7cbaf91ecc7918579cd143ee00f5e410e85ad +characters: + - be diff --git a/data/players/accounts/eo.yaml b/data/players/accounts/eo.yaml new file mode 100644 index 0000000..833f320 --- /dev/null +++ b/data/players/accounts/eo.yaml @@ -0,0 +1,4 @@ +name: eo +password_hash: sha256:CQY8xHGy9LDWG1XXHzaqfw:1265c9416dd6ac8e59c63255026f2dcb3a1cc2e57e88eb0fcefc08616a4717df +characters: + - eo diff --git a/data/players/accounts/ew.yaml b/data/players/accounts/ew.yaml new file mode 100644 index 0000000..605f274 --- /dev/null +++ b/data/players/accounts/ew.yaml @@ -0,0 +1,4 @@ +name: ew +password_hash: sha256:NIhNFQ2Wote/Yqsb50sh6A:35ddcf86eaf6c73d8b12dab98cb5a77d8d774fe98c4e8bd09acebfdefd5bb61b +characters: + - ew diff --git a/data/players/accounts/fd.yaml b/data/players/accounts/fd.yaml new file mode 100644 index 0000000..27b122d --- /dev/null +++ b/data/players/accounts/fd.yaml @@ -0,0 +1,4 @@ +name: fd +password_hash: sha256:Exyt0ZzDPmssGVmioFr+0g:4a8008f611287add62ed54f98bc160c391482f93b838e4f27cef31afb7b373ca +characters: + - fd diff --git a/data/players/accounts/he.yaml b/data/players/accounts/he.yaml new file mode 100644 index 0000000..7b47938 --- /dev/null +++ b/data/players/accounts/he.yaml @@ -0,0 +1,4 @@ +name: he +password_hash: sha256:h9wYpy+8gSxq3vWVXES9Yg:6641ad5fc0dc689966e90339a99bb5ea8b41f50a6628616c25c652456a069a82 +characters: + - he diff --git a/data/players/accounts/ir.yaml b/data/players/accounts/ir.yaml new file mode 100644 index 0000000..51f344f --- /dev/null +++ b/data/players/accounts/ir.yaml @@ -0,0 +1,4 @@ +name: ir +password_hash: sha256:PNdb6J5QxC+9O1nQ29PNPg:2410a406393f0e40df95f26b4352d13d4715fe38a86938ef21094b9dcd6c3077 +characters: + - ir diff --git a/data/players/accounts/je.yaml b/data/players/accounts/je.yaml new file mode 100644 index 0000000..d711808 --- /dev/null +++ b/data/players/accounts/je.yaml @@ -0,0 +1,4 @@ +name: je +password_hash: sha256:kfeyEgiY3+7oZVgPJlaofA:a3af89e832ef04e7e40b85222dc574b761e40143081ad900f2cbb71fec1a201d +characters: + - je diff --git a/data/players/accounts/me.yaml b/data/players/accounts/me.yaml new file mode 100644 index 0000000..a8ad8cf --- /dev/null +++ b/data/players/accounts/me.yaml @@ -0,0 +1,4 @@ +name: me +password_hash: sha256:YPIaCMkVxA58zZFkm9c76g:32a2a015d5dda3aad3e4866a46e7a31fa32993f1a4e53bc239787a795b8d9360 +characters: + - me diff --git a/data/players/accounts/mo.yaml b/data/players/accounts/mo.yaml new file mode 100644 index 0000000..7591d69 --- /dev/null +++ b/data/players/accounts/mo.yaml @@ -0,0 +1,4 @@ +name: mo +password_hash: sha256:HRAgvc/hWu3nG/jawjdREA:573246a38c6000b7431ba22f67b3d4bbfee4b625b4a572516dfd4de3d6400b80 +characters: + - mo diff --git a/data/players/accounts/nc.yaml b/data/players/accounts/nc.yaml new file mode 100644 index 0000000..65eb3dd --- /dev/null +++ b/data/players/accounts/nc.yaml @@ -0,0 +1,4 @@ +name: nc +password_hash: sha256:OMTkIHWVnQKJHAS+G3+Dmg:5c61f310d54f7e9ae6777d41cceaa807c735ad5fc2f7b8359df6f55d083b805b +characters: + - nc diff --git a/data/players/accounts/oe.yaml b/data/players/accounts/oe.yaml new file mode 100644 index 0000000..98ed473 --- /dev/null +++ b/data/players/accounts/oe.yaml @@ -0,0 +1,5 @@ +name: oe +password_hash: sha256:4ERL7R5Mws8xZ2lj2h8sbg:afed13590689dd81fc07bf8c4a8c179f5ce655cb528a9742493df6507f77e333 +characters: + - oe + - oer diff --git a/data/players/accounts/ow.yaml b/data/players/accounts/ow.yaml new file mode 100644 index 0000000..8ace8ae --- /dev/null +++ b/data/players/accounts/ow.yaml @@ -0,0 +1,4 @@ +name: ow +password_hash: sha256:HT4lN3B4cuUwyNT2s4gUJA:4d93885806eec85b33861164662b13d9548ca1d1394fe35f7a1ecf7ff876c0fd +characters: + - ow diff --git a/data/players/accounts/re.yaml b/data/players/accounts/re.yaml new file mode 100644 index 0000000..c79f98d --- /dev/null +++ b/data/players/accounts/re.yaml @@ -0,0 +1,4 @@ +name: re +password_hash: sha256:XEX82NiVAMfhK6T0j3snuw:48f795b41fee684cdcc4f9a932efb340a959a7d40ff273750ade735f309ff40b +characters: + - re diff --git a/data/players/accounts/wor.yaml b/data/players/accounts/wor.yaml new file mode 100644 index 0000000..b75e5d7 --- /dev/null +++ b/data/players/accounts/wor.yaml @@ -0,0 +1,4 @@ +name: wor +password_hash: sha256:esQph9Mzbsk51T5jzzpVoQ:a26a8f37e05dea433cb9fad710334ac85bf3daf2a8ce55fb2fc8d33d8abfca02 +characters: + - wor diff --git a/data/players/characters/ad.yaml b/data/players/characters/ad.yaml new file mode 100644 index 0000000..9bbaec2 --- /dev/null +++ b/data/players/characters/ad.yaml @@ -0,0 +1,59 @@ +name: ad +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 + 2: + item_id: copper_ore + quantity: 1 + 3: + item_id: copper_ore + quantity: 1 + 4: + item_id: copper_ore + quantity: 1 + 5: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/be.yaml b/data/players/characters/be.yaml new file mode 100644 index 0000000..e00fe49 --- /dev/null +++ b/data/players/characters/be.yaml @@ -0,0 +1,44 @@ +name: be +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: fishing_rod + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 8 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/eo.yaml b/data/players/characters/eo.yaml new file mode 100644 index 0000000..c258c4d --- /dev/null +++ b/data/players/characters/eo.yaml @@ -0,0 +1,50 @@ +name: eo +skills: + agility: 0 + alchemy: 0 + attack: 21 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1161 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: bronze_pickaxe + quantity: 1 + 2: + item_id: bones + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 7 +credits: 10 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 68 diff --git a/data/players/characters/ew.yaml b/data/players/characters/ew.yaml new file mode 100644 index 0000000..6169e00 --- /dev/null +++ b/data/players/characters/ew.yaml @@ -0,0 +1,44 @@ +name: ew +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/fd.yaml b/data/players/characters/fd.yaml new file mode 100644 index 0000000..5558d9b --- /dev/null +++ b/data/players/characters/fd.yaml @@ -0,0 +1,44 @@ +name: fd +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/he.yaml b/data/players/characters/he.yaml new file mode 100644 index 0000000..e36c8d2 --- /dev/null +++ b/data/players/characters/he.yaml @@ -0,0 +1,41 @@ +name: he +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: {} +equipment: {} +toolbelt: [] +room_id: 8 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/ir.yaml b/data/players/characters/ir.yaml new file mode 100644 index 0000000..95da9ca --- /dev/null +++ b/data/players/characters/ir.yaml @@ -0,0 +1,53 @@ +name: ir +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 + 2: + item_id: copper_ore + quantity: 1 + 3: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/je.yaml b/data/players/characters/je.yaml new file mode 100644 index 0000000..2816d76 --- /dev/null +++ b/data/players/characters/je.yaml @@ -0,0 +1,50 @@ +name: je +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: pass_stub + quantity: 1 + 2: + item_id: uncut_sapphire + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 10 +hp: 10 +credits: 50 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/me.yaml b/data/players/characters/me.yaml new file mode 100644 index 0000000..ef5d9b0 --- /dev/null +++ b/data/players/characters/me.yaml @@ -0,0 +1,41 @@ +name: me +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: {} +equipment: {} +toolbelt: [] +room_id: 8 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/mo.yaml b/data/players/characters/mo.yaml new file mode 100644 index 0000000..d9139c0 --- /dev/null +++ b/data/players/characters/mo.yaml @@ -0,0 +1,53 @@ +name: mo +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 + 2: + item_id: copper_ore + quantity: 1 + 3: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/nc.yaml b/data/players/characters/nc.yaml new file mode 100644 index 0000000..13af64e --- /dev/null +++ b/data/players/characters/nc.yaml @@ -0,0 +1,56 @@ +name: nc +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 + 2: + item_id: copper_ore + quantity: 1 + 3: + item_id: copper_ore + quantity: 1 + 4: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/oe.yaml b/data/players/characters/oe.yaml new file mode 100644 index 0000000..d54feca --- /dev/null +++ b/data/players/characters/oe.yaml @@ -0,0 +1,47 @@ +name: oe +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/oer.yaml b/data/players/characters/oer.yaml new file mode 100644 index 0000000..246ae02 --- /dev/null +++ b/data/players/characters/oer.yaml @@ -0,0 +1,95 @@ +name: oer +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 + 2: + item_id: copper_ore + quantity: 1 + 3: + item_id: copper_ore + quantity: 1 + 4: + item_id: copper_ore + quantity: 1 + 5: + item_id: copper_ore + quantity: 1 + 6: + item_id: copper_ore + quantity: 1 + 7: + item_id: copper_ore + quantity: 1 + 8: + item_id: copper_ore + quantity: 1 + 9: + item_id: copper_ore + quantity: 1 + 10: + item_id: copper_ore + quantity: 1 + 11: + item_id: copper_ore + quantity: 1 + 12: + item_id: copper_ore + quantity: 1 + 13: + item_id: copper_ore + quantity: 1 + 14: + item_id: copper_ore + quantity: 1 + 15: + item_id: copper_ore + quantity: 1 + 16: + item_id: copper_ore + quantity: 1 + 17: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/ow.yaml b/data/players/characters/ow.yaml new file mode 100644 index 0000000..32c03c5 --- /dev/null +++ b/data/players/characters/ow.yaml @@ -0,0 +1,47 @@ +name: ow +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 + 1: + item_id: copper_ore + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/re.yaml b/data/players/characters/re.yaml new file mode 100644 index 0000000..819f3b5 --- /dev/null +++ b/data/players/characters/re.yaml @@ -0,0 +1,44 @@ +name: re +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: bronze_pickaxe + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 2 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/players/characters/wor.yaml b/data/players/characters/wor.yaml new file mode 100644 index 0000000..687c8d9 --- /dev/null +++ b/data/players/characters/wor.yaml @@ -0,0 +1,50 @@ +name: wor +skills: + agility: 0 + alchemy: 0 + attack: 0 + construction: 0 + cooking: 0 + crafting: 0 + defense: 0 + fishing: 0 + fletching: 0 + hitpoints: 1154 + hunter: 0 + mining: 0 + ranged: 0 + scavenging: 0 + science: 0 + slayer: 0 + smithing: 0 + strength: 0 + technology: 0 + thieving: 0 + woodcutting: 0 +inventory: + 0: + item_id: fishing_rod + quantity: 1 + 1: + item_id: raw_trout + quantity: 1 + 2: + item_id: raw_trout + quantity: 1 +equipment: {} +toolbelt: [] +room_id: 9 +hp: 10 +credits: 0 +description: "" +attack_style: accurate +toggles: + description: true + exits: true + mobenter: true + mobleave: true + mobspawn: true + reserve: true + tinymap: true + xpdrops: true +regeneratetick: 0 diff --git a/data/rooms/1.yaml b/data/rooms/1.yaml index 97615a6..2f30f7e 100644 --- a/data/rooms/1.yaml +++ b/data/rooms/1.yaml @@ -4,9 +4,10 @@ description: "A bustling town square in the heart of Lumbridge. Cobblestone path map_symbol: "S" exits: north: 2 + west: 7 east: 3 objects: - - "lumby_fountain" + - id: lumby_fountain spawns: - item_id: "copper_ore" quantity: 1 diff --git a/data/rooms/10.yaml b/data/rooms/10.yaml new file mode 100644 index 0000000..99e84eb --- /dev/null +++ b/data/rooms/10.yaml @@ -0,0 +1,20 @@ +id: 10 +name: "Guard Post" +description: "A checkpoint at the edge of the mine. A guard stands watch beside a heavy iron gate leading north." +exits: + west: 2 + north: + room: 11 + condition: + flag: gate_open + value: true + blocked_message: "A heavy iron gate blocks the way north. Maybe the guard can help." +objects: + - id: iron_gate +mobs: + - "guard" +on_enter: + - message: "The Guard barks: \"State your business!\"" + condition: + flag: talked_to_guard + not: true diff --git a/data/rooms/11.yaml b/data/rooms/11.yaml new file mode 100644 index 0000000..dcc3b05 --- /dev/null +++ b/data/rooms/11.yaml @@ -0,0 +1,17 @@ +id: 11 +name: "Supply Room" +description: "A dusty storage room filled with crates and barrels. This area is normally off-limits." +exits: + south: + room: 10 + condition: + flag: gate_open + value: true + blocked_message: "The iron gate is closed." +spawns: + - item_id: uncut_sapphire + quantity: 1 + respawn_ticks: 100 + - item_id: credits + quantity: 50 + respawn_ticks: 200 diff --git a/data/rooms/2.yaml b/data/rooms/2.yaml index 4b1b5f8..89913f7 100644 --- a/data/rooms/2.yaml +++ b/data/rooms/2.yaml @@ -1,8 +1,14 @@ id: 2 -name: "North Lumbridge Road" -description: "A dusty road leading north out of town. To the south you see the town square. A small mine entrance is carved into a hillside to the west." -map_symbol: "R" +name: "Copper Mine" +description: "A small mining pit. The walls glint with copper veins." exits: south: 1 - north: 4 - west: 6 + east: 10 +objects: + - id: copper_rock + - id: copper_rock + - id: copper_rock +spawns: + - item_id: bronze_pickaxe + quantity: 1 + respawn_ticks: 30 diff --git a/data/rooms/4.yaml b/data/rooms/4.yaml index ab25de1..49280fe 100644 --- a/data/rooms/4.yaml +++ b/data/rooms/4.yaml @@ -5,9 +5,3 @@ map_symbol: "F" exits: south: 2 north: 5 -objects: - - "oak_tree_1" -spawns: - - item_id: "oak_logs" - quantity: 1 - respawn_ticks: 10 diff --git a/data/rooms/5.yaml b/data/rooms/5.yaml index 097a134..3c16a41 100644 --- a/data/rooms/5.yaml +++ b/data/rooms/5.yaml @@ -4,6 +4,3 @@ description: "Thick trees surround you on all sides. Shafts of light pierce thro map_symbol: "T" exits: south: 4 -objects: - - "oak_tree_2" - - "oak_tree_3" diff --git a/data/rooms/6.yaml b/data/rooms/6.yaml index 7b5d4b4..b4bae5b 100644 --- a/data/rooms/6.yaml +++ b/data/rooms/6.yaml @@ -4,13 +4,3 @@ description: "A dark, dusty mine shaft. Veins of copper and tin run through the map_symbol: "M" exits: west: 2 -objects: - - "copper_rock_1" - - "copper_rock_2" -spawns: - - item_id: "copper_ore" - quantity: 1 - respawn_ticks: 10 - - item_id: "tin_ore" - quantity: 1 - respawn_ticks: 10 diff --git a/data/rooms/7.yaml b/data/rooms/7.yaml new file mode 100644 index 0000000..f9168ec --- /dev/null +++ b/data/rooms/7.yaml @@ -0,0 +1,6 @@ +id: 7 +name: "Lake West Shore" +description: "The western edge of a placid lake. Reeds rustle in the breeze." +exits: + east: 8 + south: 1 diff --git a/data/rooms/8.yaml b/data/rooms/8.yaml new file mode 100644 index 0000000..46e29f3 --- /dev/null +++ b/data/rooms/8.yaml @@ -0,0 +1,14 @@ +id: 8 +name: "Lake Center Shore" +description: "The center of the lake's southern shore. A good spot to cast a line." +exits: + west: 7 + east: 9 +objects: + - id: fishing_spot + wander_rooms: [7, 8, 9] + wander_interval: 12 +spawns: + - item_id: fishing_rod + quantity: 1 + respawn_ticks: 30 diff --git a/data/rooms/9.yaml b/data/rooms/9.yaml new file mode 100644 index 0000000..b5ea278 --- /dev/null +++ b/data/rooms/9.yaml @@ -0,0 +1,5 @@ +id: 9 +name: "Lake East Shore" +description: "The eastern shore of the lake. A fallen log offers a comfortable seat." +exits: + west: 8 diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..9cd4c0f --- /dev/null +++ b/internal/action/action.go @@ -0,0 +1,32 @@ +package action + +type Action struct { + Type string + TargetID string + TargetName string + Step int + WaitLeft int + Data map[string]any +} + +func (a *Action) Advance() bool { + if a.WaitLeft > 0 { + a.WaitLeft-- + return false + } + return true +} + +type DropEntry struct { + ItemID string `yaml:"item_id"` + Table string `yaml:"table"` + Weight int `yaml:"weight"` + Quantity int `yaml:"quantity"` + Depletes bool `yaml:"depletes"` + Message string `yaml:"message"` +} + +type DropTableDef struct { + ID string `yaml:"id"` + Drops []DropEntry `yaml:"drops"` +} diff --git a/internal/action/behavior.go b/internal/action/behavior.go new file mode 100644 index 0000000..d7f683a --- /dev/null +++ b/internal/action/behavior.go @@ -0,0 +1,68 @@ +package action + +type GatherConfig struct { + Skill string `yaml:"skill"` + Level int `yaml:"level"` + BaseWait int `yaml:"base_wait"` + Tool string `yaml:"tool"` + Success SuccessFormula `yaml:"success"` + GatherMsg string `yaml:"gather_message"` + FailMsg string `yaml:"fail_message"` + Drops []DropEntry `yaml:"drops"` + DepleteDelay int `yaml:"deplete_delay"` + RespawnMsg string `yaml:"respawn_message"` + RespawnBroadcast string `yaml:"respawn_broadcast"` +} + +type SuccessFormula struct { + Base float64 `yaml:"base"` + PerLevel float64 `yaml:"per_level"` + Cap float64 `yaml:"cap"` +} + +type TalkConfig struct { + Nodes map[string]TalkNode `yaml:"nodes"` +} + +type TalkNode struct { + Message string `yaml:"message"` + Options []TalkOption `yaml:"options"` + Action *NodeAction `yaml:"action"` +} + +type TalkOption struct { + Text string `yaml:"text"` + Goto string `yaml:"goto"` + End bool `yaml:"end"` + Condition *Condition `yaml:"condition"` +} + +type NodeAction struct { + SetFlags map[string]any `yaml:"set_flags"` + GiveItem string `yaml:"give_item"` + TakeItem string `yaml:"take_item"` +} + +type Condition struct { + Flag string `yaml:"flag"` + Value any `yaml:"value"` + Not bool `yaml:"not"` + HasItem string `yaml:"has_item"` +} + +type UseConfig struct { + Message string `yaml:"message"` + Wait int `yaml:"wait"` + Consume map[string]int `yaml:"consume"` + Reward DropEntry `yaml:"reward"` + FailMsg string `yaml:"fail_message"` + Success *SuccessFormula `yaml:"success"` + Skill string `yaml:"skill"` + Level int `yaml:"level"` +} + +type ToggleConfig struct { + Message string `yaml:"message"` + SetFlags map[string]any `yaml:"set_flags"` + Check *Condition `yaml:"check"` +} diff --git a/internal/action/store.go b/internal/action/store.go new file mode 100644 index 0000000..fd58281 --- /dev/null +++ b/internal/action/store.go @@ -0,0 +1,174 @@ +package action + +import ( + "fmt" + "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 { + chance = cfg.Cap + } + if chance < 0 { + chance = 0 + } + return chance +} + +func (s *Store) LoadDropTable(id string) (*DropTableDef, error) { + path := filepath.Join(s.dataDir, "drops", id+".yaml") + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read drop table %s: %w", id, err) + } + var dt DropTableDef + if err := yaml.Unmarshal(data, &dt); err != nil { + return nil, fmt.Errorf("parse drop table %s: %w", id, err) + } + return &dt, nil +} + +func (s *Store) ResolveDrop(drops []DropEntry) *DropEntry { + if len(drops) == 0 { + return nil + } + total := 0 + for _, d := range drops { + total += d.Weight + } + if total <= 0 { + return nil + } + roll := rand.Intn(total) + cumulative := 0 + for i := range drops { + cumulative += drops[i].Weight + if roll < cumulative { + if drops[i].Table != "" { + sub, err := s.LoadDropTable(drops[i].Table) + if err == nil { + if resolved := s.ResolveDrop(sub.Drops); resolved != nil { + qty := drops[i].Quantity + if qty <= 0 { + qty = resolved.Quantity + } + if qty <= 0 { + qty = 1 + } + return &DropEntry{ + ItemID: resolved.ItemID, + Quantity: qty, + Depletes: drops[i].Depletes, + Message: drops[i].Message, + } + } + } + return nil + } + return &drops[i] + } + } + return &drops[0] +} diff --git a/internal/cmd/registry.go b/internal/cmd/registry.go deleted file mode 100644 index e056469..0000000 --- a/internal/cmd/registry.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" -) - -type Handler func(args []string) - -type Command struct { - Name string - Aliases []string - Handler Handler - MinArgs int -} - -type Registry struct { - cmds map[string]Command -} - -func NewRegistry() *Registry { - return &Registry{cmds: make(map[string]Command)} -} - -func (r *Registry) Register(cmd Command) { - r.cmds[cmd.Name] = cmd - for _, a := range cmd.Aliases { - r.cmds[a] = cmd - } -} - -func (r *Registry) Dispatch(input string) error { - parts := strings.Fields(input) - if len(parts) == 0 { - return nil - } - name := strings.ToLower(parts[0]) - cmd, ok := r.cmds[name] - if !ok { - return fmt.Errorf("unknown command: %s", name) - } - args := parts[1:] - if len(args) < cmd.MinArgs { - return fmt.Errorf("usage: %s requires at least %d arguments", cmd.Name, cmd.MinArgs) - } - cmd.Handler(args) - return nil -} diff --git a/internal/combat/state.go b/internal/combat/state.go index 0d8de45..43b693b 100644 --- a/internal/combat/state.go +++ b/internal/combat/state.go @@ -3,12 +3,10 @@ package combat import "sync" type State struct { - PlayerName string - MobID string - MobAttacks int // attacks mob has made (3-hit flee rule) - PlayerDamage int // total damage player dealt this combat - Active bool - LockedTicks int // ticks remaining before player can move + PlayerName string + MobID string + Active bool + LockedTicks int } var ( @@ -59,32 +57,6 @@ func GetMobTarget(mobID string) string { return mobTargets[mobID] } -func RecordMobAttack(playerName string) { - mu.Lock() - defer mu.Unlock() - if state, ok := combatants[playerName]; ok { - state.MobAttacks++ - } -} - -func CanFlee(playerName string) bool { - mu.Lock() - defer mu.Unlock() - state, ok := combatants[playerName] - if !ok { - return true - } - return state.MobAttacks >= 3 -} - -func RecordPlayerDamage(playerName string, dmg int) { - mu.Lock() - defer mu.Unlock() - if state, ok := combatants[playerName]; ok { - state.PlayerDamage += dmg - } -} - func TickCombat() { mu.Lock() defer mu.Unlock() @@ -95,13 +67,4 @@ func TickCombat() { } } -func GetTotalDamage(playerName string) int { - mu.Lock() - defer mu.Unlock() - if state, ok := combatants[playerName]; ok { - d := state.PlayerDamage - state.PlayerDamage = 0 - return d - } - return 0 -} + diff --git a/internal/game/action.go b/internal/game/action.go new file mode 100644 index 0000000..029fec3 --- /dev/null +++ b/internal/game/action.go @@ -0,0 +1,765 @@ +package game + +import ( + "fmt" + "math/rand" + "sort" + "strconv" + "strings" + + "thirdcollapse/internal/action" + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +func (g *Game) StartAction(sess *net.Session, verb, target string) { + p := sess.Player.(*player.Player) + + verb = normalizeVerb(verb) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't do that during combat!") + return + } + + if p.Action != nil { + g.CancelAction(p) + } + + lower := strings.ToLower(target) + instanceIdx := -1 + if dotPos := strings.Index(lower, "."); dotPos > 0 { + if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { + instanceIdx = n - 1 + lower = lower[dotPos+1:] + } + } + + instances := g.World.FindObjInstances(p.RoomID, lower) + sort.Slice(instances, func(i, j int) bool { + return instances[i].Index < instances[j].Index + }) + + var obj *object.ObjectDef + var mob *world.MobInstance + var behaviorID string + + if len(instances) > 0 { + var chosen *world.ObjState + if instanceIdx >= 0 && instanceIdx < len(instances) { + chosen = &instances[instanceIdx] + } else { + for i := range instances { + if !instances[i].Depleted { + chosen = &instances[i] + break + } + } + if chosen == nil { + chosen = &instances[0] + } + } + if chosen == nil { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + var err error + obj, err = g.ObjectStore.Load(chosen.DefID) + if err != nil || obj.BehaviorID == "" { + 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 == "" { + continue + } + if q := m.MatchQuality(lower); q >= world.MatchPrefix { + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].InstanceID < candidates[j].InstanceID + }) + if instanceIdx >= 0 && instanceIdx < len(candidates) { + mob = candidates[instanceIdx] + } else if len(candidates) == 1 { + mob = candidates[0] + } else { + 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 bh.Type != verb { + if obj != nil { + sess.WriteLine(fmt.Sprintf("You can't %s the %s.", verb, obj.Name)) + } else { + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + } + return + } + + switch bh.Type { + case "gather": + if obj == nil { + sess.WriteLine(fmt.Sprintf("You can't %s that.", verb)) + return + } + // Find the actual ObjState for the chosen object + chosenObj := g.World.FindObjInstances(p.RoomID, obj.ID) + sort.Slice(chosenObj, func(i, j int) bool { + return chosenObj[i].Index < chosenObj[j].Index + }) + var st *world.ObjState + for i := range chosenObj { + if !chosenObj[i].Depleted { + st = &chosenObj[i] + break + } + } + if st == nil && len(chosenObj) > 0 { + st = &chosenObj[0] + } + if st == nil { + sess.WriteLine(fmt.Sprintf("There's nothing here to %s.", verb)) + return + } + g.startGather(sess, p, obj, st) + case "talk": + if mob != nil { + g.startMobTalk(sess, p, mob) + } else { + g.startTalk(sess, p, obj) + } + 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) { + p.Action = nil +} + +func (g *Game) AdvanceActions() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok || p.Action == nil { + continue + } + if !p.Action.Advance() { + continue + } + switch p.Action.Type { + case "gather": + g.advanceGather(sess, p) + case "use": + g.advanceUse(sess, p) + } + } +} + +func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.ObjectDef, st *world.ObjState) { + cfg, err := g.BehaviorStore.LoadGather(obj.BehaviorID) + if err != nil { + sess.WriteLine("Something is wrong with this object.") + return + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + if cfg.Level > 0 && skillLevel < cfg.Level { + sess.WriteLine(fmt.Sprintf("You need level %d %s to do that.", cfg.Level, cfg.Skill)) + return + } + + if st.Depleted { + sess.WriteLine(fmt.Sprintf("The %s is depleted for %d more ticks.", obj.Name, st.DepleteTimer)) + return + } + + wait := cfg.BaseWait + + if cfg.Tool != "" { + bestSpeed := -1 + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed { + bestSpeed = def.ToolSpeed + } + } + for _, itemID := range p.Toolbelt { + def, err := g.ItemStore.Load(itemID) + if err == nil && def.ToolType == cfg.Tool && def.ToolSpeed > bestSpeed { + bestSpeed = def.ToolSpeed + } + } + if bestSpeed < 0 { + sess.WriteLine(fmt.Sprintf("You need a %s to mine the %s.", cfg.Tool, obj.Name)) + return + } + wait = cfg.BaseWait - bestSpeed + if wait < 1 { + wait = 1 + } + } + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + return + } + + p.Action = &action.Action{ + Type: "gather", + TargetID: st.DefID, + TargetName: obj.Name, + WaitLeft: 0, + Data: map[string]any{ + "behavior_id": obj.BehaviorID, + "instance_key": g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), + "instance_idx": st.Index + 1, + "effective_wait": wait, + "step": 0, + }, + } +} + +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) + return + } + + step := p.Action.Data["step"].(int) + wait := p.Action.Data["effective_wait"].(int) + instanceKey := p.Action.Data["instance_key"].(string) + + if step == 0 { + sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg)) + p.Action.Data["step"] = 1 + p.Action.WaitLeft = wait + return + } + + if step == 2 { + st := g.World.GetObjStateByKey(instanceKey) + if st != nil && st.Depleted { + p.Action.WaitLeft = 3 + return + } + if cfg.RespawnMsg != "" { + sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg)) + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait + return + } + + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := action.SuccessChance(cfg.Success, skillLevel, cfg.Level) + + if rand.Float64() < chance { + drop := g.BehaviorStore.ResolveDrop(cfg.Drops) + if drop != nil { + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + + qty := drop.Quantity + if qty <= 0 { + qty = 1 + } + + itemName := drop.ItemID + if def, err := g.ItemStore.Load(drop.ItemID); err == nil { + itemName = def.Name + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty}) + + msg := drop.Message + if msg == "" { + msg = fmt.Sprintf("You manage to get some %s.", itemName) + } + sess.WriteLine(msg) + + g.AccountStore.SaveCharacter(p) + + if drop.Depletes { + delay := cfg.DepleteDelay + if delay <= 0 { + delay = 10 + } + st := g.World.GetObjStateByKey(instanceKey) + if st != nil { + st.Depleted = true + st.DepleteTimer = 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!", p.Action.TargetName, p.Name)) + g.CancelAction(op) + } + } + + p.Action.Data["step"] = 2 + p.Action.WaitLeft = 1 + return + } + + // Non-depleting drop: loop back. Check inventory before looping. + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait + return + } + } + + // Failed: show fail message, loop back + sess.WriteLine(cfg.FailMsg) + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is too full!") + g.CancelAction(p) + return + } + p.Action.Data["step"] = 0 + p.Action.WaitLeft = wait +} + +func (g *Game) startMobTalk(sess *net.Session, p *player.Player, mob *world.MobInstance) { + cfg, err := g.BehaviorStore.LoadTalk(mob.BehaviorID) + if err != nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &action.Action{ + Type: "talk", + TargetID: mob.DefID, + TargetName: mob.Name, + Data: map[string]any{"node": "start", "behavior_id": mob.BehaviorID}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadTalk(obj.BehaviorID) + if err != nil { + sess.WriteLine("This person has nothing to say.") + return + } + + if node, ok := cfg.Nodes["start"]; ok { + p.Action = &action.Action{ + Type: "talk", + TargetID: obj.ID, + TargetName: obj.Name, + Data: map[string]any{"node": "start", "behavior_id": obj.BehaviorID}, + } + g.showTalkNode(sess, node) + } else { + sess.WriteLine("This person has nothing to say.") + } +} + +func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) { + sess.WriteLine(fmt.Sprintf("\n%s", node.Message)) + + if node.Action != nil { + g.applyNodeAction(sess, node.Action) + } + + if len(node.Options) == 0 { + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + for i, opt := range node.Options { + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue + } + sess.WriteLine(fmt.Sprintf(" %d. %s", i+1, opt.Text)) + } + sess.State = net.StateTalk + sess.Write("\nChoice: ") +} + +func (g *Game) handleTalkInput(sess *net.Session, input string) { + p, ok := sess.Player.(*player.Player) + if !ok || p.Action == nil || p.Action.Type != "talk" { + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + input = strings.TrimSpace(input) + lower := strings.ToLower(input) + if lower == "bye" || lower == "exit" || lower == "end" || lower == "quit" { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + behaviorID := p.Action.Data["behavior_id"].(string) + cfg, err := g.BehaviorStore.LoadTalk(behaviorID) + if err != nil { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + nodeKey := p.Action.Data["node"].(string) + node, ok := cfg.Nodes[nodeKey] + if !ok { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + idx, err := parseIndex(input) + if err != nil || idx <= 0 { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + validIdx := 0 + var chosen *action.TalkOption + for i := range node.Options { + opt := &node.Options[i] + if opt.Condition != nil && !g.checkCondition(sess, opt.Condition) { continue + } + validIdx++ + if validIdx == idx { + chosen = opt + break + } + } + + if chosen == nil { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + if chosen.End { + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") + return + } + + if chosen.Goto != "" { + if nextNode, ok := cfg.Nodes[chosen.Goto]; ok { + p.Action.Data["node"] = chosen.Goto + g.showTalkNode(sess, nextNode) + return + } + } + + g.CancelAction(p) + sess.State = net.StateGame + sess.Write("\r\n> ") +} + +func (g *Game) applyNodeAction(sess *net.Session, na *action.NodeAction) { + p, ok := sess.Player.(*player.Player) + if !ok { + return + } + for k, v := range na.SetFlags { + g.WorldFlags[k] = v + } + if na.TakeItem != "" { + if p.HasItem(na.TakeItem) { + p.RemoveItem(na.TakeItem, 1) + } + } + if na.GiveItem != "" { + slot := p.FirstFreeSlot() + if slot == -1 { + sess.WriteLine("Your inventory is too full to receive that.") + } else { + p.SetInvSlot(slot, &player.InventorySlot{ItemID: na.GiveItem, Quantity: 1}) + g.AccountStore.SaveCharacter(p) + } + } +} + +func (g *Game) checkCondition(sess *net.Session, c *action.Condition) bool { + p, _ := sess.Player.(*player.Player) + if c.Flag != "" { + val, ok := g.WorldFlags[c.Flag] + if c.Not { + return !ok || val != c.Value + } + return ok && val == c.Value + } + if c.HasItem != "" { + has := p != nil && p.HasItem(c.HasItem) + if c.Not { + return !has + } + return has + } + return true +} + +func (g *Game) startUse(sess *net.Session, p *player.Player, obj *object.ObjectDef) { + cfg, err := g.BehaviorStore.LoadUse(obj.BehaviorID) + if err != nil { + sess.WriteLine("You can't use this.") + return + } + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = def.Name + } + sess.WriteLine(fmt.Sprintf("You need %s to use this.", defName)) + return + } + _ = qty + } + + if cfg.Success == nil && p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + p.Action = &action.Action{ + Type: "use", + TargetID: obj.ID, + TargetName: obj.Name, + WaitLeft: 1, + Data: map[string]any{"step": 0, "behavior_id": obj.BehaviorID}, + } + + 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) + return + } + + for itemID, qty := range cfg.Consume { + if !p.HasItem(itemID) { + defName := itemID + if def, err := g.ItemStore.Load(itemID); err == nil { + defName = def.Name + } + sess.WriteLine(fmt.Sprintf("You've run out of %s.", defName)) + g.CancelAction(p) + return + } + if !p.RemoveItem(itemID, qty) { + sess.WriteLine(fmt.Sprintf("You need %d of %s.", qty, itemID)) + g.CancelAction(p) + return + } + } + + if cfg.Success != nil { + skillLevel := p.Level(player.SkillName(cfg.Skill)) + chance := action.SuccessChance(*cfg.Success, skillLevel, cfg.Level) + if rand.Float64() >= chance { + if cfg.FailMsg != "" { + sess.WriteLine(cfg.FailMsg) + } + p.Action.WaitLeft = cfg.Wait + return + } + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + qty := cfg.Reward.Quantity + if qty <= 0 { + qty = 1 + } + + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: cfg.Reward.ItemID, Quantity: qty}) + + g.AccountStore.SaveCharacter(p) + + itemName := cfg.Reward.ItemID + if def, err := g.ItemStore.Load(cfg.Reward.ItemID); err == nil { + itemName = def.Name + } + sess.WriteLine(fmt.Sprintf("You make a %s.", itemName)) + + if p.FirstFreeSlot() == -1 { + sess.WriteLine("Your inventory is full.") + g.CancelAction(p) + return + } + + p.Action.WaitLeft = cfg.Wait +} + +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 + } + + 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(fmt.Sprintf("\n%s uses the %s.", p.Name, obj.Name)) + } + } + } +} + +func normalizeVerb(v string) string { + switch v { + case "mine", "chop", "fish": + return "gather" + case "talk", "speak", "ask": + return "talk" + case "pull", "push": + return "toggle" + } + return v +} + +func (g *Game) CheckExitCondition(c *world.ExitCondition) bool { + if c.Flag != "" { + val, ok := g.WorldFlags[c.Flag] + if c.Not { + return !ok || val != c.Value + } + return ok && val == c.Value + } + return true +} + +func (g *Game) RunEnterSteps(sess *net.Session, roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.OnEnter) == 0 { + return + } + for _, step := range room.OnEnter { + if step.Condition != nil && !g.CheckExitCondition(step.Condition) { + continue + } + if step.Message != "" { + sess.WriteLine(fmt.Sprintf("\n%s", step.Message)) + } + } +} + +func (g *Game) BroadcastRespawns() { + respawns := g.World.FlushObjRespawns() + for _, st := range respawns { + def, err := g.ObjectStore.Load(st.DefID) + if err != nil || def.BehaviorID == "" { + continue + } + cfg, err := g.BehaviorStore.LoadGather(def.BehaviorID) + if err != nil || cfg.RespawnBroadcast == "" { + continue + } + name := def.Name + if idx := st.Index + 1; len(g.World.FindObjInstances(st.RoomID, st.DefID)) > 1 { + name += fmt.Sprintf(" [%d]", idx) + } + msg := strings.ReplaceAll(cfg.RespawnBroadcast, "{name}", name) + if g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(st.RoomID) { + sess.WriteLine(fmt.Sprintf("\n%s", msg)) + } + } + } +} diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go new file mode 100644 index 0000000..ebf88ac --- /dev/null +++ b/internal/game/cmd_attack.go @@ -0,0 +1,471 @@ +package game + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +func (g *Game) doAttack(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You are already in combat!") + return + } + + if p.Action != nil { + g.CancelAction(p) + } + + mob := g.findMob(sess, input, p.RoomID) + if mob == nil { + return + } + + if mob.HP <= 0 { + sess.WriteLine("That is already dead.") + return + } + + if mob.Protected { + sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true))) + return + } + + if combat.IsMobInCombat(mob.InstanceID) { + sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) + return + } + + g.startCombat(sess, p, mob) +} + +func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance { + lower := strings.ToLower(input) + mobs := g.MobStore.MobsInRoom(roomID) + + idx := -1 + name := lower + if dotPos := strings.Index(lower, "."); dotPos > 0 { + if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { + idx = n + name = lower[dotPos+1:] + } + } + + var exact []*world.MobInstance + var prefix []*world.MobInstance + for _, m := range mobs { + q := m.MatchQuality(name) + if q == world.MatchExact { + exact = append(exact, m) + } else if q == world.MatchPrefix { + prefix = append(prefix, m) + } + } + + candidates := exact + if len(candidates) == 0 { + candidates = prefix + } + + if len(candidates) == 0 { + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) + return nil + } + + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].InstanceID < candidates[j].InstanceID + }) + + if idx > 0 { + if idx-1 < len(candidates) { + return candidates[idx-1] + } + return nil + } + + if len(candidates) == 1 { + return candidates[0] + } + + seen := make(map[string]bool) + for _, m := range candidates { + seen[mobDisplayName(m, false)] = true + } + if len(seen) > 1 { + sess.WriteLine("Which one?") + return nil + } + return candidates[0] +} + +func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { + g.cancelRest(p.Name) + + combat.EnterCombat(p.Name, mob.InstanceID) + + playerSpeed := g.playerWeaponSpeed(p) + + attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) + var styleParts []string + if attBonus > 0 { + styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus)) + } + if strBonus > 0 { + styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus)) + } + if defBonus > 0 { + styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus)) + } + styleStr := "" + if len(styleParts) > 0 { + styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" + } + sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr)) + + g.Ticks.Subscribe(playerSpeed, func() bool { + cs := combat.GetCombat(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + g.playerAttack(sess, p, currentMob) + if currentMob.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) + + g.Ticks.Subscribe(mob.Speed, func() bool { + cs := combat.GetCombat(p.Name) + if cs == nil || !cs.Active { + return false + } + currentMob := g.MobStore.GetInstance(cs.MobID) + if currentMob == nil || currentMob.HP <= 0 { + return false + } + g.mobAttack(sess, p, currentMob) + if p.HP <= 0 { + g.endCombat(sess, p, currentMob) + return false + } + return true + }) +} + +func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) + + equipAtt := 0 + equipStr := 0 + if itemID, ok := p.Equipment[object.SlotMainHand]; ok { + def, err := g.ItemStore.Load(itemID) + if err == nil { + equipAtt = def.Stats.AttackBonus + equipStr = def.Stats.StrengthBonus + } + } + + attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) + defRoll := combat.DefenseRoll(mob.Defense, 0, 0) + + if combat.HitCheck(attRoll, defRoll) { + maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr) + dmg := combat.RollDamage(maxHit) + + mob.HP -= dmg + if mob.HP < 0 { + mob.HP = 0 + } + if mob.HP < mob.MaxHP && mob.HP > 0 { + mob.StartRegen() + } + gains := g.awardCombatXP(p, dmg) + + 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 %d damage.", mobName, dmg) + hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP) + line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) + if p.Toggles["xpdrops"] && 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 += " (" + strings.Join(parts, ", ") + ")" + } + sess.WriteLine(line) + } else { + sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))) + } +} + +func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { + _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) + + equipDef := 0 + for _, itemID := range p.Equipment { + def, err := g.ItemStore.Load(itemID) + if err == nil { + equipDef += def.Stats.DefenseBonus + } + } + + attRoll := combat.AttackRoll(mob.Attack, 0, 0) + defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef) + + if combat.HitCheck(attRoll, defRoll) { + maxHit := combat.MaxHit(mob.Strength, 0, 0) + dmg := combat.RollDamage(maxHit) + + 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 %d damage.", attacker, dmg) + hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP()) + sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)) + } else { + attacker := mob.Name + if !mob.Unique { + attacker = "The " + mob.Name + } + sess.WriteLine(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) + + if p.HP <= 0 { + sess.WriteLine("\nOh dear, you are dead!") + g.dropItemsOnDeath(p) + p.HP = p.MaxHP() + p.RoomID = 1 + g.AccountStore.SaveCharacter(p) + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + g.doLook(sess) + return + } + + if mob != nil && mob.HP <= 0 { + sess.WriteLine(fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))) + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other != sess && other.Player != nil { + other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob))) + } + } + } + + if mob.Drops.Remains != "" { + g.World.AddReservedGroundItem(p.RoomID, mob.Drops.Remains, 1, p.Name) + def, _ := g.ItemStore.Load(mob.Drops.Remains) + name := mob.Drops.Remains + if def != nil { + name = def.Name + } + dropper := mob.Name + if !mob.Unique { + dropper = "The " + mob.Name + } + sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name)) + } + + if len(mob.Drops.Loot) > 0 { + entry := g.BehaviorStore.ResolveDrop(mob.Drops.Loot) + if entry != nil && entry.ItemID != "" { + qty := entry.Quantity + if qty <= 0 { + qty = 1 + } + g.World.AddReservedGroundItem(p.RoomID, entry.ItemID, qty, p.Name) + def, _ := g.ItemStore.Load(entry.ItemID) + name := entry.ItemID + if def != nil { + name = def.Name + } + dropper := mob.Name + if !mob.Unique { + dropper = "The " + mob.Name + } + if qty > 1 { + sess.WriteLine(fmt.Sprintf(" %s drops: %d x %s", dropper, qty, name)) + } else { + sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name)) + } + } + } + + respawnTicks := mob.RespawnTicks + if respawnTicks <= 0 { + respawnTicks = 30 + } + instanceID := mob.InstanceID + g.Ticks.Subscribe(respawnTicks, func() bool { + g.respawnMob(instanceID) + return false + }) + } +} + +func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain { + baseXP := dmg * 4 + var gains []xpGain + + switch p.AttackStyle { + case player.Accurate: + gains = []xpGain{{string(player.Attack), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + case player.Aggressive: + gains = []xpGain{{string(player.Strength), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + case player.Defensive: + gains = []xpGain{{string(player.Defense), baseXP * 3 / 4}, {string(player.Hitpoints), baseXP / 4}} + case player.Balanced: + quarter := baseXP / 4 + gains = []xpGain{ + {string(player.Attack), quarter}, + {string(player.Strength), quarter}, + {string(player.Defense), quarter}, + {string(player.Hitpoints), quarter}, + } + } + + for _, gain := range gains { + p.AddXP(player.SkillName(gain.Skill), gain.XP) + } + g.AccountStore.SaveCharacter(p) + return gains +} + +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) + } +} + +func (g *Game) stopCombat(playerName string) { + if cs := combat.GetCombat(playerName); cs != nil { + combat.LeaveCombat(playerName) + } +} + +func (g *Game) respawnMob(instanceID string) { + inst := g.MobStore.GetInstance(instanceID) + if inst == nil { + return + } + homeRoom := inst.HomeRoomID + inst.RoomID = homeRoom + inst.HP = inst.MaxHP + g.MobStore.RollIdleDescription(inst) + + if g.Hub != nil { + for _, sess := range g.Hub.PlayersInRoom(homeRoom) { + if p, ok := sess.Player.(*player.Player); ok && p.Toggles["mobspawn"] { + sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters the area.", mobDisplayName(inst, false), mobCombatLevel(inst))) + } + } + } +} + +func (g *Game) playerWeaponSpeed(p *player.Player) int { + 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 +} diff --git a/internal/game/cmd_description.go b/internal/game/cmd_description.go new file mode 100644 index 0000000..6875840 --- /dev/null +++ b/internal/game/cmd_description.go @@ -0,0 +1,34 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doDescription(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + if p.Description != "" { + sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description)) + } else { + sess.WriteLine("You don't have a description set.") + } + sess.WriteLine("") + sess.Write("Enter a new description (or press enter to keep current): ") + sess.State = net.StateChangeDescription +} + +func (g *Game) handleDescriptionChange(sess *net.Session, input string) { + if input != "" { + p := sess.Player.(*player.Player) + p.Description = input + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("Description set to: %s", input)) + } else { + sess.WriteLine("Description left unchanged.") + } + sess.State = net.StateGame + sess.Write("\r\n> ") +} diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go new file mode 100644 index 0000000..511688a --- /dev/null +++ b/internal/game/cmd_drop.go @@ -0,0 +1,48 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doDrop(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + matches := g.findInventoryMatches(input, p) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) + return + } + + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + return + } + + itemID := matches[0].ID + slotIdx := matches[0].Slot + slot := p.InvSlot(slotIdx) + def, _ := g.ItemStore.Load(itemID) + + qty := 1 + name := itemID + if def != nil { + name = def.Name + } + + if slot.Quantity > qty { + slot.Quantity -= qty + } else { + p.SetInvSlot(slotIdx, nil) + } + + g.World.AddGroundItem(p.RoomID, itemID, qty) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) +} diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go new file mode 100644 index 0000000..bce8ddc --- /dev/null +++ b/internal/game/cmd_get.go @@ -0,0 +1,251 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doGet(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + ground := g.World.GroundItems(p.RoomID) + if len(ground) == 0 { + sess.WriteLine("There's nothing on the ground to pick up.") + return + } + + matches := g.findGroundMatches(input, p.RoomID) + if len(matches) == 0 { + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) + return + } + + if len(matches) > 1 { + unique := uniqueItemNames(matches) + if len(unique) > 1 { + sess.WriteLine("Which one?") + for _, name := range unique { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + return + } + } + + itemID := matches[0].ID + + if itemID == "credits" { + g.pickupCredits(sess, p, itemID) + return + } + + def, _ := g.ItemStore.Load(itemID) + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + sess.WriteLine("Your inventory is full.") + return + } + + qty := 1 + if def != nil && def.Stackable { + ground := g.World.GroundItems(p.RoomID) + if gqty, ok := ground[itemID]; ok && gqty > 0 { + qty = gqty + } + } + + if def != nil && def.Stackable { + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == itemID { + removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } + _ = removed + slot.Quantity += qty + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", def.Name, slot.Quantity)) + return + } + } + } + + removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } + _ = removed + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + g.AccountStore.SaveCharacter(p) + name := itemID + if def != nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) +} + +func (g *Game) doGetAll(sess *net.Session) { + p := sess.Player.(*player.Player) + ground := g.World.GroundItems(p.RoomID) + if len(ground) == 0 { + sess.WriteLine("There's nothing on the ground to pick up.") + return + } + + var picked []string + for itemID, qty := range ground { + if qty <= 0 { + continue + } + if itemID == "credits" { + taken, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) + if !ok { + continue + } + p.Credits += taken + if taken == 1 { + picked = append(picked, "1 credit") + } else { + picked = append(picked, fmt.Sprintf("%d credits", taken)) + } + continue + } + + def, _ := g.ItemStore.Load(itemID) + for qty > 0 { + if def != nil && def.Stackable { + stacked := false + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot != nil && slot.ItemID == itemID { + _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + if !ok { + qty = 0 + stacked = true + break + } + slot.Quantity += qty + picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity)) + stacked = true + qty = 0 + break + } + } + if stacked { + break + } + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + if len(picked) > 0 { + sess.WriteLine("Inventory full. Picked up so far:") + innerPickupReport(sess, picked) + } else { + sess.WriteLine("Your inventory is full.") + } + g.AccountStore.SaveCharacter(p) + return + } + _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) + if !ok { + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) + name := itemID + if def != nil { + name = def.Name + } + picked = append(picked, name) + break + } + + freeSlot := p.FirstFreeSlot() + if freeSlot == -1 { + if len(picked) > 0 { + sess.WriteLine("Inventory full. Picked up so far:") + innerPickupReport(sess, picked) + } else { + sess.WriteLine("Your inventory is full.") + } + g.AccountStore.SaveCharacter(p) + return + } + + take := 1 + _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, take, p.Name) + if !ok { + break + } + p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take}) + name := itemID + if def != nil { + name = def.Name + } + picked = append(picked, name) + qty -= take + } + } + + g.AccountStore.SaveCharacter(p) + + if len(picked) == 0 { + sess.WriteLine("Your inventory is full.") + } else { + sess.Write("You pick up: ") + innerPickupReport(sess, picked) + } +} + +func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) { + qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) + if !ok { + sess.WriteLine("That's not yours!") + return + } + if qty <= 0 { + return + } + p.Credits += qty + g.AccountStore.SaveCharacter(p) + if qty == 1 { + sess.WriteLine("You pick up 1 credit.") + } else { + sess.WriteLine(fmt.Sprintf("You pick up %d credits. (total: %d)", qty, p.Credits)) + } +} + +func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { + ground := g.World.GroundItems(roomID) + var matches []itemMatch + for itemID := range ground { + def, err := g.ItemStore.Load(itemID) + if err != nil { + continue + } + if def.MatchesName(input) { + matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1}) + } + } + return matches +} + +func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { + var matches []itemMatch + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil { + continue + } + if def.MatchesName(input) { + matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i}) + } + } + return matches +} diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go new file mode 100644 index 0000000..a544ec1 --- /dev/null +++ b/internal/game/cmd_look.go @@ -0,0 +1,403 @@ +package game + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +func (g *Game) doLook(sess *net.Session) { + p := sess.Player.(*player.Player) + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("You are in a void.") + return + } + + sess.WriteLines( + "", + room.Name, + room.Description, + ) + + 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 + jDamaged := mobs[j].HP < mobs[j].MaxHP + if iDamaged != jDamaged { + return iDamaged + } + return mobs[i].InstanceID < mobs[j].InstanceID + }) + sess.WriteLine("") + for _, m := range mobs { + hp := "" + if m.HP < m.MaxHP { + hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP) + } + var desc string + if combat.IsMobInCombat(m.InstanceID) { + def, err := g.MobStore.LoadDef(m.DefID) + if err == nil && len(def.CombatDescriptions) > 0 { + target := combat.GetMobTarget(m.InstanceID) + pattern := def.CombatDescriptions[randInt(len(def.CombatDescriptions))] + desc = " " + fmt.Sprintf(pattern, target) + } + } else if m.IdleDescription != "" { + desc = fmt.Sprintf(" %s", m.IdleDescription) + } + displayName := m.Name + if !m.Unique { + displayName = "A " + m.Name + } + sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc)) + } + } + + objs := g.World.AllObjInstances(p.RoomID) + if len(objs) > 0 { + sess.WriteLine("") + grouped := make(map[string]int) + var order []string + for _, o := range objs { + if _, ok := grouped[o.DefID]; !ok { + order = append(order, o.DefID) + } + grouped[o.DefID]++ + } + for _, objID := range order { + count := grouped[objID] + def, err := g.ObjectStore.Load(objID) + if err != nil { + continue + } + + var activeIdxs, depletedIdxs []int + for i := 0; i < count; i++ { + st := g.World.GetObjState(p.RoomID, objID, i) + if st != nil && st.Depleted { + depletedIdxs = append(depletedIdxs, i+1) + } else { + activeIdxs = append(activeIdxs, i+1) + } + } + + if len(activeIdxs) > 0 { + if len(activeIdxs) == 1 { + sess.Write(fmt.Sprintf(" A %s is here.", def.Name)) + } else { + sess.Write(fmt.Sprintf(" %d %ss are here.", len(activeIdxs), def.Name)) + } + if count > 1 { + sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(activeIdxs))) + } else { + sess.WriteLine("") + } + } + if len(depletedIdxs) > 0 { + if len(depletedIdxs) == 1 { + sess.Write(fmt.Sprintf(" 1 depleted %s is here.", def.Name)) + } else { + sess.Write(fmt.Sprintf(" %d depleted %ss are here.", len(depletedIdxs), def.Name)) + } + sess.WriteLine(fmt.Sprintf(" [%s]", intsJoin(depletedIdxs))) + } + } + } + + ground := g.World.GroundItemsDetailed(p.RoomID) + if len(ground) > 0 { + sess.WriteLine("") + sess.WriteLine("On the ground:") + for _, info := range ground { + def, err := g.ItemStore.Load(info.ItemID) + name := info.ItemID + if err == nil { + name = def.Name + } + line := "" + if info.Quantity > 1 { + line = fmt.Sprintf(" %d x %s", info.Quantity, name) + } else { + line = fmt.Sprintf(" %s", name) + } + if info.ReservedFor != "" { + if p.Toggles["reserve"] { + line += fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer) + } else { + line += " (reserved)" + } + } + sess.WriteLine(line) + } + } + + if len(room.Exits) > 0 { + sess.WriteLine("") + if p.Toggles["exits"] { + sess.WriteLine("Exits:") + for _, dir := range world.ExitOrder { + exitDef, ok := room.Exits[dir] + if !ok { + continue + } + targetRoom, err := g.World.LoadRoom(exitDef.Room) + targetName := fmt.Sprintf("#%d", exitDef.Room) + if err == nil { + targetName = targetRoom.Name + } + if exitDef.Condition != nil && !g.CheckExitCondition(exitDef.Condition) { + targetName += " (blocked)" + } + sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName)) + } + } else { + sess.Write("Exits: ") + first := true + for _, dir := range world.ExitOrder { + if _, ok := room.Exits[dir]; ok { + if !first { + sess.Write(", ") + } + sess.Write(string(dir)) + first = false + } + } + sess.WriteLine("") + } + } + + others := g.Hub.PlayersInRoom(p.RoomID) + for _, other := range others { + if other != sess && other.Player != nil { + op := other.Player.(*player.Player) + line := fmt.Sprintf("\n%s is here", op.Name) + if cs := combat.GetCombat(op.Name); cs != nil { + if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 { + name := mobDisplayName(mob, false) + if idx := mobInstanceIdx(mob, g.MobStore.MobsInRoom(p.RoomID)); idx > 0 { + name += fmt.Sprintf(" [%d]", idx) + } + line += fmt.Sprintf(" (fighting %s)", name) + } + } else if desc := actionDesc(op); desc != "" { + line += ", " + desc + } + sess.WriteLine(line + ".") + } + } +} + +func (g *Game) doLookTarget(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + lower := strings.ToLower(input) + + if exitDir := g.World.ResolveExit(lower); exitDir != "" { + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("You can't see anything that way.") + return + } + exitDef, ok := room.Exits[exitDir] + if !ok { + sess.WriteLine("You can't see anything that way.") + return + } + g.World.SeedGroundItems(exitDef.Room) + g.seedRoomMobs(exitDef.Room) + origRoom := p.RoomID + p.RoomID = exitDef.Room + g.doLook(sess) + p.RoomID = origRoom + return + } + + var best *world.MobInstance + bestQ := world.MatchNone + for _, m := range g.MobStore.MobsInRoom(p.RoomID) { + q := m.MatchQuality(lower) + if q > bestQ { + bestQ = q + best = m + } + } + if best != nil { + sess.WriteLines( + "", + fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)), + ) + if best.IdleDescription != "" { + sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription)) + } + sess.WriteLines( + "", + fmt.Sprintf(" Attack: %d", best.Attack), + fmt.Sprintf(" Strength: %d", best.Strength), + fmt.Sprintf(" Defense: %d", best.Defense), + fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP), + ) + return + } + + instances := g.World.FindObjInstances(p.RoomID, lower) + if len(instances) > 0 { + st := &instances[0] + def, _ := g.ObjectStore.Load(st.DefID) + sess.WriteLine("") + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf("%d %ss:", len(instances), def.Name)) + } else { + sess.WriteLine(def.Name) + } + + if desc, ok := def.Props["description"].(string); ok && desc != "" { + sess.WriteLine(fmt.Sprintf(" %s", desc)) + } + + for _, ist := range instances { + if ist.Depleted { + if len(instances) > 1 { + sess.WriteLine(fmt.Sprintf(" %s %d: depleted for %d more ticks.", def.Name, ist.Index+1, ist.DepleteTimer)) + } else { + sess.WriteLine(fmt.Sprintf(" Depleted for %d more ticks.", ist.DepleteTimer)) + } + } + } + return + } + + ground := g.World.GroundItems(p.RoomID) + for itemID := range ground { + def, err := g.ItemStore.Load(itemID) + if err != nil || !def.MatchesName(input) { + continue + } + sess.WriteLines( + "", + def.Name, + fmt.Sprintf(" %s", def.Description), + fmt.Sprintf(" Value: %d credits", def.Value), + ) + return + } + + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + continue + } + def, err := g.ItemStore.Load(slot.ItemID) + if err != nil || !def.MatchesName(input) { + continue + } + sess.WriteLines( + "", + def.Name, + fmt.Sprintf(" %s", def.Description), + fmt.Sprintf(" Value: %d credits", def.Value), + ) + return + } + + others := g.Hub.PlayersInRoom(p.RoomID) + for _, other := range others { + if other == sess || other.Player == nil { + continue + } + op := other.Player.(*player.Player) + if strings.ToLower(op.Name) != lower { + continue + } + showPlayerInfo(sess, op) + return + } + + sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) +} + +func showPlayerInfo(sess *net.Session, p *player.Player) { + sess.WriteLines( + "", + p.Name, + fmt.Sprintf(" Combat Level: %d", p.CombatLevel()), + fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()), + "", + ) + + for _, s := range player.AllSkills { + level := p.Level(s) + sess.WriteLine(fmt.Sprintf(" %-12s Level: %d", s, level)) + } + + sess.WriteLine("") + sess.WriteLine(" Equipment:") + for _, slot := range EquipSlots { + itemID, ok := p.Equipment[slot] + if !ok { + continue + } + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID)) + } + + if p.Description != "" { + sess.WriteLine("") + sess.WriteLine(fmt.Sprintf(" %s", p.Description)) + } +} + +func (g *Game) doExits(sess *net.Session) { + p := sess.Player.(*player.Player) + room, err := g.World.LoadRoom(p.RoomID) + if err != nil || len(room.Exits) == 0 { + sess.WriteLine("There are no exits here.") + return + } + for _, dir := range world.ExitOrder { + exitDef, ok := room.Exits[dir] + if !ok { + continue + } + targetRoom, err := g.World.LoadRoom(exitDef.Room) + targetName := fmt.Sprintf("#%d", exitDef.Room) + if err == nil { + targetName = targetRoom.Name + } + sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName)) + } +} + +func intsJoin(nums []int) string { + var parts []string + for _, n := range nums { + parts = append(parts, strconv.Itoa(n)) + } + return strings.Join(parts, ",") +} + +func mobInstanceIdx(mob *world.MobInstance, roomMobs []*world.MobInstance) int { + var same []*world.MobInstance + for _, m := range roomMobs { + if m.DefID == mob.DefID { + same = append(same, m) + } + } + if len(same) <= 1 { + return 0 + } + sort.Slice(same, func(i, j int) bool { + return same[i].InstanceID < same[j].InstanceID + }) + for i, m := range same { + if m.InstanceID == mob.InstanceID { + return i + 1 + } + } + return 0 +} diff --git a/internal/game/cmd_misc.go b/internal/game/cmd_misc.go new file mode 100644 index 0000000..f56a0ff --- /dev/null +++ b/internal/game/cmd_misc.go @@ -0,0 +1,117 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doSay(sess *net.Session, msg string) { + p := sess.Player.(*player.Player) + roomID := p.RoomID + + for _, other := range g.Hub.PlayersInRoom(roomID) { + if other == sess { + other.WriteLine(fmt.Sprintf("\nYou say: %s", msg)) + } else { + other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg)) + } + } +} + +func (g *Game) doScore(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLines( + "", + fmt.Sprintf("Name: %s", p.Name), + fmt.Sprintf("Combat Level: %d", p.CombatLevel()), + fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), + fmt.Sprintf("Credits: %d", p.Credits), + "", + "Skills:", + ) + for _, s := range player.AllSkills { + level := p.Level(s) + xp := p.Skills[s] + next := player.XPForNextLevel(xp) + sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next)) + } +} + +func (g *Game) doInventory(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + sess.WriteLine("Inventory (28 slots):") + + empty := 0 + num := 0 + for i := 0; i < 28; i++ { + slot := p.InvSlot(i) + if slot == nil { + empty++ + continue + } + num++ + def, err := g.ItemStore.Load(slot.ItemID) + name := slot.ItemID + if err == nil { + name = def.Name + } + if slot.Quantity > 1 { + sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name)) + } else { + sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name)) + } + } + sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty)) +} + +func (g *Game) doEquipment(sess *net.Session) { + p := sess.Player.(*player.Player) + sess.WriteLine("") + sess.WriteLine("Equipment:") + + for _, slot := range EquipSlots { + itemID, ok := p.Equipment[slot] + if !ok { + sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot)) + continue + } + def, err := g.ItemStore.Load(itemID) + name := itemID + if err == nil { + name = def.Name + } + sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) + } +} + +func (g *Game) doStyle(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + styles := []string{"accurate", "aggressive", "defensive", "balanced"} + if input == "" { + sess.WriteLine("\nCombat styles:") + for _, s := range styles { + marker := " " + if string(p.AttackStyle) == s { + marker = "*" + } + sess.WriteLine(fmt.Sprintf(" %s %s", marker, s)) + } + return + } + + input = strings.ToLower(input) + for _, s := range styles { + if s == input { + p.AttackStyle = player.AttackStyle(s) + g.AccountStore.SaveCharacter(p) + sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", s)) + return + } + } + sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input)) +} diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go new file mode 100644 index 0000000..b95f5c5 --- /dev/null +++ b/internal/game/cmd_move.go @@ -0,0 +1,122 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doMove(sess *net.Session, dir string) { + p := sess.Player.(*player.Player) + exitDir := g.World.ResolveExit(dir) + if exitDir == "" { + sess.WriteLine("Go where?") + return + } + + room, err := g.World.LoadRoom(p.RoomID) + if err != nil { + sess.WriteLine("You can't move from here.") + return + } + + exitDef, ok := room.Exits[exitDir] + if !ok { + sess.WriteLine("You can't go that way.") + return + } + + if exitDef.Condition != nil && !g.CheckExitCondition(exitDef.Condition) { + msg := exitDef.BlockedMessage + if msg == "" { + msg = fmt.Sprintf("The way %s is blocked.", exitDir) + } + sess.WriteLine(msg) + return + } + + targetID := exitDef.Room + + _, err = g.World.LoadRoom(targetID) + if err != nil { + sess.WriteLine("That path seems blocked.") + return + } + + if cs := combat.GetCombat(p.Name); cs != nil && cs.LockedTicks > 0 { + sess.WriteLine(fmt.Sprintf("You are locked in combat for another %d tick%s!", cs.LockedTicks, plural(cs.LockedTicks))) + return + } + + g.stopCombat(p.Name) + + if p.Action != nil { + g.CancelAction(p) + } + + oldRoom := p.RoomID + p.RoomID = targetID + g.AccountStore.SaveCharacter(p) + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.ensureRoomObjects(p.RoomID) + + if g.Hub != nil { + for _, other := range g.Hub.PlayersInRoom(oldRoom) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir)) + } + } + g.Hub.EnterRoom(sess, targetID) + for _, other := range g.Hub.PlayersInRoom(targetID) { + if other != sess { + other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name)) + } + } + } + + sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) + if p.Toggles["description"] { + g.doLook(sess) + } else { + targetRoom, _ := g.World.LoadRoom(targetID) + if targetRoom != nil { + sess.WriteLine(targetRoom.Name) + } + } + + g.RunEnterSteps(sess, targetID) +} + +func (g *Game) seedRoomMobs(roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil || len(room.Mobs) == 0 { + return + } + g.MobStore.SeedMobs(roomID, room.Mobs) +} + +func (g *Game) ensureRoomObjects(roomID int) { + room, err := g.World.LoadRoom(roomID) + if err != nil { + return + } + var ids []string + for _, obj := range room.Objects { + ids = append(ids, obj.ID) + } + g.World.EnsureObjectStates(roomID, ids) + for _, obj := range room.Objects { + def, err := g.ObjectStore.Load(obj.ID) + if err != nil { + continue + } + g.World.SetObjName(roomID, obj.ID, def.Name) + if len(obj.WanderRooms) > 0 { + g.World.SetObjWander(roomID, obj.ID, obj.WanderRooms, obj.WanderInterval) + } + } +} diff --git a/internal/game/cmd_quit.go b/internal/game/cmd_quit.go new file mode 100644 index 0000000..a720bff --- /dev/null +++ b/internal/game/cmd_quit.go @@ -0,0 +1,53 @@ +package game + +import ( + "thirdcollapse/internal/combat" + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) doQuit(sess *net.Session) { + p := sess.Player.(*player.Player) + + if combat.GetCombat(p.Name) != nil { + sess.WriteLine("You can't rest during combat!") + return + } + + g.cancelRest(p.Name) + + ticksLeft := 10 + id := g.Ticks.Subscribe(1, func() bool { + switch ticksLeft { + case 10: + sess.WriteLine("You sit down to rest...") + case 6: + sess.WriteLine("You catch your breath...") + case 3: + sess.WriteLine("You close your eyes...") + case 0: + g.cancelRest(p.Name) + g.AccountStore.SaveCharacter(p) + g.charsMu.Lock() + delete(g.loggedInChars, p.Name) + g.charsMu.Unlock() + if g.Hub != nil { + g.Hub.LeaveRoom(sess) + } + sess.Player = nil + sess.State = net.StateMenu + g.showMenu(sess) + return false + } + ticksLeft-- + return true + }) + g.restTimers[p.Name] = id +} + +func (g *Game) cancelRest(playerName string) { + if id, ok := g.restTimers[playerName]; ok { + g.Ticks.Unsubscribe(id) + delete(g.restTimers, playerName) + } +} diff --git a/internal/game/cmd_ticktest.go b/internal/game/cmd_ticktest.go new file mode 100644 index 0000000..6cadab7 --- /dev/null +++ b/internal/game/cmd_ticktest.go @@ -0,0 +1,21 @@ +package game + +import ( + "fmt" + + "thirdcollapse/internal/net" +) + +func (g *Game) doTickTest(sess *net.Session) { + count := 0 + g.Ticks.Subscribe(5, func() bool { + count++ + sess.WriteLine(fmt.Sprintf("[Tick #%d] 600ms heartbeat is working.", count)) + if count >= 3 { + sess.WriteLine("[Tick test complete.]") + return false + } + return true + }) + sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.") +} diff --git a/internal/game/cmd_toggle.go b/internal/game/cmd_toggle.go new file mode 100644 index 0000000..19c111c --- /dev/null +++ b/internal/game/cmd_toggle.go @@ -0,0 +1,54 @@ +package game + +import ( + "fmt" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +var toggles = []struct { + Name string + Description string +}{ + {"description", "Long room descriptions"}, + {"tinymap", "Mini-map display"}, + {"xpdrops", "XP drop messages in combat"}, + {"exits", "Long exit display in look"}, + {"mobenter", "Messages when mobs enter the room"}, + {"mobleave", "Messages when mobs leave the room"}, + {"mobspawn", "Messages when mobs spawn in the area"}, + {"reserve", "Show full reserved item details"}, +} + +func (g *Game) doToggle(sess *net.Session, input string) { + p := sess.Player.(*player.Player) + + if input == "" { + sess.WriteLine("") + for _, t := range toggles { + status := "Off" + if p.Toggles[t.Name] { + status = "On" + } + sess.WriteLine(fmt.Sprintf(" %-12s %-3s %s", t.Name, status, t.Description)) + } + g.AccountStore.SaveCharacter(p) + return + } + + for _, t := range toggles { + if strings.ToLower(input) == t.Name { + p.Toggles[t.Name] = !p.Toggles[t.Name] + status := "Off" + if p.Toggles[t.Name] { + status = "On" + } + sess.WriteLine(fmt.Sprintf("\n%s %s.", t.Description, status)) + g.AccountStore.SaveCharacter(p) + return + } + } + sess.WriteLine(fmt.Sprintf("\nUnknown toggle: %s", input)) +} diff --git a/internal/game/game.go b/internal/game/game.go index 528c895..d793516 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -2,13 +2,10 @@ package game import ( "fmt" - "math/rand" - "os" - "sort" - "strconv" "strings" + "sync" - "thirdcollapse/internal/combat" + "thirdcollapse/internal/action" "thirdcollapse/internal/engine" "thirdcollapse/internal/net" "thirdcollapse/internal/object" @@ -17,28 +14,33 @@ import ( ) type Game struct { - World *world.World - ObjectStore *object.ObjectStore - ItemStore *object.ItemStore - AccountStore *player.AccountStore - MobStore *world.MobStore - Hub *net.Hub - Ticks *engine.Engine - dataDir string - restTimers map[string]uint64 // player name -> tick subscription ID - loggedInChars map[string]*net.Session // character name -> session + World *world.World + ObjectStore *object.ObjectStore + ItemStore *object.ItemStore + AccountStore *player.AccountStore + MobStore *world.MobStore + BehaviorStore *action.Store + Hub *net.Hub + Ticks *engine.Engine + WorldFlags map[string]any + dataDir string + restTimers map[string]uint64 + charsMu sync.Mutex + loggedInChars map[string]*net.Session combatPadWidth int } func New(dataDir string) *Game { return &Game{ - World: world.New(dataDir), - ObjectStore: object.NewObjectStore(dataDir), - ItemStore: object.NewItemStore(dataDir), - AccountStore: player.NewAccountStore(dataDir), - MobStore: world.NewMobStore(dataDir), - Ticks: engine.New(), - dataDir: dataDir, + World: world.New(dataDir), + ObjectStore: object.NewObjectStore(dataDir), + ItemStore: object.NewItemStore(dataDir), + AccountStore: player.NewAccountStore(dataDir), + MobStore: world.NewMobStore(dataDir), + BehaviorStore: action.NewStore(dataDir), + Ticks: engine.New(), + WorldFlags: make(map[string]any), + dataDir: dataDir, restTimers: make(map[string]uint64), loggedInChars: make(map[string]*net.Session), } @@ -48,7 +50,9 @@ func (g *Game) SetHub(hub *net.Hub) { g.Hub = hub hub.OnRemove(func(sess *net.Session) { if p, ok := sess.Player.(*player.Player); ok { + g.charsMu.Lock() delete(g.loggedInChars, p.Name) + g.charsMu.Unlock() } }) } @@ -81,508 +85,11 @@ func (g *Game) HandleSession(sess *net.Session, input string) { g.handleGameCommand(sess, input) case net.StateChangeDescription: g.handleDescriptionChange(sess, input) + case net.StateTalk: + g.handleTalkInput(sess, input) } } -func (g *Game) handleAccountName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if name == "" { - sess.Write("Account name: ") - return - } - actualName, err := g.AccountStore.FindAccount(name) - if err == nil { - sess.Account = &net.AccountEntry{Name: actualName} - sess.State = net.StatePassword - sess.Write("Password: ") - } else { - sess.Account = &net.AccountEntry{Name: name} - sess.State = net.StateNewAccountPass - sess.Write("A new visitor to this rock!\nChoose password: ") - } -} - -func (g *Game) handlePassword(sess *net.Session, input string) { - if input == "" { - sess.Write("Password: ") - return - } - acc, err := g.AccountStore.LoadAccount(sess.Account.Name) - if err != nil { - sess.WriteLine("Error loading account.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if !player.CheckPassword(input, acc.PasswordHash) { - sess.WriteLine("Wrong password.") - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - sess.Account = &net.AccountEntry{ - Name: acc.Name, - PasswordHash: acc.PasswordHash, - Characters: acc.Characters, - } - g.showMenu(sess) -} - -func (g *Game) handleNewAccountPass(sess *net.Session, input string) { - input = strings.TrimSpace(input) - if input == "" { - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - sess.PendingPass = input - sess.State = net.StateNewAccountConfirm - sess.Write("Confirm password: ") -} - -func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { - input = strings.TrimSpace(input) - if input == "" { - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - if input != sess.PendingPass { - sess.WriteLine("Passwords do not match.") - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - hash, err := player.HashPassword(input) - if err != nil { - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - acc := &player.Account{ - Name: sess.Account.Name, - PasswordHash: hash, - } - if err := g.AccountStore.SaveAccount(acc); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) - sess.Account = nil - sess.State = net.StateAccountName - sess.Write("Account name: ") - return - } - - sess.Account = &net.AccountEntry{ - Name: acc.Name, - PasswordHash: acc.PasswordHash, - } - sess.PendingPass = "" - g.showMenu(sess) -} - -func (g *Game) handleRenameAccount(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - if newName == "" { - sess.Write("New account name: ") - return - } - oldName := sess.Account.Name - if newName == oldName { - sess.WriteLine("That's already your account name.") - g.showMenu(sess) - return - } - if g.AccountStore.AccountExists(newName) { - sess.WriteLine("An account with that name already exists.") - sess.Write("New account name: ") - return - } - - // Rename the account file - oldPath := g.AccountStore.AccountPath(oldName) - newPath := g.AccountStore.AccountPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) - g.showMenu(sess) - return - } - - sess.Account.Name = newName - sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) - g.showMenu(sess) -} - -func (g *Game) handleRenameChar(sess *net.Session, input string) { - name := strings.TrimSpace(input) - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - - // Verify the character exists on this account - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - g.showMenu(sess) - return - } - - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) - sess.Write("New name: ") -} - -func (g *Game) handleRenameCharName(sess *net.Session, input string) { - newName := strings.TrimSpace(input) - oldName := sess.PendingChar - if newName == "" { - sess.Write("New name: ") - return - } - if newName == oldName { - sess.WriteLine("That's already the character's name.") - g.showMenu(sess) - return - } - if g.AccountStore.CharacterExists(newName) { - sess.WriteLine("A character with that name already exists.") - sess.Write("New name: ") - return - } - - // Rename character file - oldPath := g.AccountStore.CharPath(oldName) - newPath := g.AccountStore.CharPath(newName) - if err := os.Rename(oldPath, newPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) - g.showMenu(sess) - return - } - - // Load and update the character's internal name - p, _ := g.AccountStore.LoadCharacter(newName) - if p != nil { - p.Name = newName - g.AccountStore.SaveCharacter(p) - } - - // Update account character list - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - for i, c := range acc.Characters { - if c == oldName { - acc.Characters[i] = newName - break - } - } - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - sess.PendingChar = "" - sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) - g.showMenu(sess) -} - -func (g *Game) showDeleteConfirm(sess *net.Session) { - sess.State = net.StateDeleteChar - sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) -} - -func (g *Game) handleDeleteChar(sess *net.Session, input string) { - // If PendingChar not set, this is the character selection step - if sess.PendingChar == "" { - name := strings.TrimSpace(input) - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = sess.Account.Characters[idx-1] - } else { - sess.PendingChar = name - } - found := false - for _, c := range sess.Account.Characters { - if c == sess.PendingChar { - found = true - break - } - } - if !found { - sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - g.showDeleteConfirm(sess) - return - } - - // Confirmation step - input = strings.TrimSpace(input) - expected := "DELETE " + sess.PendingChar - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Delete cancelled.") - sess.PendingChar = "" - g.showMenu(sess) - return - } - - // Delete the character file - charPath := g.AccountStore.CharPath(sess.PendingChar) - if err := os.Remove(charPath); err != nil { - sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) - sess.PendingChar = "" - g.showMenu(sess) - return - } - - // Remove from account - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - var newChars []string - for _, c := range acc.Characters { - if c != sess.PendingChar { - newChars = append(newChars, c) - } - } - acc.Characters = newChars - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = newChars - - sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) - sess.PendingChar = "" - g.showMenu(sess) -} - -func (g *Game) handlePurgeAccount(sess *net.Session, input string) { - input = strings.TrimSpace(input) - expected := "PURGE " + sess.Account.Name - if strings.ToUpper(input) != strings.ToUpper(expected) { - sess.WriteLine("Purge cancelled.") - g.showMenu(sess) - return - } - - // Delete all character files - for _, name := range sess.Account.Characters { - os.Remove(g.AccountStore.CharPath(name)) - } - - // Delete account file - os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) - - sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) - sess.Conn.Close() -} - -func (g *Game) showMenu(sess *net.Session) { - sess.State = net.StateMenu - lines := []string{ - "", - fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name), - "", - } - if len(sess.Account.Characters) > 0 { - lines = append(lines, - " (C)onnect character to TC", - "", - " (L)ist characters", - " (R)ename character", - " (D)elete character", - ) - } - lines = append(lines, - " (N)ew character", - " (P)urge account", - " (A)ccount rename", - " (Q)uit", - "", - "INPUT: ", - ) - sess.WriteLines(lines...) -} - -func (g *Game) handleMenu(sess *net.Session, input string) { - switch strings.ToLower(strings.TrimSpace(input)) { - case "": - sess.Write("INPUT: ") - case "c": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!") - sess.Write("\nINPUT: ") - return - } - if len(sess.Account.Characters) == 1 { - g.connectCharacter(sess, sess.Account.Characters[0]) - return - } - sess.WriteLine("\nSelect character:") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.State = net.StateNewCharName - sess.PendingChar = "connect" - sess.Write("\nChoice: ") - - case "l": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)") - } else { - sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:") - for _, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" - %s", name)) - } - } - sess.Write("\nINPUT: ") - - case "n": - sess.State = net.StateNewCharName - sess.PendingChar = "" - sess.Write("What's your character name?: ") - - case "a": - sess.State = net.StateRenameAccount - sess.Write("New account name: ") - - case "r": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nNo characters to rename.") - sess.Write("\nChoice: ") - return - } - if len(sess.Account.Characters) == 1 { - sess.PendingChar = sess.Account.Characters[0] - sess.State = net.StateRenameCharName - sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) - sess.Write("New name: ") - return - } - sess.State = net.StateRenameChar - sess.WriteLine("\nRename which character?") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.Write("\n> : ") - - case "d": - if len(sess.Account.Characters) == 0 { - sess.WriteLine("\nNo characters to delete.") - sess.Write("\nINPUT: ") - return - } - if len(sess.Account.Characters) == 1 { - sess.PendingChar = sess.Account.Characters[0] - } else { - sess.State = net.StateDeleteChar - sess.WriteLine("\nDelete which character?") - for i, name := range sess.Account.Characters { - sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) - } - sess.Write("\n>: ") - return - } - g.showDeleteConfirm(sess) - - case "p": - sess.State = net.StatePurgeAccount - sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) - sess.Write("\n> ") - - case "q": - sess.WriteLine("Later.") - sess.Conn.Close() - return - - default: - sess.Write("SYNTAX ERROR\nINPUT: ") - } -} - -func (g *Game) handleNewCharName(sess *net.Session, input string) { - name := strings.TrimSpace(input) - - // Coming from "c" menu — selecting a character by number - if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { - if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { - sess.PendingChar = "" - g.connectCharacter(sess, sess.Account.Characters[idx-1]) - return - } - sess.WriteLine("Invalid choice.") - sess.Write("\nChoice: ") - return - } - - if name == "" { - sess.Write("Character name: ") - return - } - - if g.AccountStore.CharacterExists(name) { - sess.WriteLine("A character with that name already exists.") - sess.Write("Character name: ") - return - } - - // Create new character - p := player.New(name) - p.RoomID = 1 // spawn room - - if err := g.AccountStore.SaveCharacter(p); err != nil { - sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - // Add to account - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) - acc.Characters = append(acc.Characters, name) - g.AccountStore.SaveAccount(acc) - sess.Account.Characters = acc.Characters - - g.connectCharacter(sess, name) -} - -func (g *Game) connectCharacter(sess *net.Session, name string) { - if existing := g.loggedInChars[name]; existing != nil { - sess.WriteLine("This character is logged in elsewhere.") - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - p, err := g.AccountStore.LoadCharacter(name) - if err != nil { - sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) - sess.State = net.StateMenu - g.showMenu(sess) - return - } - - sess.Player = p - sess.State = net.StateGame - g.loggedInChars[name] = sess - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - - g.doLook(sess) - sess.Write("\r\n> ") -} - func (g *Game) handleGameCommand(sess *net.Session, input string) { if input == "" { sess.Write("> ") @@ -596,7 +103,7 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { parts := strings.Fields(strings.ToLower(input)) cmd := parts[0] args := parts[1:] - + switch cmd { case "get", "take", "grab", "pick": if len(args) == 0 { @@ -662,1627 +169,23 @@ func (g *Game) handleGameCommand(sess *net.Session, input string) { } else { g.doHelp(sess, strings.Join(args, " ")) } - default: - sess.WriteLine("Unknown command.") - } - - sess.Write("\r\n> ") -} - -var toggles = []struct { - Name string - Description string -}{ - {"description", "Long room descriptions"}, - {"tinymap", "Mini-map display"}, - {"xpdrops", "XP drop messages in combat"}, - {"exits", "Long exit display in look"}, - {"mobenter", "Messages when mobs enter the room"}, - {"mobleave", "Messages when mobs leave the room"}, - {"mobspawn", "Messages when mobs spawn in the area"}, - {"reserve", "Show full reserved item details"}, -} - -func (g *Game) doToggle(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - - if input == "" { - sess.WriteLine("") - for _, t := range toggles { - status := "Off" - if p.Toggles[t.Name] { - status = "On" - } - sess.WriteLine(fmt.Sprintf(" %-12s %-3s %s", t.Name, status, t.Description)) - } - g.AccountStore.SaveCharacter(p) - return - } - - for _, t := range toggles { - if strings.ToLower(input) == t.Name { - p.Toggles[t.Name] = !p.Toggles[t.Name] - status := "Off" - if p.Toggles[t.Name] { - status = "On" - } - sess.WriteLine(fmt.Sprintf("\n%s %s.", t.Description, status)) - g.AccountStore.SaveCharacter(p) - return - } - } - sess.WriteLine(fmt.Sprintf("\nUnknown toggle: %s", input)) -} - -func (g *Game) doMove(sess *net.Session, dir string) { - p := sess.Player.(*player.Player) - exitDir := g.World.ResolveExit(dir) - if exitDir == "" { - sess.WriteLine("Go where?") - return - } - - room, err := g.World.LoadRoom(p.RoomID) - if err != nil { - sess.WriteLine("You can't move from here.") - return - } - - targetID, ok := room.Exits[exitDir] - if !ok { - sess.WriteLine("You can't go that way.") - return - } - - _, err = g.World.LoadRoom(targetID) - if err != nil { - sess.WriteLine("That path seems blocked.") - return - } - - // Check combat lock - if cs := combat.GetCombat(p.Name); cs != nil && cs.LockedTicks > 0 { - sess.WriteLine(fmt.Sprintf("You are locked in combat for another %d tick%s!", cs.LockedTicks, plural(cs.LockedTicks))) - return - } - - // Interrupt combat on move - g.stopCombat(p.Name) - - oldRoom := p.RoomID - p.RoomID = targetID - g.AccountStore.SaveCharacter(p) - - g.World.SeedGroundItems(p.RoomID) - g.seedRoomMobs(p.RoomID) - - if g.Hub != nil { - // Notify people in old room - for _, other := range g.Hub.PlayersInRoom(oldRoom) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s leaves to the %s.", p.Name, exitDir)) - } - } - g.Hub.EnterRoom(sess, targetID) - // Notify people in new room - for _, other := range g.Hub.PlayersInRoom(targetID) { - if other != sess { - other.WriteLine(fmt.Sprintf("\n%s arrives.", p.Name)) - } - } - } - - sess.WriteLine(fmt.Sprintf("\nYou walk %s.", exitDir)) - if p.Toggles["description"] { - g.doLook(sess) - } else { - targetRoom, _ := g.World.LoadRoom(targetID) - if targetRoom != nil { - sess.WriteLine(targetRoom.Name) - } - } -} - -func (g *Game) doStyle(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - - styles := []string{"accurate", "aggressive", "defensive", "balanced"} - if input == "" { - sess.WriteLine("\nCombat styles:") - for _, s := range styles { - marker := " " - if string(p.AttackStyle) == s { - marker = "*" - } - sess.WriteLine(fmt.Sprintf(" %s %s", marker, s)) - } - return - } - - input = strings.ToLower(input) - for _, s := range styles { - if s == input { - p.AttackStyle = player.AttackStyle(s) - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("\nCombat style set to %s.", s)) - return - } - } - sess.WriteLine(fmt.Sprintf("\nUnknown style: %s. Choices: accurate, aggressive, defensive, balanced", input)) -} - -func (g *Game) doLook(sess *net.Session) { - p := sess.Player.(*player.Player) - room, err := g.World.LoadRoom(p.RoomID) - if err != nil { - sess.WriteLine("You are in a void.") - return - } - - sess.WriteLines( - "", - room.Name, - room.Description, - ) - - // Mobs - 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 - jDamaged := mobs[j].HP < mobs[j].MaxHP - if iDamaged != jDamaged { - return iDamaged - } - return mobs[i].InstanceID < mobs[j].InstanceID - }) - sess.WriteLine("") - for _, m := range mobs { - hp := "" - if m.HP < m.MaxHP { - hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP) - } - var desc string - if combat.IsMobInCombat(m.InstanceID) { - def, err := g.MobStore.LoadDef(m.DefID) - if err == nil && len(def.CombatDescriptions) > 0 { - target := combat.GetMobTarget(m.InstanceID) - pattern := def.CombatDescriptions[rand.Intn(len(def.CombatDescriptions))] - desc = " " + fmt.Sprintf(pattern, target) - } - } else if m.IdleDescription != "" { - desc = fmt.Sprintf(" %s", m.IdleDescription) - } - displayName := m.Name - if !m.Unique { - displayName = "A " + m.Name - } - sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc)) - } - } - - // Ground items - ground := g.World.GroundItemsDetailed(p.RoomID) - if len(ground) > 0 { - sess.WriteLine("") - sess.WriteLine("On the ground:") - for _, info := range ground { - def, err := g.ItemStore.Load(info.ItemID) - name := info.ItemID - if err == nil { - name = def.Name - } - line := "" - if info.Quantity > 1 { - line = fmt.Sprintf(" %d x %s", info.Quantity, name) - } else { - line = fmt.Sprintf(" %s", name) - } - if info.ReservedFor != "" { - if p.Toggles["reserve"] { - line += fmt.Sprintf(" (reserved for %s for %d ticks)", info.ReservedFor, info.ReserveTimer) - } else { - line += " (reserved)" - } - } - sess.WriteLine(line) - } - } - - // Exits - if len(room.Exits) > 0 { - sess.WriteLine("") - if p.Toggles["exits"] { - sess.WriteLine("Exits:") - for _, dir := range world.ExitOrder { - targetID, ok := room.Exits[dir] - if !ok { - continue - } - targetRoom, err := g.World.LoadRoom(targetID) - targetName := fmt.Sprintf("#%d", targetID) - if err == nil { - targetName = targetRoom.Name - } - sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName)) - } - } else { - sess.Write("Exits: ") - first := true - for _, dir := range world.ExitOrder { - if _, ok := room.Exits[dir]; ok { - if !first { - sess.Write(", ") - } - sess.Write(string(dir)) - first = false - } - } - sess.WriteLine("") - } - } - - // Show other players - others := g.Hub.PlayersInRoom(p.RoomID) - for _, other := range others { - if other != sess && other.Player != nil { - op := other.Player.(*player.Player) - line := fmt.Sprintf("\n%s is here", op.Name) - if cs := combat.GetCombat(op.Name); cs != nil { - if mob := g.MobStore.GetInstance(cs.MobID); mob != nil && mob.HP > 0 { - line += fmt.Sprintf(" (fighting %s)", mobDisplayName(mob, false)) - } - } - sess.WriteLine(line + ".") - } - } -} - -func (g *Game) doSay(sess *net.Session, msg string) { - p := sess.Player.(*player.Player) - roomID := p.RoomID - - for _, other := range g.Hub.PlayersInRoom(roomID) { - if other == sess { - other.WriteLine(fmt.Sprintf("\nYou say: %s", msg)) - } else { - other.WriteLine(fmt.Sprintf("\n%s says: %s", p.Name, msg)) - } - } -} - -func (g *Game) doScore(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLines( - "", - fmt.Sprintf("Name: %s", p.Name), - fmt.Sprintf("Combat Level: %d", p.CombatLevel()), - fmt.Sprintf("HP: %d/%d", p.HP, p.MaxHP()), - fmt.Sprintf("Credits: %d", p.Credits), - "", - "Skills:", - ) - for _, s := range player.AllSkills { - level := p.Level(s) - xp := p.Skills[s] - next := player.XPForNextLevel(xp) - sess.WriteLine(fmt.Sprintf(" %-12s Level: %2d XP: %d / %d", s, level, xp, xp+next)) - } -} - -func (g *Game) doInventory(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLine("") - sess.WriteLine("Inventory (28 slots):") - - empty := 0 - num := 0 - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - empty++ - continue - } - num++ - def, err := g.ItemStore.Load(slot.ItemID) - name := slot.ItemID - if err == nil { - name = def.Name - } - if slot.Quantity > 1 { - sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name)) + case "mine", "chop", "fish", "use", "pull", "push": + if len(args) == 0 { + sess.WriteLine(fmt.Sprintf("%s what?", cmd)) } else { - sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name)) - } - } - sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty)) -} - -func (g *Game) doEquipment(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLine("") - sess.WriteLine("Equipment:") - - slots := []object.EquipSlot{ - object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, - object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, - object.SlotMainHand, object.SlotOffHand, object.SlotRing, - } - for _, slot := range slots { - itemID, ok := p.Equipment[slot] - if !ok { - sess.WriteLine(fmt.Sprintf(" %-12s (empty)", slot)) - continue - } - def, err := g.ItemStore.Load(itemID) - name := itemID - if err == nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name)) - } -} - -func (g *Game) cancelRest(playerName string) { - if id, ok := g.restTimers[playerName]; ok { - g.Ticks.Unsubscribe(id) - delete(g.restTimers, playerName) - } -} - -func (g *Game) doQuit(sess *net.Session) { - p := sess.Player.(*player.Player) - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You can't rest during combat!") - return - } - - g.cancelRest(p.Name) - - ticksLeft := 10 - id := g.Ticks.Subscribe(1, func() bool { - switch ticksLeft { - case 10: - sess.WriteLine("You sit down to rest...") - case 6: - sess.WriteLine("You catch your breath...") - case 3: - sess.WriteLine("You close your eyes...") - case 0: - g.cancelRest(p.Name) - g.AccountStore.SaveCharacter(p) - delete(g.loggedInChars, p.Name) - if g.Hub != nil { - g.Hub.LeaveRoom(sess) - } - sess.Player = nil - sess.State = net.StateMenu - g.showMenu(sess) - return false - } - ticksLeft-- - return true - }) - g.restTimers[p.Name] = id -} - -func plural(n int) string { - if n == 1 { - return "" - } - return "s" -} - -func parseIndex(s string) (int, error) { - var idx int - _, err := fmt.Sscanf(s, "%d", &idx) - if err != nil { - return 0, err - } - return idx, nil -} - -func (g *Game) pickupCredits(sess *net.Session, p *player.Player, itemID string) { - qty, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) - if !ok { - sess.WriteLine("That's not yours!") - return - } - if qty <= 0 { - return - } - p.Credits += qty - g.AccountStore.SaveCharacter(p) - if qty == 1 { - sess.WriteLine("You pick up 1 credit.") - } else { - sess.WriteLine(fmt.Sprintf("You pick up %d credits. (total: %d)", qty, p.Credits)) - } -} - -func (g *Game) doGetAll(sess *net.Session) { - p := sess.Player.(*player.Player) - ground := g.World.GroundItems(p.RoomID) - if len(ground) == 0 { - sess.WriteLine("There's nothing on the ground to pick up.") - return - } - - var picked []string - for itemID, qty := range ground { - if qty <= 0 { - continue - } - if itemID == "credits" { - taken, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, 999999, p.Name) - if !ok { - continue - } - p.Credits += taken - if taken == 1 { - picked = append(picked, "1 credit") - } else { - picked = append(picked, fmt.Sprintf("%d credits", taken)) - } - continue - } - - def, _ := g.ItemStore.Load(itemID) - for qty > 0 { - if def != nil && def.Stackable { - stacked := false - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == itemID { - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { - qty = 0 - stacked = true - break - } - slot.Quantity += qty - picked = append(picked, fmt.Sprintf("%s (now %d)", def.Name, slot.Quantity)) - stacked = true - qty = 0 - break - } - } - if stacked { - break - } - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - if len(picked) > 0 { - sess.WriteLine("Inventory full. Picked up so far:") - innerPickupReport(sess, picked) - } else { - sess.WriteLine("Your inventory is full.") - } - g.AccountStore.SaveCharacter(p) - return - } - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) - name := itemID - if def != nil { - name = def.Name - } - picked = append(picked, name) - break - } - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - if len(picked) > 0 { - sess.WriteLine("Inventory full. Picked up so far:") - innerPickupReport(sess, picked) - } else { - sess.WriteLine("Your inventory is full.") - } - g.AccountStore.SaveCharacter(p) - return - } - - take := 1 - _, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, take, p.Name) - if !ok { - break - } - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: take}) - name := itemID - if def != nil { - name = def.Name - } - picked = append(picked, name) - qty -= take - } - } - - g.AccountStore.SaveCharacter(p) - - if len(picked) == 0 { - sess.WriteLine("Your inventory is full.") - } else { - sess.Write("You pick up: ") - innerPickupReport(sess, picked) - } -} - -func (g *Game) doLookTarget(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - lower := strings.ToLower(input) - - // Check if looking at an exit direction - if exitDir := g.World.ResolveExit(lower); exitDir != "" { - room, err := g.World.LoadRoom(p.RoomID) - if err != nil { - sess.WriteLine("You can't see anything that way.") - return - } - targetID, ok := room.Exits[exitDir] - if !ok { - sess.WriteLine("You can't see anything that way.") - return - } - // Seed target room and show its look output - g.World.SeedGroundItems(targetID) - g.seedRoomMobs(targetID) - origRoom := p.RoomID - p.RoomID = targetID - g.doLook(sess) - p.RoomID = origRoom - return - } - - // Check mobs — prefer exact matches over prefix - var best *world.MobInstance - bestQ := world.MatchNone - for _, m := range g.MobStore.MobsInRoom(p.RoomID) { - q := m.MatchQuality(lower) - if q > bestQ { - bestQ = q - best = m - } - } - if best != nil { - sess.WriteLines( - "", - fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)), - ) - if best.IdleDescription != "" { - sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription)) - } - sess.WriteLines( - "", - fmt.Sprintf(" Attack: %d", best.Attack), - fmt.Sprintf(" Strength: %d", best.Strength), - fmt.Sprintf(" Defense: %d", best.Defense), - fmt.Sprintf(" HP: %d/%d", best.HP, best.MaxHP), - ) - return - } - - // Check room objects - room, err := g.World.LoadRoom(p.RoomID) - if err == nil { - for _, objID := range room.Objects { - def, err := g.ObjectStore.Load(objID) - if err != nil { - continue - } - if !world.WordPrefixMatch(lower, def.Name) && !world.WordPrefixMatch(lower, def.ID) { - continue - } - sess.WriteLine("") - sess.WriteLine(def.Name) - if desc, ok := def.Props["description"].(string); ok && desc != "" { - sess.WriteLine(fmt.Sprintf(" %s", desc)) - } + g.StartAction(sess, cmd, strings.Join(args, " ")) return } - } - - // Check ground items - ground := g.World.GroundItems(p.RoomID) - for itemID := range ground { - def, err := g.ItemStore.Load(itemID) - if err != nil || !def.MatchesName(input) { - continue - } - sess.WriteLines( - "", - def.Name, - fmt.Sprintf(" %s", def.Description), - fmt.Sprintf(" Value: %d credits", def.Value), - ) - return - } - - // Check inventory items - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil || !def.MatchesName(input) { - continue - } - sess.WriteLines( - "", - def.Name, - fmt.Sprintf(" %s", def.Description), - fmt.Sprintf(" Value: %d credits", def.Value), - ) - return - } - - // Check other players - others := g.Hub.PlayersInRoom(p.RoomID) - for _, other := range others { - if other == sess || other.Player == nil { - continue - } - op := other.Player.(*player.Player) - if strings.ToLower(op.Name) != lower { - continue - } - showPlayerInfo(sess, op) - return - } - - sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) -} - -func showPlayerInfo(sess *net.Session, p *player.Player) { - sess.WriteLines( - "", - p.Name, - fmt.Sprintf(" Combat Level: %d", p.CombatLevel()), - fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()), - "", - ) - - // Skills - for _, s := range player.AllSkills { - level := p.Level(s) - xp := p.Skills[s] - sess.WriteLine(fmt.Sprintf(" %-12s Level: %d", s, level)) - _ = xp - } - - // Equipment - sess.WriteLine("") - sess.WriteLine(" Equipment:") - slots := []object.EquipSlot{ - object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, - object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, - object.SlotMainHand, object.SlotOffHand, object.SlotRing, - } - for _, slot := range slots { - itemID, ok := p.Equipment[slot] - if !ok { - continue - } - sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, itemID)) - } - - if p.Description != "" { - sess.WriteLine("") - sess.WriteLine(fmt.Sprintf(" %s", p.Description)) - } -} - -func (g *Game) doExits(sess *net.Session) { - p := sess.Player.(*player.Player) - room, err := g.World.LoadRoom(p.RoomID) - if err != nil || len(room.Exits) == 0 { - sess.WriteLine("There are no exits here.") - return - } - for _, dir := range world.ExitOrder { - targetID, ok := room.Exits[dir] - if !ok { - continue - } - targetRoom, err := g.World.LoadRoom(targetID) - targetName := fmt.Sprintf("#%d", targetID) - if err == nil { - targetName = targetRoom.Name - } - sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName)) - } -} - -func (g *Game) doDescription(sess *net.Session) { - p := sess.Player.(*player.Player) - sess.WriteLine("") - if p.Description != "" { - sess.WriteLine(fmt.Sprintf("Current description: %s", p.Description)) - } else { - sess.WriteLine("You don't have a description set.") - } - sess.WriteLine("") - sess.Write("Enter a new description (or press enter to keep current): ") - sess.State = net.StateChangeDescription -} - -func (g *Game) handleDescriptionChange(sess *net.Session, input string) { - if input != "" { - p := sess.Player.(*player.Player) - p.Description = input - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("Description set to: %s", input)) - } else { - sess.WriteLine("Description left unchanged.") - } - sess.State = net.StateGame - sess.Write("\r\n> ") -} - -func innerPickupReport(sess *net.Session, picked []string) { - for i, name := range picked { - if i == len(picked)-1 { - sess.WriteLine(name) - } else if i == len(picked)-2 { - sess.Write(name + " and ") + case "talk", "speak", "ask": + if len(args) == 0 { + sess.WriteLine("Talk to whom?") } else { - sess.Write(name + ", ") - } - } -} - -func (g *Game) doGet(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - ground := g.World.GroundItems(p.RoomID) - if len(ground) == 0 { - sess.WriteLine("There's nothing on the ground to pick up.") - return - } - - matches := g.findGroundMatches(input, p.RoomID) - if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) - return - } - - if len(matches) > 1 { - unique := uniqueItemNames(matches) - if len(unique) > 1 { - sess.WriteLine("Which one?") - for _, name := range unique { - sess.WriteLine(fmt.Sprintf(" - %s", name)) - } + g.StartAction(sess, "talk", strings.Join(args, " ")) return } + default: + sess.WriteLine("Unknown command.") } - itemID := matches[0].ID - - // Credits go to the credits field, not inventory - if itemID == "credits" { - g.pickupCredits(sess, p, itemID) - return - } - - def, _ := g.ItemStore.Load(itemID) - - freeSlot := p.FirstFreeSlot() - if freeSlot == -1 { - sess.WriteLine("Your inventory is full.") - return - } - - qty := 1 - if def != nil && def.Stackable { - ground := g.World.GroundItems(p.RoomID) - if gqty, ok := ground[itemID]; ok && gqty > 0 { - qty = gqty - } - } - // Check if we can stack - if def != nil && def.Stackable { - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot != nil && slot.ItemID == itemID { - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { - sess.WriteLine("That's not yours!") - return - } - _ = removed - slot.Quantity += qty - g.AccountStore.SaveCharacter(p) - name := def.Name - sess.WriteLine(fmt.Sprintf("You pick up a %s. (now %d)", name, slot.Quantity)) - return - } - } - } - - removed, ok := g.World.RemoveReservedGroundItem(p.RoomID, itemID, qty, p.Name) - if !ok { - sess.WriteLine("That's not yours!") - return - } - _ = removed - p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: qty}) - g.AccountStore.SaveCharacter(p) - name := itemID - if def != nil { - name = def.Name - } - sess.WriteLine(fmt.Sprintf("You pick up a %s.", name)) -} - -func (g *Game) doDrop(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - - matches := g.findInventoryMatches(input, p) - if len(matches) == 0 { - sess.WriteLine(fmt.Sprintf("You don't have any '%s'.", input)) - return - } - - unique := uniqueItemNames(matches) - if len(unique) > 1 { - sess.WriteLine("Which one?") - for _, name := range unique { - sess.WriteLine(fmt.Sprintf(" - %s", name)) - } - return - } - - itemID := matches[0].ID - slotIdx := matches[0].Slot - slot := p.InvSlot(slotIdx) - def, _ := g.ItemStore.Load(itemID) - - qty := 1 - name := itemID - if def != nil { - name = def.Name - } - - if slot.Quantity > qty { - slot.Quantity -= qty - } else { - p.SetInvSlot(slotIdx, nil) - } - - g.World.AddGroundItem(p.RoomID, itemID, qty) - g.AccountStore.SaveCharacter(p) - sess.WriteLine(fmt.Sprintf("You drop a %s.", name)) -} - -type itemMatch struct { - ID string - Name string - Slot int // inventory slot index, -1 for ground -} - -func (g *Game) findGroundMatches(input string, roomID int) []itemMatch { - ground := g.World.GroundItems(roomID) - var matches []itemMatch - for itemID := range ground { - def, err := g.ItemStore.Load(itemID) - if err != nil { - continue - } - if def.MatchesName(input) { - matches = append(matches, itemMatch{ID: itemID, Name: def.Name, Slot: -1}) - } - } - return matches -} - -func (g *Game) findInventoryMatches(input string, p *player.Player) []itemMatch { - var matches []itemMatch - for i := 0; i < 28; i++ { - slot := p.InvSlot(i) - if slot == nil { - continue - } - def, err := g.ItemStore.Load(slot.ItemID) - if err != nil { - continue - } - if def.MatchesName(input) { - matches = append(matches, itemMatch{ID: slot.ItemID, Name: def.Name, Slot: i}) - } - } - return matches -} - -func uniqueItemNames(matches []itemMatch) []string { - seen := make(map[string]bool) - var out []string - for _, m := range matches { - if !seen[m.Name] { - seen[m.Name] = true - out = append(out, m.Name) - } - } - return out -} - -func (g *Game) doTickTest(sess *net.Session) { - count := 0 - id := g.Ticks.Subscribe(5, func() bool { - count++ - sess.WriteLine(fmt.Sprintf("[Tick #%d] 600ms heartbeat is working.", count)) - if count >= 3 { - sess.WriteLine("[Tick test complete.]") - return false - } - return true - }) - _ = id - sess.WriteLine("Tick test started — you'll see 3 messages at 5-tick intervals while commands still work.") -} - -func (g *Game) doAttack(sess *net.Session, input string) { - p := sess.Player.(*player.Player) - - if combat.GetCombat(p.Name) != nil { - sess.WriteLine("You are already in combat!") - return - } - - mob := g.findMob(sess, input, p.RoomID) - if mob == nil { - return - } - - if mob.HP <= 0 { - sess.WriteLine("That is already dead.") - return - } - - if mob.Protected { - sess.WriteLine(fmt.Sprintf("You can't attack %s!", mobDisplayName(mob, true))) - return - } - - if combat.IsMobInCombat(mob.InstanceID) { - sess.WriteLine(fmt.Sprintf("%s is already engaged in combat!", mobDisplayName(mob, false))) - return - } - - g.startCombat(sess, p, mob) -} - -func (g *Game) findMob(sess *net.Session, input string, roomID int) *world.MobInstance { - lower := strings.ToLower(input) - mobs := g.MobStore.MobsInRoom(roomID) - - idx := -1 - name := lower - if dotPos := strings.Index(lower, "."); dotPos > 0 { - if n, err := strconv.Atoi(lower[:dotPos]); err == nil && n > 0 { - idx = n - name = lower[dotPos+1:] - } - } - - var exact []*world.MobInstance - var prefix []*world.MobInstance - for _, m := range mobs { - q := m.MatchQuality(name) - if q == world.MatchExact { - exact = append(exact, m) - } else if q == world.MatchPrefix { - prefix = append(prefix, m) - } - } - - // Prefer exact matches - candidates := exact - if len(candidates) == 0 { - candidates = prefix - } - - if len(candidates) == 0 { - sess.WriteLine(fmt.Sprintf("There's no '%s' here.", input)) - return nil - } - - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].InstanceID < candidates[j].InstanceID - }) - - if idx > 0 { - if idx-1 < len(candidates) { - return candidates[idx-1] - } - return nil - } - - if len(candidates) == 1 { - return candidates[0] - } - - // Multiple candidates — different names means ambiguous - seen := make(map[string]bool) - for _, m := range candidates { - seen[mobDisplayName(m, false)] = true - } - if len(seen) > 1 { - sess.WriteLine("Which one?") - return nil - } - // Same name — return the first - return candidates[0] -} - -func (g *Game) seedRoomMobs(roomID int) { - room, err := g.World.LoadRoom(roomID) - if err != nil { - return - } - if len(room.Mobs) == 0 { - return - } - g.MobStore.SeedMobs(roomID, room.Mobs) -} - -func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - g.cancelRest(p.Name) - - instanceID := g.findMobInstanceID(mob) - - combat.EnterCombat(p.Name, instanceID) - - playerSpeed := g.playerWeaponSpeed(p) - - attBonus, strBonus, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - var styleParts []string - if attBonus > 0 { - styleParts = append(styleParts, fmt.Sprintf("+%d atk", attBonus)) - } - if strBonus > 0 { - styleParts = append(styleParts, fmt.Sprintf("+%d str", strBonus)) - } - if defBonus > 0 { - styleParts = append(styleParts, fmt.Sprintf("+%d def", defBonus)) - } - styleStr := "" - if len(styleParts) > 0 { - styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")" - } - sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", mobDisplayName(mob, true), styleStr)) - - // Player tick callback - g.Ticks.Subscribe(playerSpeed, func() bool { - cs := combat.GetCombat(p.Name) - if cs == nil || !cs.Active { - return false - } - currentMob := g.MobStore.GetInstance(cs.MobID) - if currentMob == nil || currentMob.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - g.playerAttack(sess, p, currentMob) - if currentMob.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - return true - }) - - // Mob tick callback - g.Ticks.Subscribe(mob.Speed, func() bool { - cs := combat.GetCombat(p.Name) - if cs == nil || !cs.Active { - return false - } - currentMob := g.MobStore.GetInstance(cs.MobID) - if currentMob == nil || currentMob.HP <= 0 { - return false - } - combat.RecordMobAttack(p.Name) - g.mobAttack(sess, p, currentMob) - if p.HP <= 0 { - g.endCombat(sess, p, currentMob) - return false - } - return true - }) -} - -func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) - - equipAtt := 0 - equipStr := 0 - if itemID, ok := p.Equipment[object.SlotMainHand]; ok { - def, err := g.ItemStore.Load(itemID) - if err == nil { - equipAtt = def.Stats.AttackBonus - equipStr = def.Stats.StrengthBonus - } - } - - attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt) - defRoll := combat.DefenseRoll(mob.Defense, 0, 0) - - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(p.Level(player.Strength), strBonus, equipStr) - dmg := combat.RollDamage(maxHit) - - mob.HP -= dmg - if mob.HP < 0 { - mob.HP = 0 - } - if mob.HP < mob.MaxHP && mob.HP > 0 { - mob.StartRegen() - } - gains := g.awardCombatXP(p, dmg) - - 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 %d damage.", mobName, dmg) - hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP) - line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart) - if p.Toggles["xpdrops"] && len(gains) > 0 { - var parts []string - for _, g := range gains { - parts = append(parts, fmt.Sprintf("+%dxp %s", g.XP, player.SkillAbbr[g.Skill])) - } - line += " (" + strings.Join(parts, ", ") + ")" - } - sess.WriteLine(line) - } else { - sess.WriteLine(fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))) - } -} - -func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - _, _, defBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - - equipDef := 0 - for _, itemID := range p.Equipment { - def, err := g.ItemStore.Load(itemID) - if err == nil { - equipDef += def.Stats.DefenseBonus - } - } - - attRoll := combat.AttackRoll(mob.Attack, 0, 0) - defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef) - - if combat.HitCheck(attRoll, defRoll) { - maxHit := combat.MaxHit(mob.Strength, 0, 0) - dmg := combat.RollDamage(maxHit) - - 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 %d damage.", attacker, dmg) - hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP()) - sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)) - } else { - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - sess.WriteLine(fmt.Sprintf(" %s misses you.", attacker)) - } -} - -func (g *Game) DisconnectTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - if !sess.Disconnecting { - continue - } - p, ok := sess.Player.(*player.Player) - if !ok { - g.Hub.Remove(sess) - continue - } - if combat.GetCombat(p.Name) != nil { - continue - } - sess.DisconnectTicks-- - if sess.DisconnectTicks <= 0 { - g.AccountStore.SaveCharacter(p) - g.Hub.HardRemove(sess) - } - } -} - -func (g *Game) RegenTick() { - if g.Hub == nil { - return - } - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok { - continue - } - if p.HP <= 0 || p.HP >= p.MaxHP() { - p.RegenerateTick = 0 - continue - } - p.RegenerateTick-- - if p.RegenerateTick <= 0 { - p.HP++ - if p.HP >= p.MaxHP() { - p.HP = p.MaxHP() - p.RegenerateTick = 0 - } else { - p.RegenerateTick = 100 - } - } - } -} - -func (g *Game) roomsWithinRange(homeID int, maxDist int) map[int]bool { - reachable := map[int]bool{homeID: true} - if maxDist <= 0 { - return reachable - } - frontier := []int{homeID} - dist := map[int]int{homeID: 0} - for len(frontier) > 0 { - current := frontier[0] - frontier = frontier[1:] - if dist[current] >= maxDist { - continue - } - room, err := g.World.LoadRoom(current) - if err != nil { - continue - } - for _, targetID := range room.Exits { - if _, ok := dist[targetID]; ok { - continue - } - dist[targetID] = dist[current] + 1 - reachable[targetID] = true - frontier = append(frontier, targetID) - } - } - return reachable -} - -func (g *Game) WanderTick() { - if g.Hub == nil { - return - } - - type moveEvent struct { - inst *world.MobInstance - fromRoom int - toRoom int - exitDir world.ExitDir - } - var moves []moveEvent - - for _, inst := range g.MobStore.AllInstances() { - if inst.HP <= 0 || inst.Wander <= 0 || inst.WanderTick <= 0 { - continue - } - if combat.IsMobInCombat(inst.InstanceID) { - continue - } - inst.WanderTickCounter++ - if inst.WanderTickCounter >= inst.WanderTick { - inst.WanderTickCounter = 0 - - if rand.Float64() < inst.WanderChance { - room, err := g.World.LoadRoom(inst.RoomID) - if err != nil || len(room.Exits) == 0 { - continue - } - - reachable := g.roomsWithinRange(inst.HomeRoomID, inst.Wander) - var validDirs []world.ExitDir - for dir, targetID := range room.Exits { - if reachable[targetID] { - validDirs = append(validDirs, dir) - } - } - if len(validDirs) > 0 { - dir := validDirs[rand.Intn(len(validDirs))] - moves = append(moves, moveEvent{inst, inst.RoomID, room.Exits[dir], dir}) - } - } - } - } - - for _, m := range moves { - m.inst.RoomID = m.toRoom - for _, sess := range g.Hub.AllSessions() { - p, ok := sess.Player.(*player.Player) - if !ok { - continue - } - if p.RoomID == m.fromRoom && p.Toggles["mobleave"] { - sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves %s.", mobDisplayName(m.inst, false), mobCombatLevel(m.inst), m.exitDir)) - } - if p.RoomID == m.toRoom && p.Toggles["mobenter"] { - sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters from the %s.", mobDisplayName(m.inst, false), mobCombatLevel(m.inst), world.OppositeExit[m.exitDir])) - } - } - } -} - -func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInstance) { - g.combatPadWidth = 0 - combat.LeaveCombat(p.Name) - - if p.HP <= 0 { - sess.WriteLine(fmt.Sprintf("\nOh dear, you are dead!")) - g.dropItemsOnDeath(p) - p.HP = p.MaxHP() - p.RoomID = 1 - g.AccountStore.SaveCharacter(p) - if g.Hub != nil { - g.Hub.EnterRoom(sess, p.RoomID) - } - g.doLook(sess) - return - } - - if mob != nil && mob.HP <= 0 { - sess.WriteLine(fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))) - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob))) - } - } - } - - // Always drop remains - if mob.Drops.Remains != "" { - g.World.AddReservedGroundItem(p.RoomID, mob.Drops.Remains, 1, p.Name) - def, _ := g.ItemStore.Load(mob.Drops.Remains) - name := mob.Drops.Remains - if def != nil { - name = def.Name - } - dropper := mob.Name - if !mob.Unique { - dropper = "The " + mob.Name - } - sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name)) - } - - // Weighted loot roll — exactly one result - if len(mob.Drops.Loot) > 0 { - totalWeight := 0 - for _, e := range mob.Drops.Loot { - totalWeight += e.Weight - } - roll := randInt(totalWeight) - cumulative := 0 - for _, e := range mob.Drops.Loot { - cumulative += e.Weight - if roll < cumulative { - g.World.AddReservedGroundItem(p.RoomID, e.ItemID, e.Quantity, p.Name) - def, _ := g.ItemStore.Load(e.ItemID) - name := e.ItemID - if def != nil { - name = def.Name - } - dropper := mob.Name - if !mob.Unique { - dropper = "The " + mob.Name - } - if e.Quantity > 1 { - sess.WriteLine(fmt.Sprintf(" %s drops: %d x %s", dropper, e.Quantity, name)) - } else { - sess.WriteLine(fmt.Sprintf(" %s drops: %s", dropper, name)) - } - break - } - } - } - - // Schedule respawn - instanceID := g.findMobInstanceID(mob) - respawnTicks := mob.RespawnTicks - if respawnTicks <= 0 { - respawnTicks = 30 - } - g.Ticks.Subscribe(respawnTicks, func() bool { - g.respawnMob(instanceID) - return false - }) - } -} - -type xpGain struct { - Skill player.SkillName - XP int -} - -func (g *Game) awardCombatXP(p *player.Player, dmg int) []xpGain { - baseXP := dmg * 4 - var gains []xpGain - - switch p.AttackStyle { - case player.Accurate: - gains = []xpGain{{player.Attack, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}} - case player.Aggressive: - gains = []xpGain{{player.Strength, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}} - case player.Defensive: - gains = []xpGain{{player.Defense, baseXP * 3 / 4}, {player.Hitpoints, baseXP / 4}} - case player.Balanced: - quarter := baseXP / 4 - gains = []xpGain{{player.Attack, quarter}, {player.Strength, quarter}, {player.Defense, quarter}, {player.Hitpoints, quarter}} - } - - for _, g := range gains { - p.AddXP(g.Skill, g.XP) - } - g.AccountStore.SaveCharacter(p) - return gains -} - -type deathDrop struct { - itemID string - quantity int - totalVal int - isEquip bool - equipSlot object.EquipSlot - invSlot int -} - -func (g *Game) dropItemsOnDeath(p *player.Player) { - roomID := p.RoomID - - // Credits always drop on death - if p.Credits > 0 { - g.World.AddGroundItem(roomID, "credits", p.Credits) - p.Credits = 0 - } - - var items []deathDrop - - // Inventory - 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, - }) - } - - // Equipment - 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) - } -} - -func (g *Game) stopCombat(playerName string) { - cs := combat.GetCombat(playerName) - if cs == nil { - return - } - combat.LeaveCombat(playerName) -} - -func (g *Game) respawnMob(instanceID string) { - inst := g.MobStore.GetInstance(instanceID) - if inst == nil { - return - } - homeRoom := inst.HomeRoomID - inst.RoomID = homeRoom - inst.HP = inst.MaxHP - g.MobStore.RollIdleDescription(inst) - - if g.Hub != nil { - for _, sess := range g.Hub.PlayersInRoom(homeRoom) { - if p, ok := sess.Player.(*player.Player); ok && p.Toggles["mobspawn"] { - sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters the area.", mobDisplayName(inst, false), mobCombatLevel(inst))) - } - } - } -} - -func (g *Game) findMobInstanceID(mob *world.MobInstance) string { - return mob.InstanceID -} - -func (g *Game) playerWeaponSpeed(p *player.Player) int { - 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 // unarmed speed -} - -func mobDisplayName(m *world.MobInstance, definite bool) string { - if m.Unique { - return m.Name - } - if definite { - return "the " + m.Name - } - return "a " + m.Name -} - -func mobCombatLevel(m *world.MobInstance) int { - base := 0.25 * float64(m.Defense+m.MaxHP+m.Defense) - base += 0.25 * float64(m.Attack+m.Strength) - return int(base) -} - -func randInt(max int) int { - if max <= 0 { - return 0 - } - return combat.RollDamage(max) - 1 + sess.Write("\r\n> ") } diff --git a/internal/game/session.go b/internal/game/session.go new file mode 100644 index 0000000..edc904d --- /dev/null +++ b/internal/game/session.go @@ -0,0 +1,500 @@ +package game + +import ( + "fmt" + "os" + "strings" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/player" +) + +func (g *Game) handleAccountName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if name == "" { + sess.Write("Account name: ") + return + } + actualName, err := g.AccountStore.FindAccount(name) + if err == nil { + sess.Account = &net.AccountEntry{Name: actualName} + sess.State = net.StatePassword + sess.Write("Password: ") + } else { + sess.Account = &net.AccountEntry{Name: name} + sess.State = net.StateNewAccountPass + sess.Write("A new visitor to this rock!\nChoose password: ") + } +} + +func (g *Game) handlePassword(sess *net.Session, input string) { + if input == "" { + sess.Write("Password: ") + return + } + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.WriteLine("Error loading account.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if !player.CheckPassword(input, acc.PasswordHash) { + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + sess.Account = &net.AccountEntry{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + Characters: acc.Characters, + } + g.showMenu(sess) +} + +func (g *Game) handleNewAccountPass(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + sess.PendingPass = input + sess.State = net.StateNewAccountConfirm + sess.Write("Confirm password: ") +} + +func (g *Game) handleNewAccountConfirm(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" { + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + if input != sess.PendingPass { + sess.WriteLine("Passwords do not match.") + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + hash, err := player.HashPassword(input) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + acc := &player.Account{ + Name: sess.Account.Name, + PasswordHash: hash, + } + if err := g.AccountStore.SaveAccount(acc); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.Account = nil + sess.State = net.StateAccountName + sess.Write("Account name: ") + return + } + + sess.Account = &net.AccountEntry{ + Name: acc.Name, + PasswordHash: acc.PasswordHash, + } + sess.PendingPass = "" + g.showMenu(sess) +} + +func (g *Game) showMenu(sess *net.Session) { + sess.State = net.StateMenu + lines := []string{ + "", + fmt.Sprintf("SUCCESSFUL LOGIN as %s.", sess.Account.Name), + "", + } + if len(sess.Account.Characters) > 0 { + lines = append(lines, + " (C)onnect character to TC", + "", + " (L)ist characters", + " (R)ename character", + " (D)elete character", + ) + } + lines = append(lines, + " (N)ew character", + " (P)urge account", + " (A)ccount rename", + " (Q)uit", + "", + "INPUT: ", + ) + sess.WriteLines(lines...) +} + +func (g *Game) handleMenu(sess *net.Session, input string) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "": + sess.Write("INPUT: ") + case "c": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nSYSTEM ERROR: Make a character with 'N' first!") + sess.Write("\nINPUT: ") + return + } + if len(sess.Account.Characters) == 1 { + g.connectCharacter(sess, sess.Account.Characters[0]) + return + } + sess.WriteLine("\nSelect character:") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.State = net.StateNewCharName + sess.PendingChar = "connect" + sess.Write("\nChoice: ") + + case "l": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nSYSTEM ERROR: Zero characters on this account.\n(Make a character with 'N' first)") + } else { + sess.WriteLine("\nSELECT Humans FROM GaiaZeroFour:") + for _, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" - %s", name)) + } + } + sess.Write("\nINPUT: ") + + case "n": + sess.State = net.StateNewCharName + sess.PendingChar = "" + sess.Write("What's your character name?: ") + + case "a": + sess.State = net.StateRenameAccount + sess.Write("New account name: ") + + case "r": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters to rename.") + sess.Write("\nChoice: ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("\nRenaming %s.", sess.PendingChar)) + sess.Write("New name: ") + return + } + sess.State = net.StateRenameChar + sess.WriteLine("\nRename which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\n> : ") + + case "d": + if len(sess.Account.Characters) == 0 { + sess.WriteLine("\nNo characters to delete.") + sess.Write("\nINPUT: ") + return + } + if len(sess.Account.Characters) == 1 { + sess.PendingChar = sess.Account.Characters[0] + } else { + sess.State = net.StateDeleteChar + sess.WriteLine("\nDelete which character?") + for i, name := range sess.Account.Characters { + sess.WriteLine(fmt.Sprintf(" %d) %s", i+1, name)) + } + sess.Write("\n>: ") + return + } + g.showDeleteConfirm(sess) + + case "p": + sess.State = net.StatePurgeAccount + sess.WriteLine(fmt.Sprintf("\nPurge account %s and all characters?\nTo be clear you are about to PERMANENTLY DELETE EVERYTHING!\nType PURGE %s to confirm, or anything else to cancel.", sess.Account.Name, sess.Account.Name)) + sess.Write("\n> ") + + case "q": + sess.WriteLine("Later.") + sess.Conn.Close() + return + + default: + sess.Write("SYNTAX ERROR\nINPUT: ") + } +} + +func (g *Game) handleNewCharName(sess *net.Session, input string) { + name := strings.TrimSpace(input) + + if sess.PendingChar == "connect" && len(sess.Account.Characters) > 0 { + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = "" + g.connectCharacter(sess, sess.Account.Characters[idx-1]) + return + } + sess.WriteLine("Invalid choice.") + sess.Write("\nChoice: ") + return + } + + if name == "" { + sess.Write("Character name: ") + return + } + + if g.AccountStore.CharacterExists(name) { + sess.WriteLine("A character with that name already exists.") + sess.Write("Character name: ") + return + } + + p := player.New(name) + p.RoomID = 1 + + if err := g.AccountStore.SaveCharacter(p); err != nil { + sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc.Characters = append(acc.Characters, name) + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + g.connectCharacter(sess, name) +} + +func (g *Game) connectCharacter(sess *net.Session, name string) { + g.charsMu.Lock() + if existing := g.loggedInChars[name]; existing != nil { + g.charsMu.Unlock() + sess.WriteLine("This character is logged in elsewhere.") + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + g.charsMu.Unlock() + sess.WriteLine(fmt.Sprintf("Error loading character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } + + sess.Player = p + sess.State = net.StateGame + g.loggedInChars[name] = sess + g.charsMu.Unlock() + + g.World.SeedGroundItems(p.RoomID) + g.seedRoomMobs(p.RoomID) + g.ensureRoomObjects(p.RoomID) + + if g.Hub != nil { + g.Hub.EnterRoom(sess, p.RoomID) + } + + g.doLook(sess) + sess.Write("\r\n> ") +} + +func (g *Game) handleRenameAccount(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + if newName == "" { + sess.Write("New account name: ") + return + } + oldName := sess.Account.Name + if newName == oldName { + sess.WriteLine("That's already your account name.") + g.showMenu(sess) + return + } + if g.AccountStore.AccountExists(newName) { + sess.WriteLine("An account with that name already exists.") + sess.Write("New account name: ") + return + } + + oldPath := g.AccountStore.AccountPath(oldName) + newPath := g.AccountStore.AccountPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming account: %v", err)) + g.showMenu(sess) + return + } + + sess.Account.Name = newName + sess.WriteLine(fmt.Sprintf("Account renamed to %s.", newName)) + g.showMenu(sess) +} + +func (g *Game) handleRenameChar(sess *net.Session, input string) { + name := strings.TrimSpace(input) + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + g.showMenu(sess) + return + } + + sess.State = net.StateRenameCharName + sess.WriteLine(fmt.Sprintf("Renaming %s.", sess.PendingChar)) + sess.Write("New name: ") +} + +func (g *Game) handleRenameCharName(sess *net.Session, input string) { + newName := strings.TrimSpace(input) + oldName := sess.PendingChar + if newName == "" { + sess.Write("New name: ") + return + } + if newName == oldName { + sess.WriteLine("That's already the character's name.") + g.showMenu(sess) + return + } + if g.AccountStore.CharacterExists(newName) { + sess.WriteLine("A character with that name already exists.") + sess.Write("New name: ") + return + } + + oldPath := g.AccountStore.CharPath(oldName) + newPath := g.AccountStore.CharPath(newName) + if err := os.Rename(oldPath, newPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) + return + } + + p, _ := g.AccountStore.LoadCharacter(newName) + if p != nil { + p.Name = newName + g.AccountStore.SaveCharacter(p) + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + for i, c := range acc.Characters { + if c == oldName { + acc.Characters[i] = newName + break + } + } + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = acc.Characters + + sess.PendingChar = "" + sess.WriteLine(fmt.Sprintf("%s renamed to %s.", oldName, newName)) + g.showMenu(sess) +} + +func (g *Game) showDeleteConfirm(sess *net.Session) { + sess.State = net.StateDeleteChar + sess.WriteLine(fmt.Sprintf("\nDelete %s? Type DELETE %s to confirm, or anything else to cancel.", sess.PendingChar, sess.PendingChar)) +} + +func (g *Game) handleDeleteChar(sess *net.Session, input string) { + if sess.PendingChar == "" { + name := strings.TrimSpace(input) + if idx, err := parseIndex(name); err == nil && idx > 0 && idx <= len(sess.Account.Characters) { + sess.PendingChar = sess.Account.Characters[idx-1] + } else { + sess.PendingChar = name + } + found := false + for _, c := range sess.Account.Characters { + if c == sess.PendingChar { + found = true + break + } + } + if !found { + sess.WriteLine(fmt.Sprintf("No character named %s on this account.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + g.showDeleteConfirm(sess) + return + } + + input = strings.TrimSpace(input) + expected := "DELETE " + sess.PendingChar + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Delete cancelled.") + sess.PendingChar = "" + g.showMenu(sess) + return + } + + charPath := g.AccountStore.CharPath(sess.PendingChar) + if err := os.Remove(charPath); err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + sess.PendingChar = "" + g.showMenu(sess) + return + } + + acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + var newChars []string + for _, c := range acc.Characters { + if c != sess.PendingChar { + newChars = append(newChars, c) + } + } + acc.Characters = newChars + g.AccountStore.SaveAccount(acc) + sess.Account.Characters = newChars + + sess.WriteLine(fmt.Sprintf("%s has been deleted.", sess.PendingChar)) + sess.PendingChar = "" + g.showMenu(sess) +} + +func (g *Game) handlePurgeAccount(sess *net.Session, input string) { + input = strings.TrimSpace(input) + expected := "PURGE " + sess.Account.Name + if strings.ToUpper(input) != strings.ToUpper(expected) { + sess.WriteLine("Purge cancelled.") + g.showMenu(sess) + return + } + + for _, name := range sess.Account.Characters { + os.Remove(g.AccountStore.CharPath(name)) + } + + os.Remove(g.AccountStore.AccountPath(sess.Account.Name)) + + sess.WriteLine(fmt.Sprintf("Account %s has been purged. Thanks for playing.", sess.Account.Name)) + sess.Conn.Close() +} diff --git a/internal/game/tick.go b/internal/game/tick.go new file mode 100644 index 0000000..9595668 --- /dev/null +++ b/internal/game/tick.go @@ -0,0 +1,141 @@ +package game + +import ( + "fmt" + "math/rand" + + "thirdcollapse/internal/combat" + "thirdcollapse/internal/player" +) + +func (g *Game) DisconnectTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + if !sess.Disconnecting { + continue + } + p, ok := sess.Player.(*player.Player) + if !ok { + g.Hub.Remove(sess) + continue + } + if combat.GetCombat(p.Name) != nil { + continue + } + sess.DisconnectTicks-- + if sess.DisconnectTicks <= 0 { + g.AccountStore.SaveCharacter(p) + g.Hub.HardRemove(sess) + } + } +} + +func (g *Game) RegenTick() { + if g.Hub == nil { + return + } + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok { + continue + } + if p.HP <= 0 || p.HP >= p.MaxHP() { + p.RegenerateTick = 0 + continue + } + p.RegenerateTick-- + if p.RegenerateTick <= 0 { + p.HP++ + if p.HP >= p.MaxHP() { + p.HP = p.MaxHP() + p.RegenerateTick = 0 + } else { + p.RegenerateTick = 100 + } + } + } +} + +func (g *Game) WanderTick() { + if g.Hub == nil { + return + } + + type moveEvent struct { + name string + fromRoom int + toRoom int + level int + } + + var moves []moveEvent + + // Mob wandering + for _, inst := range g.MobStore.AllInstances() { + if inst.HP <= 0 || len(inst.WanderRooms) == 0 || inst.WanderInterval <= 0 { + continue + } + if combat.IsMobInCombat(inst.InstanceID) { + continue + } + inst.WanderTickCounter++ + if inst.WanderTickCounter >= inst.WanderInterval { + inst.WanderTickCounter = 0 + toRoom := inst.WanderRooms[rand.Intn(len(inst.WanderRooms))] + if toRoom == inst.RoomID { + continue + } + moves = append(moves, moveEvent{ + name: mobDisplayName(inst, false), + fromRoom: inst.RoomID, + toRoom: toRoom, + level: mobCombatLevel(inst), + }) + inst.RoomID = toRoom + } + } + + // Object wandering (fishing spots, etc.) + g.World.TickObjWander() + 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.Action.TargetID == om.DefID { + g.CancelAction(p) + } + } + } + moves = append(moves, moveEvent{ + name: "a " + om.Name, + fromRoom: om.FromRoom, + toRoom: om.ToRoom, + level: 0, + }) + } + + for _, m := range moves { + for _, sess := range g.Hub.AllSessions() { + p, ok := sess.Player.(*player.Player) + if !ok { + continue + } + if p.RoomID == m.fromRoom && p.Toggles["mobleave"] { + if m.level > 0 { + sess.WriteLine(fmt.Sprintf("\n%s (level %d) leaves.", m.name, m.level)) + } else { + sess.WriteLine(fmt.Sprintf("\n%s moves away.", m.name)) + } + } + if p.RoomID == m.toRoom && p.Toggles["mobenter"] { + if m.level > 0 { + sess.WriteLine(fmt.Sprintf("\n%s (level %d) enters.", m.name, m.level)) + } else { + sess.WriteLine(fmt.Sprintf("\n%s drifts in.", m.name)) + } + } + } + } +} diff --git a/internal/game/utils.go b/internal/game/utils.go new file mode 100644 index 0000000..0559f4f --- /dev/null +++ b/internal/game/utils.go @@ -0,0 +1,114 @@ +package game + +import ( + "fmt" + "math/rand" + + "thirdcollapse/internal/net" + "thirdcollapse/internal/object" + "thirdcollapse/internal/player" + "thirdcollapse/internal/world" +) + +var EquipSlots = []object.EquipSlot{ + object.SlotHead, object.SlotNeck, object.SlotTorso, object.SlotLegs, + object.SlotHands, object.SlotFeet, object.SlotBack, object.SlotAmmo, + object.SlotMainHand, object.SlotOffHand, object.SlotRing, +} + +type itemMatch struct { + ID string + Name string + Slot int +} + +type xpGain struct { + Skill string + XP int +} + +type deathDrop struct { + itemID string + quantity int + totalVal int + isEquip bool + equipSlot object.EquipSlot + invSlot int +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +func parseIndex(s string) (int, error) { + var idx int + _, err := fmt.Sscanf(s, "%d", &idx) + return idx, err +} + +func mobDisplayName(m *world.MobInstance, definite bool) string { + if m.Unique { + return m.Name + } + if definite { + return "the " + m.Name + } + return "a " + m.Name +} + +func mobCombatLevel(m *world.MobInstance) int { + return int(0.25*float64(m.Attack+m.Strength+m.Defense+m.MaxHP) + 0.5) +} + +func uniqueItemNames(matches []itemMatch) []string { + seen := make(map[string]bool) + var out []string + for _, m := range matches { + if !seen[m.Name] { + seen[m.Name] = true + out = append(out, m.Name) + } + } + return out +} + +func randInt(max int) int { + if max <= 0 { + return 0 + } + return rand.Intn(max) +} + +func innerPickupReport(sess *net.Session, picked []string) { + for i, name := range picked { + if i == len(picked)-1 { + sess.WriteLine(name) + } else if i == len(picked)-2 { + sess.Write(name + " and ") + } else { + sess.Write(name + ", ") + } + } +} + +func actionDesc(p *player.Player) string { + if p.Action == nil { + return "" + } + target := p.Action.TargetName + if idx, ok := p.Action.Data["instance_idx"]; ok { + target += fmt.Sprintf(" [%d]", idx) + } + switch p.Action.Type { + case "gather": + return "mining a " + target + case "talk": + return "talking to " + target + case "use": + return "using a " + target + } + return "" +} diff --git a/internal/net/server.go b/internal/net/server.go index e5761a2..8fb975f 100644 --- a/internal/net/server.go +++ b/internal/net/server.go @@ -25,6 +25,7 @@ const ( StatePurgeAccount StateGame StateChangeDescription + StateTalk ) type Session struct { diff --git a/internal/object/item.go b/internal/object/item.go index 84e43b1..170e07f 100644 --- a/internal/object/item.go +++ b/internal/object/item.go @@ -31,13 +31,15 @@ type ItemDef struct { Name string `yaml:"name"` Aliases []string `yaml:"aliases"` Description string `yaml:"description"` - Value int `yaml:"value"` // credits + Value int `yaml:"value"` Stackable bool `yaml:"stackable"` EquipSlot EquipSlot `yaml:"equip_slot"` WeaponType WeaponType `yaml:"weapon_type"` Stats ItemStats `yaml:"stats"` - Speed int `yaml:"speed"` // ticks between attacks - Toolbelt bool `yaml:"toolbelt"` // can go on toolbelt + Speed int `yaml:"speed"` + Toolbelt bool `yaml:"toolbelt"` + ToolType string `yaml:"tool_type"` + ToolSpeed int `yaml:"tool_speed"` } type ItemStats struct { diff --git a/internal/object/object.go b/internal/object/object.go index 3b8741a..edee3ab 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -1,8 +1,8 @@ package object type ObjectDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Behavior string `yaml:"behavior"` - Props map[string]any `yaml:"props"` + ID string `yaml:"id"` + Name string `yaml:"name"` + BehaviorID string `yaml:"behavior"` + Props map[string]any `yaml:"props"` } diff --git a/internal/player/player.go b/internal/player/player.go index ad56923..d6e450a 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -1,5 +1,6 @@ package player +import "thirdcollapse/internal/action" import "thirdcollapse/internal/object" type SkillName string @@ -83,10 +84,10 @@ type InventorySlot struct { type Player struct { Name string `yaml:"name"` - Skills map[SkillName]int `yaml:"skills"` // xp - Inventory map[int]*InventorySlot `yaml:"inventory"` // slot 0-27 -> item - Equipment map[object.EquipSlot]string `yaml:"equipment"` // slot -> item_id - Toolbelt []string `yaml:"toolbelt"` // item_ids + Skills map[SkillName]int `yaml:"skills"` + Inventory map[int]*InventorySlot `yaml:"inventory"` + Equipment map[object.EquipSlot]string `yaml:"equipment"` + Toolbelt []string `yaml:"toolbelt"` RoomID int `yaml:"room_id"` HP int `yaml:"hp"` Credits int `yaml:"credits"` @@ -94,6 +95,7 @@ type Player struct { AttackStyle AttackStyle `yaml:"attack_style"` Toggles map[string]bool `yaml:"toggles"` RegenerateTick int + Action *action.Action `yaml:"-"` } func (p *Player) InvSlot(i int) *InventorySlot { @@ -189,3 +191,41 @@ func (p *Player) StartRegen() { p.RegenerateTick = 100 } } + +func (p *Player) HasToolbeltItem(itemID string) bool { + for _, id := range p.Toolbelt { + if id == itemID { + return true + } + } + return false +} + +func (p *Player) HasItem(itemID string) bool { + for _, slot := range p.Inventory { + if slot != nil && slot.ItemID == itemID && slot.Quantity > 0 { + return true + } + } + return false +} + +func (p *Player) RemoveItem(itemID string, qty int) bool { + remaining := qty + for i, slot := range p.Inventory { + if slot == nil || slot.ItemID != itemID { + continue + } + if slot.Quantity <= remaining { + remaining -= slot.Quantity + delete(p.Inventory, i) + } else { + slot.Quantity -= remaining + remaining = 0 + } + if remaining <= 0 { + return true + } + } + return remaining <= 0 +} diff --git a/internal/world/mob.go b/internal/world/mob.go index ebf97cf..9dfec59 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -8,24 +8,20 @@ import ( "strings" "sync" + "thirdcollapse/internal/action" "gopkg.in/yaml.v3" ) -type LootEntry struct { - ItemID string `yaml:"item_id"` - Weight int `yaml:"weight"` - Quantity int `yaml:"quantity"` -} - type DropTable struct { - Remains string `yaml:"remains"` - Loot []LootEntry `yaml:"loot"` + Remains string `yaml:"remains"` + Loot []action.DropEntry `yaml:"loot"` } 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"` @@ -37,33 +33,32 @@ type MobDef struct { Protected bool `yaml:"protected"` Unique bool `yaml:"unique"` RespawnTicks int `yaml:"respawn_ticks"` - Wander int `yaml:"wander"` - WanderTick int `yaml:"wander_tick"` - WanderChance float64 `yaml:"wander_chance"` + WanderRooms []int `yaml:"wander_rooms"` + WanderInterval int `yaml:"wander_interval"` Drops DropTable `yaml:"drops"` } type MobInstance struct { - InstanceID string - DefID string - Name string - HP int - MaxHP int - Attack int - Strength int - Defense int - Speed int - Aggressive bool - Protected bool - Unique bool - RespawnTicks int - RoomID int - HomeRoomID int - Drops DropTable - IdleDescription string - Wander int - WanderTick int - WanderChance float64 + InstanceID string + DefID string + Name string + BehaviorID string + HP int + MaxHP int + Attack int + Strength int + Defense int + Speed int + Aggressive bool + Protected bool + Unique bool + RespawnTicks int + RoomID int + HomeRoomID int + Drops DropTable + IdleDescription string + WanderRooms []int + WanderInterval int WanderTickCounter int regenerateTick int } @@ -147,44 +142,12 @@ func (s *MobStore) LoadDef(id string) (*MobDef, error) { return &def, nil } -func (s *MobStore) SpawnMob(defID string, roomID int, instanceID string) (*MobInstance, error) { - def, err := s.LoadDef(defID) - if err != nil { - return nil, err - } - s.mu.Lock() - defer s.mu.Unlock() - inst := &MobInstance{ - InstanceID: instanceID, - DefID: defID, - Name: def.Name, - HP: def.HP, - MaxHP: def.HP, - Attack: def.Attack, - Strength: def.Strength, - Defense: def.Defense, - Speed: def.Speed, - Aggressive: def.Aggressive, - RespawnTicks: def.RespawnTicks, - RoomID: roomID, - Drops: def.Drops, - } - s.instances[instanceID] = inst - return inst, nil -} - func (s *MobStore) GetInstance(id string) *MobInstance { s.mu.Lock() defer s.mu.Unlock() return s.instances[id] } -func (s *MobStore) RemoveInstance(id string) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.instances, id) -} - func (s *MobStore) AllInstances() []*MobInstance { s.mu.Lock() defer s.mu.Unlock() @@ -235,9 +198,8 @@ func (s *MobStore) RollIdleDescription(inst *MobInstance) { return } inst.IdleDescription = pickIdleDescription(def.IdleDescriptions) - inst.Wander = def.Wander - inst.WanderTick = def.WanderTick - inst.WanderChance = def.WanderChance + inst.WanderRooms = def.WanderRooms + inst.WanderInterval = def.WanderInterval inst.WanderTickCounter = 0 } @@ -269,6 +231,7 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) { InstanceID: instID, DefID: defID, Name: dw.def.Name, + BehaviorID: dw.def.BehaviorID, HP: dw.def.HP, MaxHP: dw.def.HP, Attack: dw.def.Attack, @@ -279,9 +242,8 @@ func (s *MobStore) SeedMobs(roomID int, mobIDs []string) { Protected: dw.def.Protected, Unique: dw.def.Unique, RespawnTicks: dw.def.RespawnTicks, - Wander: dw.def.Wander, - WanderTick: dw.def.WanderTick, - WanderChance: dw.def.WanderChance, + WanderRooms: dw.def.WanderRooms, + WanderInterval: dw.def.WanderInterval, RoomID: roomID, HomeRoomID: roomID, Drops: dw.def.Drops, diff --git a/internal/world/room.go b/internal/world/room.go index a92a428..6c2d0e3 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -1,5 +1,7 @@ package world +import "gopkg.in/yaml.v3" + type ExitDir string const ( @@ -41,13 +43,50 @@ type SpawnDef struct { RespawnTicks int `yaml:"respawn_ticks"` } +type ExitDef struct { + Room int `yaml:"room"` + Condition *ExitCondition `yaml:"condition"` + BlockedMessage string `yaml:"blocked_message"` +} + +func (e *ExitDef) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + var n int + if err := value.Decode(&n); err != nil { + return err + } + e.Room = n + return nil + } + type raw ExitDef + return value.Decode((*raw)(e)) +} + +type ExitCondition struct { + Flag string `yaml:"flag"` + Value any `yaml:"value"` + Not bool `yaml:"not"` +} + type Room struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - MapSymbol string `yaml:"map_symbol"` - Exits map[ExitDir]int `yaml:"exits"` - Objects []string `yaml:"objects"` - Spawns []SpawnDef `yaml:"spawns"` - Mobs []string `yaml:"mobs"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + MapSymbol string `yaml:"map_symbol"` + Exits map[ExitDir]ExitDef `yaml:"exits"` + Objects []RoomObject `yaml:"objects"` + Spawns []SpawnDef `yaml:"spawns"` + Mobs []string `yaml:"mobs"` + OnEnter []EnterStep `yaml:"on_enter"` +} + +type EnterStep struct { + Message string `yaml:"message"` + Condition *ExitCondition `yaml:"condition"` +} + +type RoomObject struct { + ID string `yaml:"id"` + WanderRooms []int `yaml:"wander_rooms"` + WanderInterval int `yaml:"wander_interval"` } diff --git a/internal/world/world.go b/internal/world/world.go index 1429081..71fd518 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -2,8 +2,10 @@ package world import ( "fmt" + "math/rand" "os" "path/filepath" + "sort" "strings" "sync" @@ -37,6 +39,177 @@ type World struct { mu sync.Mutex groundItems map[int][]*groundEntry seeded map[int]bool + objStates map[string]*ObjState + objMoves []ObjMove +} + +type ObjMove struct { + DefID string + Name string + FromRoom int + ToRoom int +} + +type ObjState struct { + Depleted bool + DepleteTimer int + DefID string + Name string + Index int + RoomID int + JustRespawned bool + WanderRooms []int + WanderInterval int + WanderCounter int +} + +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() + 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) 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 key, 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, + }) + _ = key + } + 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 { + // Check against display name (space-separated words) + for _, word := range strings.Fields(objName) { + if strings.HasPrefix(strings.ToLower(word), lower) { + return true + } + } + // Check defID both as whole and split by underscores + if strings.HasPrefix(strings.ToLower(defID), lower) { + return true + } + for _, part := range strings.Split(defID, "_") { + if strings.HasPrefix(strings.ToLower(part), lower) { + return true + } + } + return false +} + +func (w *World) SetObjName(roomID int, defID string, name string) { + w.mu.Lock() + defer w.mu.Unlock() + for key, st := range w.objStates { + if st.RoomID == roomID && st.DefID == defID { + st.Name = name + } + _ = key + } +} + +func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval int) { + w.mu.Lock() + defer w.mu.Unlock() + for key, st := range w.objStates { + if st.RoomID == roomID && st.DefID == defID { + st.WanderRooms = rooms + st.WanderInterval = interval + } + _ = key + } +} + +func (w *World) SetObjDepleted(roomID int, defID string, index int, delay int) { + 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 { @@ -44,6 +217,7 @@ func New(dataDir string) *World { dataDir: dataDir, groundItems: make(map[int][]*groundEntry), seeded: make(map[int]bool), + objStates: make(map[string]*ObjState), } } @@ -59,10 +233,7 @@ func (w *World) LoadRoom(id int) (*Room, error) { } room.ID = id if room.Exits == nil { - room.Exits = make(map[ExitDir]int) - } - if room.Objects == nil { - room.Objects = make([]string, 0) + room.Exits = make(map[ExitDir]ExitDef) } if room.Spawns == nil { room.Spawns = make([]SpawnDef, 0) @@ -261,4 +432,75 @@ func (w *World) Tick() { } } } + + for _, st := range w.objStates { + if st.Depleted && st.DepleteTimer > 0 { + st.DepleteTimer-- + if st.DepleteTimer <= 0 { + st.Depleted = false + st.JustRespawned = true + } + } + } +} + +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 st.WanderCounter >= st.WanderInterval { + 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 } |
