From c14c2e403c75a913e1a6d962fb6fe64f013adae5 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Wed, 1 Jul 2026 22:30:31 -0400 Subject: fix: combat formulas and mob attack types (mobs switch attack styles if safespotting) --- AGENTS.md | 2 +- building_guide/mobs.md | 67 +++--- building_guide/objects.md | 9 +- building_guide/quests.md | 37 ++-- data/help/attack.yaml | 13 ++ data/help/combat.yaml | 34 ++- data/help/hide.yaml | 3 +- data/help/safespot.yaml | 7 +- data/help/science.yaml | 19 +- data/help/stats.yaml | 21 +- data/help/style.yaml | 15 +- data/mobs/examples/ancient_construct.yaml | 109 +++++----- data/mobs/examples/bandit.yaml | 90 ++++---- data/mobs/examples/bioengineer.yaml | 108 +++++----- data/mobs/examples/corrupted_scientist.yaml | 103 ++++----- data/mobs/examples/cow.yaml | 74 +++---- data/mobs/examples/crawler.yaml | 94 +++++---- data/mobs/examples/drone.yaml | 94 +++++---- data/mobs/examples/elite_synth.yaml | 103 ++++----- data/mobs/examples/enforcer_bot.yaml | 99 ++++----- data/mobs/examples/farmer.yaml | 108 +++++----- data/mobs/examples/feral_cyborg.yaml | 90 ++++---- data/mobs/examples/ghoul.yaml | 91 ++++---- data/mobs/examples/goblin.yaml | 78 +++---- data/mobs/examples/greater_drone.yaml | 80 +++---- data/mobs/examples/heavy_mech.yaml | 99 ++++----- data/mobs/examples/iron_sentinel.yaml | 76 +++---- data/mobs/examples/lesser_drone.yaml | 78 +++---- data/mobs/examples/man.yaml | 116 +++++----- data/mobs/examples/molten_elemental.yaml | 99 ++++----- data/mobs/examples/moss_giant.yaml | 78 +++---- data/mobs/examples/mutant_rat.yaml | 93 ++++---- data/mobs/examples/phantom.yaml | 94 +++++---- data/mobs/examples/plasma_wraith.yaml | 103 ++++----- data/mobs/examples/psionic_entity.yaml | 108 +++++----- data/mobs/examples/rat.yaml | 62 +++--- data/mobs/examples/scrap_bot.yaml | 92 ++++---- data/mobs/examples/security_turret.yaml | 99 ++++----- data/mobs/examples/skeleton.yaml | 76 +++---- data/mobs/examples/slug.yaml | 84 ++++---- data/mobs/examples/solar_panel_frame.yaml | 84 ++++---- data/mobs/examples/titan_sentinel.yaml | 97 ++++----- data/mobs/examples/veteran_merc.yaml | 100 ++++----- data/mobs/examples/void_reaper.yaml | 119 ++++++----- data/mobs/examples/war_drone.yaml | 99 ++++----- data/mobs/examples/zombie.yaml | 81 +++---- internal/admin/api_courses.go | 4 +- internal/admin/api_items.go | 4 +- internal/admin/api_map.go | 10 +- internal/admin/api_mobs.go | 4 +- internal/admin/api_modules.go | 4 +- internal/admin/api_objects.go | 4 +- internal/admin/api_rooms.go | 16 +- internal/admin/api_techs.go | 4 +- internal/admin/static/mobeditor.js | 81 ++++++- internal/admin/undo.go | 12 +- internal/behavior/types.go | 94 ++++----- internal/combat/formulas.go | 44 ++-- internal/combat/types.go | 36 ++++ internal/combat/types_test.go | 30 +++ internal/config/config.go | 24 +-- internal/game/act.go | 14 +- internal/game/act_combine.go | 12 +- internal/game/act_farm.go | 22 +- internal/game/act_gather.go | 28 +-- internal/game/act_state.go | 10 +- internal/game/act_steal.go | 12 +- internal/game/act_use.go | 4 +- internal/game/cmd_attack.go | 2 +- internal/game/cmd_dig.go | 1 - internal/game/cmd_room_insert.go | 2 +- internal/game/cmd_smelt.go | 2 +- internal/game/cmd_stats.go | 29 +-- internal/game/cmd_trigger_combat.go | 34 ++- internal/game/cmd_trigger_enchant.go | 5 +- internal/game/cmd_trigger_utility.go | 2 +- internal/game/cmd_use.go | 2 +- internal/game/combat_attack.go | 79 ++++--- internal/game/combat_mob.go | 317 +++++++++++++++++----------- internal/game/combat_mob_test.go | 61 ++++++ internal/game/core_equip.go | 2 +- internal/game/core_login_char.go | 23 +- internal/game/core_utils.go | 21 ++ internal/game/look_target.go | 2 +- internal/game/map_test.go | 8 +- internal/game/render_map.go | 19 +- internal/game/sys_hazard.go | 8 +- internal/game/sys_safespot.go | 20 +- internal/game/sys_science.go | 25 +-- internal/game/sys_technology.go | 23 +- internal/game/sys_triggers.go | 8 +- internal/game/tick.go | 6 +- internal/item/item.go | 58 ++--- internal/item/store.go | 21 +- internal/object/store.go | 19 +- internal/validate/checks.go | 59 +++++- internal/validate/grid_test.go | 14 +- internal/validate/mob_attacktype_test.go | 31 +++ internal/world/mob.go | 268 +++++++++++++---------- internal/world/room.go | 36 ++-- internal/world/trigger.go | 8 +- internal/world/world.go | 8 +- 102 files changed, 2939 insertions(+), 2242 deletions(-) create mode 100644 internal/combat/types.go create mode 100644 internal/combat/types_test.go create mode 100644 internal/game/combat_mob_test.go create mode 100644 internal/validate/mob_attacktype_test.go diff --git a/AGENTS.md b/AGENTS.md index 42e08e9..a2eb532 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ Root-level `shop:` on a MobDef (`behavior.ShopConfig`) — NOT part of the talk ## Combat System -`attack_type` (stab/slash/crush/ranged/science) selects attack/defense bonuses. Ranged consumes 1 ammo/attack. Aggressive mobs auto-attack if `playerLevel <= mobLevel * 2`. Movement during combat = flee attempt (mob hits abort). Death: drop credits + keep 3 most valuable items, respawn at room 1, deactivate techs. Equipment `requirements` checked before equipping. Formulas and XP splits in `combat/tracker.go`. +`attack_type` (stab/slash/crush/ranged/science) selects attack/defense bonuses. **Weapons** carry a single `attack_type` string. **Mobs** carry an `attack_type` list (`[]string`, validated): exactly one melee type (stab/slash/crush) plus optionally `ranged`/`science`. A mob uses its melee type in normal combat; when a player is safespotted from melee (`safespotBlocksMelee`), the mob switches to its strongest ranged/science type (`mobStrongestRangedScience`, by max hit) via `effectiveMobAttackType`. A melee-only mob whose melee is blocked is fully blocked (`safespotBlocksMob`). Tech protection (`applyTechProtection`) uses the *resolved* attack type of the landed hit. Ranged consumes 1 ammo/attack. Aggressive mobs auto-attack if `playerLevel <= mobLevel * 2`. Movement during combat = flee attempt (mob hits abort). Death: drop credits + keep 3 most valuable items, respawn at room 1, deactivate techs. Equipment `requirements` checked before equipping. Formulas and XP splits in `combat/tracker.go`. ## Labor System (`sys_labor.go`, `sys_hazard.go`) diff --git a/building_guide/mobs.md b/building_guide/mobs.md index 31ff109..7a6f0aa 100644 --- a/building_guide/mobs.md +++ b/building_guide/mobs.md @@ -22,15 +22,16 @@ drops: quantity: 10 combat: kind: combat + aggressive: false + attack_types: [crush] # list: exactly one melee (stab/slash/crush) + optional ranged/science + respawn_ticks: 30 stats: hp: 7 attack: 1 strength: 1 defense: 1 - attack_type: crush # stab/slash/crush/ranged/science (default: crush) speed: 5 - aggressive: false - respawn_ticks: 30 + max_melee_hit: 1 # max hit with melee (for melee-attacking mobs) bonuses: attack_bonus: 0 strength_bonus: 0 @@ -51,21 +52,22 @@ combat: ### Aggressive Mobs -Mobs with `combat.stats.aggressive: true` auto-attack players entering their room (1-tick delay). Only aggros if the player's combat level is at most double the mob's combat level. Checked on room enter and login. +Mobs with `combat.aggressive: true` auto-attack players entering their room (1-tick delay). Only aggros if the player's combat level is at most double the mob's combat level. Checked on room enter and login. ```yaml name: goblin combat: kind: combat + aggressive: true # attacks players on sight + attack_types: [slash] + respawn_ticks: 25 stats: - aggressive: true # attacks players on sight hp: 12 attack: 1 strength: 3 defense: 3 - attack_type: slash speed: 5 - respawn_ticks: 25 + max_melee_hit: 1 # OSRS-derived: ((strength+9)*(strength_bonus+64)+320)/640 bonuses: attack_bonus: 3 # equipment-equivalent attack bonus for mob's attack roll strength_bonus: 2 # equipment-equivalent strength bonus for mob's max hit @@ -94,10 +96,10 @@ All combat stats live under `combat.stats:` | `defense` | Defense level (base defense) | | `ranged` | Ranged level (for ranged/science mobs) | | `science` | Science level | -| `attack_type` | Determines which player defense is used against this mob (stab/slash/crush/ranged/science) | | `speed` | Attack speed in ticks (default 5) | -| `aggressive` | Auto-attacks players on sight (false if omitted) | -| `respawn_ticks` | Ticks until respawn after death | +| `max_melee_hit` | Maximum melee hit (for melee-attacking mobs). Computed as ((strength+9)*(strength_bonus+64)+320)/640 | +| `max_ranged_hit` | Maximum ranged hit (for ranged-attacking mobs). Computed as ((ranged+9)*(ranged_bonus+64)+320)/640 | +| `max_science_hit` | Maximum science hit (for science-attacking mobs). A set value — magic max hits are not level-derived in OSRS | #### Bonuses (`combat.stats.bonuses:`) @@ -105,10 +107,10 @@ All combat stats live under `combat.stats:` |---|---| | `attack_bonus` | Equipment-equivalent attack bonus (added to attack roll) | | `strength_bonus` | Equipment-equivalent strength bonus (added to max hit) | -| `science_bonus` | Equipment-equivalent science attack bonus | -| `science_percent_bonus` | Percent boost to science damage | -| `ranged_bonus` | Equipment-equivalent ranged attack bonus | -| `ranged_strength_bonus` | Equipment-equivalent ranged strength bonus | +| `science_bonus` | Equipment-equivalent science attack bonus (used for science-attacking mobs' accuracy) | +| `science_percent_bonus` | Percent boost to science damage (applied to max_science_hit for science-attacking mobs) | +| `ranged_bonus` | Equipment-equivalent ranged attack bonus (used for ranged-attacking mobs' accuracy) | +| `ranged_strength_bonus` | Equipment-equivalent ranged strength bonus (used for max_ranged_hit derivation) | #### Defenses (`combat.stats.defenses:`) @@ -119,13 +121,17 @@ All combat stats live under `combat.stats:` | `crush_defense` | Crush defense bonus | | `science_defense` | Science defense bonus | | `ranged_defense` | Ranged defense bonus | -| `weakness` | Elemental weakness (solar/hydro/eco/bio/chaos) — for future Science combat | +| `weakness` | Elemental weakness (solar/hydro/eco/bio/chaos) — matching science module element gives +accuracy% and +damage | +| `weakness_percent` | Percent bonus per point of elemental weakness (e.g. 50 = +50% accuracy and +50% base max hit) | #### Combat-level fields (under `combat:`) | Field | Description | |---|---| | `kind` | `combat` (default) or `task` — task mobs are worksites | +| `aggressive` | Auto-attacks players on sight (false if omitted). Only aggros if player's combat level ≤ mob level × 2 | +| `attack_types` | List of attack types this mob can use. Must contain **exactly one** melee type (`stab`/`slash`/`crush`) and may optionally add `ranged` and/or `science`. The mob uses its melee type in normal combat; when a player is safespotted from melee it switches to its strongest ranged/science type (by max hit). Written as a YAML list, e.g. `[crush]` or `[stab, ranged]`. | +| `respawn_ticks` | Ticks until respawn after death | | `size` | Mob size for safespot blocking: small/medium/large/massive (default: medium) | | `assassin_level` | Required assassin skill level to attack this mob | | `finishing_blow` | Item ID required to kill (mob stays at 1 HP until used via `use on `) | @@ -215,15 +221,15 @@ steal: speed: 4 # Ticks per steal attempt combat: kind: combat + attack_types: [slash] + respawn_ticks: 25 stats: - hp: 15 - attack: 3 + hp: 12 + attack: 1 strength: 3 defense: 3 - attack_type: crush speed: 5 - aggressive: false - respawn_ticks: 40 + max_melee_hit: 1 bonuses: attack_bonus: 0 strength_bonus: 0 @@ -257,15 +263,16 @@ Assassin (Slayer) mobs restrict combat by assassin level and introduce finishing name: slug combat: kind: combat + aggressive: false + attack_types: [crush] + respawn_ticks: 25 stats: hp: 15 attack: 3 strength: 3 defense: 1 - attack_type: crush speed: 6 - aggressive: false - respawn_ticks: 25 + max_melee_hit: 1 bonuses: attack_bonus: 0 strength_bonus: 0 @@ -297,15 +304,16 @@ Damage-without mobs deal 1.5x damage unless the player has the specified item eq name: drone combat: kind: combat + aggressive: true + attack_types: [crush] + respawn_ticks: 35 stats: hp: 45 attack: 15 strength: 14 defense: 12 - attack_type: crush speed: 4 - aggressive: true - respawn_ticks: 35 + max_melee_hit: 2 # drone: strength 14, bonuses 0 bonuses: attack_bonus: 0 strength_bonus: 0 @@ -349,13 +357,14 @@ idle_descriptions: - "stands half-assembled, panels waiting to be fitted" combat: kind: task # "task" triggers the work-engine mode + aggressive: false # task mobs do not fight; this is ignored + attack_types: [crush] + respawn_ticks: 40 # the worksite replenishes after this many ticks stats: hp: 40 # units of work to complete (NOT a health bar) defense: 10 # base difficulty - attack_type: crush speed: 5 - aggressive: false # task mobs do not fight; this is ignored - respawn_ticks: 40 # the worksite replenishes after this many ticks + max_melee_hit: 0 # task mobs do not attack; max hits are unused bonuses: attack_bonus: 0 strength_bonus: 0 diff --git a/building_guide/objects.md b/building_guide/objects.md index a897255..af693c4 100644 --- a/building_guide/objects.md +++ b/building_guide/objects.md @@ -449,9 +449,12 @@ player flags via talk node actions: ### Combat Integration -- Melee mobs (attack_type: stab/slash/crush) cannot initiate aggro or land hits on - safespotted players if the mob's size is within the safespot's `max_block_size`. -- Ranged and science mobs (attack_type: ranged/science) ignore safespots entirely. +- A safespot blocks a mob's **melee** attacks if the mob's size is within the + safespot's `max_block_size`. +- When a mob's melee is blocked, if it also has a `ranged`/`science` attack type it + switches to its strongest ranged/science attack (by max hit) and keeps attacking. + A mob with only melee is fully blocked (cannot aggro or land hits). +- Mobs whose size exceeds `max_block_size` are never blocked and always use melee. - If a safespotted player attacks with melee, they automatically leave cover. - Hiding in combat takes 4 ticks; if the mob hits during this time, the hide fails. - Hiding out of combat takes 1 tick. diff --git a/building_guide/quests.md b/building_guide/quests.md index b9556dc..ff04118 100644 --- a/building_guide/quests.md +++ b/building_guide/quests.md @@ -41,14 +41,19 @@ talk: heal: 10 options: - text: "\"Thanks!\"" -attack: 1 -strength: 1 -defense: 1 -hp: 20 -speed: 5 -aggressive: false +combat: + kind: combat + aggressive: false + attack_types: [crush] + respawn_ticks: 30 + stats: + attack: 1 + strength: 1 + defense: 1 + hp: 20 + speed: 5 idle_descriptions: - - "mutters about the rat infestation" + - "mutters about the rat infestation" ``` Talk configs are inline on the mob under the `talk:` key. No separate behavior file needed. @@ -56,13 +61,17 @@ Talk configs are inline on the mob under the `talk:` key. No separate behavior f ### 2. Rat mobs (`data/mobs/rat.yaml`) ```yaml name: "giant rat" -attack: 2 -strength: 1 -defense: 1 -hp: 3 -speed: 4 -aggressive: true -respawn_ticks: 60 +combat: + kind: combat + aggressive: true + attack_types: [crush] + respawn_ticks: 60 + stats: + attack: 2 + strength: 1 + defense: 1 + hp: 3 + speed: 4 drops: remains: "rat bones" ``` diff --git a/data/help/attack.yaml b/data/help/attack.yaml index fb65ef6..1c41771 100644 --- a/data/help/attack.yaml +++ b/data/help/attack.yaml @@ -13,3 +13,16 @@ description: | Attacks occur at weapon speed on the 600ms tick. Use "style" to change combat style. Move in any direction to attempt to flee combat. + + Damage formula (OSRS-standard): + Max hit = (EffectiveStrength * (StrBonus + 64) + 320) / 640 + Attack roll = EffectiveAttack * (AttackBonus + 64) + Defense roll = (mob.Defense + 9) * (mobDefBonus + 64) + Hit chance: if AtkRoll > DefRoll: 1 - (Def+2)/(2*Atk), else Atk/(2*Def) + Damage: roll [0, MaxHit], 0 becomes 1 + + Effective level = base_level + style_bonus + tech_boosts + 8 (player) + base_level + 9 (NPC/defense) + base_level + 9 (science, no style bonus) + + See 'combat', 'stats', 'style', and 'science' for details. diff --git a/data/help/combat.yaml b/data/help/combat.yaml index f588d81..7a190f7 100644 --- a/data/help/combat.yaml +++ b/data/help/combat.yaml @@ -3,18 +3,40 @@ category: "General" description: | Attack a mob with: attack - Your accuracy depends on your attack type (stab, slash, crush, ranged, - or science) and your total equipment bonus for that type. Your weapon - determines the attack type. + Combat follows standard OSRS formulas for accuracy, hit chance, + maximum hit, and damage output. The three attack styles (melee, + ranged, and science) each use the same core formula structure. - Mobs have per-type defense bonuses. A skeleton with high slash defense - but weak crush defense takes more hits from a mace than a sword. + Attack roll: EffectiveAttack * (EquipmentBonus + 64) + Max hit: (EffectiveStrength * (EquipmentStrength + 64) + 320) / 640 + Hit chance: If AttackRoll > DefenseRoll: + 1 - (DefRoll + 2) / (2 * (AtkRoll + 1)) + Else: + AtkRoll / (2 * (DefRoll + 1)) + Damage: Roll [0, MaxHit], 0 becomes 1 + + Effective Levels: + Player: level + style_bonus + tech_boosts + 8 + NPC: level + 9 + Science: level + 9 (no style bonus) + + Your accuracy depends on your weapon's attack type (stab, slash, + crush, ranged, or science) and your total equipment bonus for + that type. + + Mobs have per-type defense bonuses. A skeleton with high slash + defense but weak crush defense takes more hits from a mace than + a sword. Mobs also have a weakness element — matching it with + a science module's element boosts both accuracy and damage. Use the 'style' command to change your combat style: - accurate - +3 accuracy level, XP to accuracy + accurate - +3 accuracy/ranged level, XP to accuracy/ranged aggressive - +3 strength level, XP to strength defensive - +3 defense level, XP to defense balanced - +1 to all, XP split evenly + For melee weapons the style affects attack/strength/defense. + For ranged weapons accurate and aggressive both give +3 ranged. + Use 'stats' to see your total equipment bonuses and current attack roll. Use 'look ' to see an item's combat stats. diff --git a/data/help/hide.yaml b/data/help/hide.yaml index eb2d27a..45565e4 100644 --- a/data/help/hide.yaml +++ b/data/help/hide.yaml @@ -1,7 +1,8 @@ name: "hide" category: "Skills" description: | - Hide behind a safespot object to block melee attacks. + Hide behind a safespot object to block melee attacks. Mobs that also + have a ranged or science attack will switch to it and keep hitting you. Usage: hide hide behind diff --git a/data/help/safespot.yaml b/data/help/safespot.yaml index 02dbb20..d584cf1 100644 --- a/data/help/safespot.yaml +++ b/data/help/safespot.yaml @@ -10,9 +10,10 @@ description: | Find a safespot: look Leave a safespot: unhide (or unsafespot) - While in a safespot, melee mobs cannot reach you. Ranged and science - mobs can still attack. Attacking with melee while in a safespot forces - you out of cover. + While in a safespot, mobs cannot reach you with melee. A mob that also + has a ranged or science attack will switch to it and keep attacking you; + a mob with only melee is fully blocked. Attacking with melee while in a + safespot forces you out of cover. Safespots degrade over time while in use. Each degrade level shows a different message. When fully degraded, the safespot is destroyed. diff --git a/data/help/science.yaml b/data/help/science.yaml index 901b2f3..a8a79c9 100644 --- a/data/help/science.yaml +++ b/data/help/science.yaml @@ -37,6 +37,23 @@ description: | - 'trigger ' queues a one-shot mod for your next attack - Only one mod can be queued at a time + Science damage formula: + Max hit = floor(mod_max_hit * (1 + equipment_science_damage% / 100)) + If the target is weak to your mod's element: + Max hit += floor(mod_max_hit * target_weakness% / 100) + + Elemental weakness: + Some mobs have an elemental weakness (solar, hydro, eco, bio, + chaos) with a weakness percent. If your module's element matches + the mob's weakness, you gain +weakness% accuracy and + +floor(base * weakness%) damage on top of your equipment bonus. + + Accuracy roll: EffectiveScience * (ScienceAttack + 64) + EffectiveScience = Science level + tech_boosts + 9 + + The mob's Science level determines its magic defense roll: + Defense roll = (mob.Science + 9) * (mob.ScienceDefense + 64) + Type 'mods' to see all modules you can currently use. - See also: trigger, autotrigger, mods, scavenging + See also: trigger, autotrigger, mods, scavenging, combat diff --git a/data/help/stats.yaml b/data/help/stats.yaml index 395f230..342306a 100644 --- a/data/help/stats.yaml +++ b/data/help/stats.yaml @@ -13,5 +13,22 @@ description: | Strength bonus increases your max melee hit. Ranged strength increases your max ranged hit. - Science damage increases your max science hit. - Technology bonus will affect technology abilities (future). + Science damage increases your max science hit as a percentage + multiplier on the module's base max hit. + Technology bonus reduces technology (prayer) drain. + + Formulas used for the displayed attack roll and max hit: + + Melee: + Attack roll = PlayerEffective(Accuracy, style_bonus) * (equip_attack + 64) + Max hit = (PlayerEffective(Strength, style_bonus) * (str_bonus + 64) + 320) / 640 + PlayerEffective(level, style) = level + style + tech_boosts + 8 + + Ranged: + Attack roll = PlayerEffective(Ranged, style_bonus) * (ranged_attack + 64) + Max hit = (PlayerEffective(Ranged, style_bonus) * (ranged_str + 64) + 320) / 640 + + Science: + Attack roll = ScienceEffective(Science) * (science_attack + 64) + Max hit = mod_max_hit * (1 + science_damage% / 100) + elemental_weakness_bonus + ScienceEffective(level) = level + tech_boosts + 9 diff --git a/data/help/style.yaml b/data/help/style.yaml index 663dc07..fbdf2bb 100644 --- a/data/help/style.yaml +++ b/data/help/style.yaml @@ -6,18 +6,23 @@ description: | Changes your combat style. Prefix matching is supported. Styles: - accurate - +3 to accuracy level, XP goes to Accuracy + accurate - +3 to accuracy/ranged level, XP goes to Accuracy/Ranged aggressive - +3 to strength level, XP goes to Strength defensive - +3 to defense level, XP goes to Defense balanced - +1 to all three, XP split evenly For ranged weapons: - accurate - +3 to ranged level - aggressive - +3 to ranged level (same as accurate for ranged) - defensive - +3 to defense level - balanced - +1 to ranged and defense + accurate - +3 to ranged level, XP to Ranged + aggressive - +3 to ranged level, XP to Ranged + defensive - +3 to defense level, XP to Defense + balanced - +1 to ranged and defense, XP split + + Your style bonus affects your effective level in the OSRS formulas: + PlayerEffective = level + style_bonus + tech_boosts + 8 + This is then used in both the attack roll and max hit calculation. Your style does NOT change your attack type. The attack type is determined by your weapon (e.g., swords use slash, daggers use stab). Use 'stats' to see how your current style affects your attack roll. + See 'combat' for full formula details. diff --git a/data/mobs/examples/ancient_construct.yaml b/data/mobs/examples/ancient_construct.yaml index ab878cd..139e6ae 100644 --- a/data/mobs/examples/ancient_construct.yaml +++ b/data/mobs/examples/ancient_construct.yaml @@ -1,56 +1,59 @@ -name: ancient construct -description: "An impossibly old machine from before the regression, still operational. Its design is alien even by modern standards, covered in glowing runes that predate recorded history." -idle_descriptions: - - "etches glowing symbols into the air" - - "reconfigures its form with clicking sounds" - - "projects a holographic star map" - - "emits a resonant chime that shakes the walls" -drops: - remains: scrap - loot: - - item_id: credits - weight: 40 - quantity: 10000 - - item_id: bloodjunk - weight: 20 - quantity: 8 - - item_id: deathjunk - weight: 15 - quantity: 8 - - table: gem_table - weight: 10 - - item_id: uncut_diamond - weight: 5 - quantity: 3 - - table: herb_table_high - weight: 10 combat: - kind: combat - stats: - hp: 152 - attack: 58 - strength: 62 - defense: 66 - attack_type: slash - speed: 4 aggressive: true + attack_types: + - slash + combat_descriptions: + - slices at %s with a blade of pure light + - unleashes a cascade of ancient energy at %s + - teleports behind %s and strikes + kind: combat respawn_ticks: 150 - bonuses: - attack_bonus: 55 - strength_bonus: 55 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 95 - slash_defense: 100 - crush_defense: 85 - science_defense: -40 - ranged_defense: 70 - weakness: chaos - size: massive - combat_descriptions: - - "slices at %s with a blade of pure light" - - "unleashes a cascade of ancient energy at %s" - - "teleports behind %s and strikes" + size: massive + stats: + attack: 58 + bonuses: + attack_bonus: 55 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 55 + defense: 66 + defenses: + crush_defense: 85 + ranged_defense: 70 + science_defense: -40 + slash_defense: 100 + stab_defense: 95 + weakness: chaos + weakness_percent: 50 + hp: 152 + max_melee_hit: 13 + speed: 4 + strength: 62 +description: An impossibly old machine from before the regression, still operational. Its design is alien even by modern standards, covered in glowing runes that predate recorded history. +drops: + loot: + - item_id: credits + quantity: 10000 + weight: 40 + - item_id: bloodjunk + quantity: 8 + weight: 20 + - item_id: deathjunk + quantity: 8 + weight: 15 + - table: gem_table + weight: 10 + - item_id: uncut_diamond + quantity: 3 + weight: 5 + - table: herb_table_high + weight: 10 + remains: scrap +idle_descriptions: + - etches glowing symbols into the air + - reconfigures its form with clicking sounds + - projects a holographic star map + - emits a resonant chime that shakes the walls +name: ancient construct diff --git a/data/mobs/examples/bandit.yaml b/data/mobs/examples/bandit.yaml index a3fd3e7..8e4c4b0 100644 --- a/data/mobs/examples/bandit.yaml +++ b/data/mobs/examples/bandit.yaml @@ -1,47 +1,49 @@ -name: bandit -description: "A scrappy outlaw with a cybernetic arm and patched armor. He eyes you with predatory intent." -idle_descriptions: - - "scans the horizon for targets" - - "wipes a blade on his sleeve" - - "spits on the ground" - - "adjusts a cybernetic knuckle" -drops: - remains: bones - loot: - - item_id: credits - weight: 70 - quantity: 30 - - item_id: credits - weight: 20 - quantity: 80 - - table: herb_table_low - weight: 10 combat: - kind: combat - stats: - hp: 28 - attack: 10 - strength: 12 - defense: 12 - attack_type: stab - speed: 5 aggressive: true + attack_types: + - stab + combat_descriptions: + - slashes at %s with a serrated blade + - delivers a crushing punch with a cybernetic fist + - lunges at %s with a battle cry + kind: combat respawn_ticks: 30 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - size: medium - combat_descriptions: - - "slashes at %s with a serrated blade" - - "delivers a crushing punch with a cybernetic fist" - - "lunges at %s with a battle cry" + size: medium + stats: + attack: 10 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 12 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 28 + max_melee_hit: 2 + speed: 5 + strength: 12 +description: A scrappy outlaw with a cybernetic arm and patched armor. He eyes you with predatory intent. +drops: + loot: + - item_id: credits + quantity: 30 + weight: 70 + - item_id: credits + quantity: 80 + weight: 20 + - table: herb_table_low + weight: 10 + remains: bones +idle_descriptions: + - scans the horizon for targets + - wipes a blade on his sleeve + - spits on the ground + - adjusts a cybernetic knuckle +name: bandit diff --git a/data/mobs/examples/bioengineer.yaml b/data/mobs/examples/bioengineer.yaml index d6c9193..6a7a74a 100644 --- a/data/mobs/examples/bioengineer.yaml +++ b/data/mobs/examples/bioengineer.yaml @@ -1,56 +1,58 @@ -name: Bioengineer -description: "A lab-coated scientist carrying a satchel of genetically modified seeds. Her pockets are stuffed with rare specimens." -idle_descriptions: - - "scribbles notes on a clipboard" - - "carefully inspects a vial of green liquid" - - "adjusts her safety goggles" - - "mutters about gene splicing yields" -steal: - table: bioengineer_steal - level: 38 - xp: 45 - speed: 4 -drops: - remains: "bones" - loot: - - item_id: "sweetcorn_seed" - weight: 30 - quantity: 2 - - item_id: "strawberry_seed" - weight: 25 - quantity: 1 - - item_id: "watermelon_seed" - weight: 15 - quantity: 1 - - item_id: "ranarr_seed" - weight: 5 - quantity: 1 combat: - kind: combat - stats: - hp: 25 - attack: 6 - strength: 4 - defense: 5 - attack_type: crush - speed: 5 aggressive: false + attack_types: + - crush + combat_descriptions: + - jabs a syringe at %s + - is being overpowered by %s + - fights desperately against %s + kind: combat respawn_ticks: 50 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - size: small - combat_descriptions: - - "jabs a syringe at %s" - - "is being overpowered by %s" - - "fights desperately against %s" + size: small + stats: + attack: 6 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 5 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 25 + max_melee_hit: 1 + speed: 5 + strength: 4 +description: A lab-coated scientist carrying a satchel of genetically modified seeds. Her pockets are stuffed with rare specimens. +drops: + loot: + - item_id: sweetcorn_seed + quantity: 2 + weight: 30 + - item_id: strawberry_seed + quantity: 1 + weight: 25 + - item_id: watermelon_seed + quantity: 1 + weight: 15 + - item_id: ranarr_seed + quantity: 1 + weight: 5 + remains: bones +idle_descriptions: + - scribbles notes on a clipboard + - carefully inspects a vial of green liquid + - adjusts her safety goggles + - mutters about gene splicing yields +name: Bioengineer +steal: + level: 38 + speed: 4 + table: bioengineer_steal + xp: 45 diff --git a/data/mobs/examples/corrupted_scientist.yaml b/data/mobs/examples/corrupted_scientist.yaml index aeda04d..3757932 100644 --- a/data/mobs/examples/corrupted_scientist.yaml +++ b/data/mobs/examples/corrupted_scientist.yaml @@ -1,52 +1,57 @@ -name: corrupted scientist -description: "A researcher whose mind was consumed by their own experiments. Energy crackles around their fingertips and their lab coat is singed and tattered." -idle_descriptions: - - "mutters incomprehensible formulas" - - "energy arcs between their fingers" - - "scribbles equations on the wall in soot" - - "stares at their own hands in horror" -drops: - remains: bones - loot: - - item_id: credits - weight: 50 - quantity: 150 - - item_id: solarjunk - weight: 20 - quantity: 2 - - item_id: hydrojunk - weight: 15 - quantity: 2 - - table: herb_table_mid - weight: 15 combat: - kind: combat - stats: - hp: 40 - attack: 1 - strength: 1 - defense: 18 - science: 50 - attack_type: science - speed: 5 aggressive: true + attack_types: + - crush + - science + combat_descriptions: + - blasts %s with a burst of unstable energy + - unleashes a wave of chaotic force at %s + - channels raw power through their hands at %s + kind: combat respawn_ticks: 45 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 25 - slash_defense: 25 - crush_defense: 20 - science_defense: 35 - ranged_defense: -5 - weakness: eco - size: medium - combat_descriptions: - - "blasts %s with a burst of unstable energy" - - "unleashes a wave of chaotic force at %s" - - "channels raw power through their hands at %s" + size: medium + stats: + attack: 25 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 18 + defenses: + crush_defense: 20 + ranged_defense: -5 + science_defense: 35 + slash_defense: 25 + stab_defense: 25 + weakness: eco + weakness_percent: 50 + hp: 40 + max_melee_hit: 6 + max_science_hit: 10 + science: 50 + speed: 5 + strength: 25 +description: A researcher whose mind was consumed by their own experiments. Energy crackles around their fingertips and their lab coat is singed and tattered. +drops: + loot: + - item_id: credits + quantity: 150 + weight: 50 + - item_id: solarjunk + quantity: 2 + weight: 20 + - item_id: hydrojunk + quantity: 2 + weight: 15 + - table: herb_table_mid + weight: 15 + remains: bones +idle_descriptions: + - mutters incomprehensible formulas + - energy arcs between their fingers + - scribbles equations on the wall in soot + - stares at their own hands in horror +name: corrupted scientist diff --git a/data/mobs/examples/cow.yaml b/data/mobs/examples/cow.yaml index 2171d12..06556f4 100644 --- a/data/mobs/examples/cow.yaml +++ b/data/mobs/examples/cow.yaml @@ -1,39 +1,41 @@ -name: cow -description: "A placid dairy cow, chewing cud." -idle_descriptions: - - "moos softly" - - "chews on some grass" - - "swishes its tail lazily" - - "stares at you with big brown eyes" -drops: - remains: bones - loot: - - item_id: cowhide - weight: 100 - - item_id: raw_beef - weight: 100 combat: - kind: combat - stats: - hp: 8 - attack: 1 - strength: 1 - defense: 1 - attack_type: crush - speed: 5 aggressive: false + attack_types: + - crush + kind: combat respawn_ticks: 30 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 1 - slash_defense: 1 - crush_defense: 1 - science_defense: 0 - ranged_defense: 0 - size: large + size: large + stats: + attack: 1 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 1 + defenses: + crush_defense: 1 + ranged_defense: 0 + science_defense: 0 + slash_defense: 1 + stab_defense: 1 + hp: 8 + max_melee_hit: 1 + speed: 5 + strength: 1 +description: A placid dairy cow, chewing cud. +drops: + loot: + - item_id: cowhide + weight: 100 + - item_id: raw_beef + weight: 100 + remains: bones +idle_descriptions: + - moos softly + - chews on some grass + - swishes its tail lazily + - stares at you with big brown eyes +name: cow diff --git a/data/mobs/examples/crawler.yaml b/data/mobs/examples/crawler.yaml index 55d8f6f..53a149a 100644 --- a/data/mobs/examples/crawler.yaml +++ b/data/mobs/examples/crawler.yaml @@ -1,49 +1,51 @@ -name: crawler -description: "A heavily armored bio-mechanical creature with a chitinous exoskeleton. Its regenerative biology prevents death unless dissolved with acid." -idle_descriptions: - - "scrapes its mandibles together menacingly" - - "clicks and chitters in an alien rhythm" - - "tests the air with feathered antennae" - - "coils its segmented body defensively" -drops: - remains: chitin_plate - loot: - - item_id: credits - weight: 70 - quantity: 250 - - item_id: acid_vial - weight: 10 - - item_id: credits - weight: 20 - quantity: 500 combat: - kind: combat - stats: - hp: 80 - attack: 25 - strength: 22 - defense: 30 - attack_type: crush - speed: 5 aggressive: false + assassin_level: 30 + attack_types: + - crush + combat_descriptions: + - snaps its mandibles at %s + - lashes out with a barbed tail at %s + - charges headlong into %s + finishing_blow: acid_vial + kind: combat respawn_ticks: 45 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - assassin_level: 30 - finishing_blow: acid_vial - size: large - combat_descriptions: - - "snaps its mandibles at %s" - - "lashes out with a barbed tail at %s" - - "charges headlong into %s" + size: large + stats: + attack: 25 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 30 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 80 + max_melee_hit: 3 + speed: 5 + strength: 22 +description: A heavily armored bio-mechanical creature with a chitinous exoskeleton. Its regenerative biology prevents death unless dissolved with acid. +drops: + loot: + - item_id: credits + quantity: 250 + weight: 70 + - item_id: acid_vial + weight: 10 + - item_id: credits + quantity: 500 + weight: 20 + remains: chitin_plate +idle_descriptions: + - scrapes its mandibles together menacingly + - clicks and chitters in an alien rhythm + - tests the air with feathered antennae + - coils its segmented body defensively +name: crawler diff --git a/data/mobs/examples/drone.yaml b/data/mobs/examples/drone.yaml index 08091f5..30b1bcd 100644 --- a/data/mobs/examples/drone.yaml +++ b/data/mobs/examples/drone.yaml @@ -1,49 +1,51 @@ -name: drone -description: "A malfunctioning security drone crackling with electrical discharge. Its attacks are especially dangerous to anyone not wearing insulated gloves." -idle_descriptions: - - "hovers erratically, sparking" - - "emits a high-pitched whine" - - "scans the area with a flickering red beam" - - "rotates its weapon array with a mechanical click" -drops: - remains: circuit_board - loot: - - item_id: credits - weight: 80 - quantity: 75 - - item_id: insulated_gloves - weight: 5 - - item_id: credits - weight: 15 - quantity: 200 combat: - kind: combat - stats: - hp: 45 - attack: 15 - strength: 14 - defense: 12 - attack_type: crush - speed: 4 aggressive: true + assassin_level: 15 + attack_types: + - crush + combat_descriptions: + - fires an electrical bolt at %s + - charges its capacitors and zaps %s + - swoops down on %s with crackling energy + damage_without: insulated_gloves + kind: combat respawn_ticks: 35 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - assassin_level: 15 - damage_without: insulated_gloves - size: small - combat_descriptions: - - "fires an electrical bolt at %s" - - "charges its capacitors and zaps %s" - - "swoops down on %s with crackling energy" + size: small + stats: + attack: 15 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 12 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 45 + max_melee_hit: 2 + speed: 4 + strength: 14 +description: A malfunctioning security drone crackling with electrical discharge. Its attacks are especially dangerous to anyone not wearing insulated gloves. +drops: + loot: + - item_id: credits + quantity: 75 + weight: 80 + - item_id: insulated_gloves + weight: 5 + - item_id: credits + quantity: 200 + weight: 15 + remains: circuit_board +idle_descriptions: + - hovers erratically, sparking + - emits a high-pitched whine + - scans the area with a flickering red beam + - rotates its weapon array with a mechanical click +name: drone diff --git a/data/mobs/examples/elite_synth.yaml b/data/mobs/examples/elite_synth.yaml index 9628572..4bf7638 100644 --- a/data/mobs/examples/elite_synth.yaml +++ b/data/mobs/examples/elite_synth.yaml @@ -1,52 +1,57 @@ -name: elite synth -description: "A sleek combat android with glowing blue optics and a compact energy weapon. Its chassis is polished alloy and its movements are disturbingly fluid." -idle_descriptions: - - "calibrates targeting sensors with a soft chime" - - "projects a holographic tactical display" - - "emits a binary chirp" - - "reconfigures its weapon array silently" -drops: - remains: circuit_board - loot: - - item_id: credits - weight: 50 - quantity: 1500 - - item_id: biojunk - weight: 20 - quantity: 3 - - item_id: naturejunk - weight: 15 - quantity: 3 - - table: gem_table - weight: 15 combat: - kind: combat - stats: - hp: 104 - attack: 1 - strength: 1 - defense: 38 - science: 65 - attack_type: science - speed: 4 aggressive: true + attack_types: + - slash + - science + combat_descriptions: + - unleashes a coherent energy beam at %s + - fires a pulse of disruptor energy at %s + - overloads its emitters and blasts %s + kind: combat respawn_ticks: 75 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 45 - slash_defense: 45 - crush_defense: 40 - science_defense: 55 - ranged_defense: -15 - weakness: chaos - size: medium - combat_descriptions: - - "unleashes a coherent energy beam at %s" - - "fires a pulse of disruptor energy at %s" - - "overloads its emitters and blasts %s" + size: medium + stats: + attack: 45 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 38 + defenses: + crush_defense: 40 + ranged_defense: -15 + science_defense: 55 + slash_defense: 45 + stab_defense: 45 + weakness: chaos + weakness_percent: 50 + hp: 104 + max_melee_hit: 9 + max_science_hit: 12 + science: 65 + speed: 4 + strength: 45 +description: A sleek combat android with glowing blue optics and a compact energy weapon. Its chassis is polished alloy and its movements are disturbingly fluid. +drops: + loot: + - item_id: credits + quantity: 1500 + weight: 50 + - item_id: biojunk + quantity: 3 + weight: 20 + - item_id: naturejunk + quantity: 3 + weight: 15 + - table: gem_table + weight: 15 + remains: circuit_board +idle_descriptions: + - calibrates targeting sensors with a soft chime + - projects a holographic tactical display + - emits a binary chirp + - reconfigures its weapon array silently +name: elite synth diff --git a/data/mobs/examples/enforcer_bot.yaml b/data/mobs/examples/enforcer_bot.yaml index efdd698..7ba5ac0 100644 --- a/data/mobs/examples/enforcer_bot.yaml +++ b/data/mobs/examples/enforcer_bot.yaml @@ -1,51 +1,54 @@ -name: enforcer bot -description: "A heavy riot control automaton with impact shields and a shock baton. It moves with deliberate, crushing steps." -idle_descriptions: - - "patrols the area with heavy footsteps" - - "deploys its riot shield with a hiss" - - "announces a warning in garbled speech" - - "tests its shock baton against a wall" -drops: - remains: scrap - loot: - - item_id: credits - weight: 60 - quantity: 350 - - item_id: scrap - weight: 20 - quantity: 5 - - item_id: credits - weight: 15 - quantity: 800 - - table: gem_table - weight: 5 combat: - kind: combat - stats: - hp: 70 - attack: 30 - strength: 35 - defense: 32 - attack_type: crush - speed: 5 aggressive: true + attack_types: + - crush + combat_descriptions: + - smashes %s with a shock baton + - slams its riot shield into %s + - delivers a stunning blow to %s + kind: combat respawn_ticks: 55 - bonuses: - attack_bonus: 25 - strength_bonus: 25 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 40 - slash_defense: 40 - crush_defense: 50 - science_defense: -15 - ranged_defense: 30 - weakness: solar - size: large - combat_descriptions: - - "smashes %s with a shock baton" - - "slams its riot shield into %s" - - "delivers a stunning blow to %s" + size: large + stats: + attack: 30 + bonuses: + attack_bonus: 25 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 25 + defense: 32 + defenses: + crush_defense: 50 + ranged_defense: 30 + science_defense: -15 + slash_defense: 40 + stab_defense: 40 + weakness: solar + weakness_percent: 50 + hp: 70 + max_melee_hit: 6 + speed: 5 + strength: 35 +description: A heavy riot control automaton with impact shields and a shock baton. It moves with deliberate, crushing steps. +drops: + loot: + - item_id: credits + quantity: 350 + weight: 60 + - item_id: scrap + quantity: 5 + weight: 20 + - item_id: credits + quantity: 800 + weight: 15 + - table: gem_table + weight: 5 + remains: scrap +idle_descriptions: + - patrols the area with heavy footsteps + - deploys its riot shield with a hiss + - announces a warning in garbled speech + - tests its shock baton against a wall +name: enforcer bot diff --git a/data/mobs/examples/farmer.yaml b/data/mobs/examples/farmer.yaml index 8e72ac2..a5f5e3d 100644 --- a/data/mobs/examples/farmer.yaml +++ b/data/mobs/examples/farmer.yaml @@ -1,56 +1,58 @@ -name: Farmer -description: "A weathered farmer in muddy overalls, pockets bulging with seeds." -idle_descriptions: - - "examines a handful of seeds" - - "wipes dirt from his hands" - - "mutters about the growing season" - - "adjusts his wide-brimmed hat" -steal: - table: farmer_steal - level: 10 - xp: 15 - speed: 4 -drops: - remains: "bones" - loot: - - item_id: "potato_seed" - weight: 40 - quantity: 3 - - item_id: "onion_seed" - weight: 30 - quantity: 2 - - item_id: "cabbage_seed" - weight: 20 - quantity: 2 - - item_id: "tomato_seed" - weight: 10 - quantity: 1 combat: - kind: combat - stats: - hp: 15 - attack: 3 - strength: 3 - defense: 3 - attack_type: crush - speed: 5 aggressive: false + attack_types: + - crush + combat_descriptions: + - swings a shovel at %s + - is getting beaten by %s + - grapples with %s + kind: combat respawn_ticks: 40 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - size: small - combat_descriptions: - - "swings a shovel at %s" - - "is getting beaten by %s" - - "grapples with %s" + size: small + stats: + attack: 3 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 3 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 15 + max_melee_hit: 1 + speed: 5 + strength: 3 +description: A weathered farmer in muddy overalls, pockets bulging with seeds. +drops: + loot: + - item_id: potato_seed + quantity: 3 + weight: 40 + - item_id: onion_seed + quantity: 2 + weight: 30 + - item_id: cabbage_seed + quantity: 2 + weight: 20 + - item_id: tomato_seed + quantity: 1 + weight: 10 + remains: bones +idle_descriptions: + - examines a handful of seeds + - wipes dirt from his hands + - mutters about the growing season + - adjusts his wide-brimmed hat +name: Farmer +steal: + level: 10 + speed: 4 + table: farmer_steal + xp: 15 diff --git a/data/mobs/examples/feral_cyborg.yaml b/data/mobs/examples/feral_cyborg.yaml index e5edf3d..8aa0969 100644 --- a/data/mobs/examples/feral_cyborg.yaml +++ b/data/mobs/examples/feral_cyborg.yaml @@ -1,47 +1,49 @@ -name: feral cyborg -description: "A heavily augmented human whose implants have driven them to madness. Razor-sharp blades extend from their forearms and their optical implant glows red." -idle_descriptions: - - "paces back and forth, muttering" - - "retracts and extends forearm blades" - - "emits a distorted laugh" - - "scratches at their own cybernetics" -drops: - remains: bones - loot: - - item_id: credits - weight: 60 - quantity: 300 - - item_id: scrap - weight: 25 - quantity: 3 - - table: herb_table_mid - weight: 15 combat: - kind: combat - stats: - hp: 82 - attack: 34 - strength: 36 - defense: 32 - attack_type: slash - speed: 4 aggressive: true + attack_types: + - slash + combat_descriptions: + - slashes %s with forearm blades + - delivers a powered kick to %s + - grapples %s with augmented strength + kind: combat respawn_ticks: 50 - bonuses: - attack_bonus: 30 - strength_bonus: 28 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 35 - slash_defense: 45 - crush_defense: 30 - science_defense: -10 - ranged_defense: 35 - size: medium - combat_descriptions: - - "slashes %s with forearm blades" - - "delivers a powered kick to %s" - - "grapples %s with augmented strength" + size: medium + stats: + attack: 34 + bonuses: + attack_bonus: 30 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 28 + defense: 32 + defenses: + crush_defense: 30 + ranged_defense: 35 + science_defense: -10 + slash_defense: 45 + stab_defense: 35 + hp: 82 + max_melee_hit: 6 + speed: 4 + strength: 36 +description: A heavily augmented human whose implants have driven them to madness. Razor-sharp blades extend from their forearms and their optical implant glows red. +drops: + loot: + - item_id: credits + quantity: 300 + weight: 60 + - item_id: scrap + quantity: 3 + weight: 25 + - table: herb_table_mid + weight: 15 + remains: bones +idle_descriptions: + - paces back and forth, muttering + - retracts and extends forearm blades + - emits a distorted laugh + - scratches at their own cybernetics +name: feral cyborg diff --git a/data/mobs/examples/ghoul.yaml b/data/mobs/examples/ghoul.yaml index 070d6f1..49be88f 100644 --- a/data/mobs/examples/ghoul.yaml +++ b/data/mobs/examples/ghoul.yaml @@ -1,47 +1,50 @@ -name: ghoul -description: "A radiation-scarred crew member, barely recognizable as human. Its flesh glows faintly and its eyes are hollow sockets." -idle_descriptions: - - "shambles aimlessly and moans" - - "scratches at the walls leaving glowing marks" - - "stares blankly into the darkness" - - "emits a low, rattling groan" -drops: - remains: bones - loot: - - item_id: credits - weight: 60 - quantity: 80 - - item_id: scrap - weight: 25 - - table: herb_table_low - weight: 15 combat: - kind: combat - stats: - hp: 54 - attack: 22 - strength: 22 - defense: 20 - attack_type: crush - speed: 4 aggressive: true + attack_types: + - crush + combat_descriptions: + - claws at %s with irradiated hands + - lunges at %s with a gurgling shriek + - bites %s with decaying teeth + kind: combat respawn_ticks: 35 - bonuses: - attack_bonus: 15 - strength_bonus: 12 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 20 - slash_defense: 25 - crush_defense: 15 - science_defense: -5 - ranged_defense: 20 - weakness: hydro - size: medium - combat_descriptions: - - "claws at %s with irradiated hands" - - "lunges at %s with a gurgling shriek" - - "bites %s with decaying teeth" + size: medium + stats: + attack: 22 + bonuses: + attack_bonus: 15 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 12 + defense: 20 + defenses: + crush_defense: 15 + ranged_defense: 20 + science_defense: -5 + slash_defense: 25 + stab_defense: 20 + weakness: hydro + weakness_percent: 50 + hp: 54 + max_melee_hit: 4 + speed: 4 + strength: 22 +description: A radiation-scarred crew member, barely recognizable as human. Its flesh glows faintly and its eyes are hollow sockets. +drops: + loot: + - item_id: credits + quantity: 80 + weight: 60 + - item_id: scrap + weight: 25 + - table: herb_table_low + weight: 15 + remains: bones +idle_descriptions: + - shambles aimlessly and moans + - scratches at the walls leaving glowing marks + - stares blankly into the darkness + - emits a low, rattling groan +name: ghoul diff --git a/data/mobs/examples/goblin.yaml b/data/mobs/examples/goblin.yaml index 23a5f77..649c896 100644 --- a/data/mobs/examples/goblin.yaml +++ b/data/mobs/examples/goblin.yaml @@ -1,41 +1,43 @@ -name: goblin -description: "A small green-skinned goblin with a rusty blade." -idle_descriptions: - - "mutters something unintelligible" - - "waves a rusty blade around" - - "picks its nose" - - "kicks a pebble" -drops: - remains: bones - loot: - - item_id: credits - weight: 80 - quantity: 5 - - item_id: credits - weight: 20 - quantity: 25 combat: - kind: combat - stats: - hp: 12 - attack: 1 - strength: 3 - defense: 3 - attack_type: slash - speed: 5 aggressive: true + attack_types: + - slash + kind: combat respawn_ticks: 25 - bonuses: - attack_bonus: 3 - strength_bonus: 2 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 4 - slash_defense: 3 - crush_defense: -2 - science_defense: 0 - ranged_defense: 3 - size: small + size: small + stats: + attack: 1 + bonuses: + attack_bonus: 3 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 2 + defense: 3 + defenses: + crush_defense: -2 + ranged_defense: 3 + science_defense: 0 + slash_defense: 3 + stab_defense: 4 + hp: 12 + max_melee_hit: 1 + speed: 5 + strength: 3 +description: A small green-skinned goblin with a rusty blade. +drops: + loot: + - item_id: credits + quantity: 5 + weight: 80 + - item_id: credits + quantity: 25 + weight: 20 + remains: bones +idle_descriptions: + - mutters something unintelligible + - waves a rusty blade around + - picks its nose + - kicks a pebble +name: goblin diff --git a/data/mobs/examples/greater_drone.yaml b/data/mobs/examples/greater_drone.yaml index 93993ea..08ecd33 100644 --- a/data/mobs/examples/greater_drone.yaml +++ b/data/mobs/examples/greater_drone.yaml @@ -1,41 +1,45 @@ -name: greater drone -description: "A large, heavily armored drone with multiple energy emitters. Red warning lights pulse along its hull." -idle_descriptions: - - "projects a defensive energy shield" - - "recalibrates its targeting array" - - "emits a warning klaxon" - - "deploys sensor probes" -drops: - loot: - - item_id: scrap - weight: 40 - quantity: 3 - - item_id: credits - weight: 60 - quantity: 1500 combat: - kind: combat - stats: - hp: 120 - attack: 1 - strength: 1 - defense: 30 - science: 40 - attack_type: science - speed: 4 aggressive: true + attack_types: + - crush + - science + kind: combat respawn_ticks: 80 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 55 - slash_defense: 55 - crush_defense: 50 - science_defense: 40 - ranged_defense: -20 - size: medium + size: medium + stats: + attack: 30 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 30 + defenses: + crush_defense: 50 + ranged_defense: -20 + science_defense: 40 + slash_defense: 55 + stab_defense: 55 + hp: 120 + max_melee_hit: 6 + max_science_hit: 8 + science: 40 + speed: 4 + strength: 30 +description: A large, heavily armored drone with multiple energy emitters. Red warning lights pulse along its hull. +drops: + loot: + - item_id: scrap + quantity: 3 + weight: 40 + - item_id: credits + quantity: 1500 + weight: 60 +idle_descriptions: + - projects a defensive energy shield + - recalibrates its targeting array + - emits a warning klaxon + - deploys sensor probes +name: greater drone diff --git a/data/mobs/examples/heavy_mech.yaml b/data/mobs/examples/heavy_mech.yaml index 669c444..afb5c2e 100644 --- a/data/mobs/examples/heavy_mech.yaml +++ b/data/mobs/examples/heavy_mech.yaml @@ -1,51 +1,54 @@ -name: heavy mech -description: "A lumbering assault mech with layered armor plating and a rotary cannon arm. Steam vents from its exhaust ports with each step." -idle_descriptions: - - "scans the room with a blue optical beam" - - "vents a cloud of steam" - - "rotates its cannon with a mechanical whine" - - "announces a threat level assessment" -drops: - remains: scrap - loot: - - item_id: credits - weight: 55 - quantity: 1000 - - item_id: scrap - weight: 25 - quantity: 8 - - table: gem_table - weight: 10 - - item_id: lawjunk - weight: 10 - quantity: 2 combat: - kind: combat - stats: - hp: 94 - attack: 40 - strength: 44 - defense: 44 - attack_type: crush - speed: 5 aggressive: true + attack_types: + - crush + combat_descriptions: + - fires its rotary cannon at %s + - smashes %s with a hydraulic fist + - unleashes a missile salvo at %s + kind: combat respawn_ticks: 70 - bonuses: - attack_bonus: 35 - strength_bonus: 35 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 55 - slash_defense: 55 - crush_defense: 65 - science_defense: -25 - ranged_defense: 40 - weakness: solar - size: massive - combat_descriptions: - - "fires its rotary cannon at %s" - - "smashes %s with a hydraulic fist" - - "unleashes a missile salvo at %s" + size: massive + stats: + attack: 40 + bonuses: + attack_bonus: 35 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 35 + defense: 44 + defenses: + crush_defense: 65 + ranged_defense: 40 + science_defense: -25 + slash_defense: 55 + stab_defense: 55 + weakness: solar + weakness_percent: 50 + hp: 94 + max_melee_hit: 8 + speed: 5 + strength: 44 +description: A lumbering assault mech with layered armor plating and a rotary cannon arm. Steam vents from its exhaust ports with each step. +drops: + loot: + - item_id: credits + quantity: 1000 + weight: 55 + - item_id: scrap + quantity: 8 + weight: 25 + - table: gem_table + weight: 10 + - item_id: lawjunk + quantity: 2 + weight: 10 + remains: scrap +idle_descriptions: + - scans the room with a blue optical beam + - vents a cloud of steam + - rotates its cannon with a mechanical whine + - announces a threat level assessment +name: heavy mech diff --git a/data/mobs/examples/iron_sentinel.yaml b/data/mobs/examples/iron_sentinel.yaml index 48b2dbe..9628db1 100644 --- a/data/mobs/examples/iron_sentinel.yaml +++ b/data/mobs/examples/iron_sentinel.yaml @@ -1,40 +1,42 @@ -name: iron sentinel -description: "A massive bipedal automaton forged from pre-regression alloys. Its joints grind with each deliberate step." -idle_descriptions: - - "stands motionless, servos whining softly" - - "turns its armored head with a mechanical grind" - - "vents steam from exhaust ports" - - "scans the area with a red optical sensor" -drops: - loot: - - item_id: scrap - weight: 30 - quantity: 5 - - item_id: credits - weight: 60 - quantity: 5000 combat: - kind: combat - stats: - hp: 180 - attack: 25 - strength: 30 - defense: 45 - attack_type: crush - speed: 6 aggressive: true + attack_types: + - crush + kind: combat respawn_ticks: 100 - bonuses: - attack_bonus: 40 - strength_bonus: 35 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 80 - slash_defense: 80 - crush_defense: 70 - science_defense: -30 - ranged_defense: 50 - size: large + size: large + stats: + attack: 25 + bonuses: + attack_bonus: 40 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 35 + defense: 45 + defenses: + crush_defense: 70 + ranged_defense: 50 + science_defense: -30 + slash_defense: 80 + stab_defense: 80 + hp: 180 + max_melee_hit: 6 + speed: 6 + strength: 30 +description: A massive bipedal automaton forged from pre-regression alloys. Its joints grind with each deliberate step. +drops: + loot: + - item_id: scrap + quantity: 5 + weight: 30 + - item_id: credits + quantity: 5000 + weight: 60 +idle_descriptions: + - stands motionless, servos whining softly + - turns its armored head with a mechanical grind + - vents steam from exhaust ports + - scans the area with a red optical sensor +name: iron sentinel diff --git a/data/mobs/examples/lesser_drone.yaml b/data/mobs/examples/lesser_drone.yaml index d9c452d..dd84ba3 100644 --- a/data/mobs/examples/lesser_drone.yaml +++ b/data/mobs/examples/lesser_drone.yaml @@ -1,40 +1,44 @@ -name: lesser drone -description: "A floating metallic drone with a pulsing energy core. Its surface crackles with electric discharge." -idle_descriptions: - - "hovers silently, scanning the area" - - "emits a low electronic hum" - - "projects a thin beam of light across the floor" - - "rotates slowly in place" -drops: - loot: - - item_id: scrap - weight: 60 - - item_id: credits - weight: 40 - quantity: 300 combat: - kind: combat - stats: - hp: 55 - attack: 1 - strength: 1 - defense: 18 - science: 20 - attack_type: science - speed: 4 aggressive: true + attack_types: + - crush + - science + kind: combat respawn_ticks: 50 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 35 - slash_defense: 35 - crush_defense: 30 - science_defense: 20 - ranged_defense: -10 - size: small + size: small + stats: + attack: 15 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 18 + defenses: + crush_defense: 30 + ranged_defense: -10 + science_defense: 20 + slash_defense: 35 + stab_defense: 35 + hp: 55 + max_melee_hit: 4 + max_science_hit: 6 + science: 20 + speed: 4 + strength: 15 +description: A floating metallic drone with a pulsing energy core. Its surface crackles with electric discharge. +drops: + loot: + - item_id: scrap + weight: 60 + - item_id: credits + quantity: 300 + weight: 40 +idle_descriptions: + - hovers silently, scanning the area + - emits a low electronic hum + - projects a thin beam of light across the floor + - rotates slowly in place +name: lesser drone diff --git a/data/mobs/examples/man.yaml b/data/mobs/examples/man.yaml index ec3624c..1ffa746 100644 --- a/data/mobs/examples/man.yaml +++ b/data/mobs/examples/man.yaml @@ -1,60 +1,62 @@ -name: man -description: "A shabby-looking man loitering in the town square." -idle_descriptions: - - "scribbles something in a small notebook" - - "gazes skyward at the clouds" - - "leans against a wall, looking bored" - - "scratches his head thoughtfully" - - "stares off into the distance" - - "adjusts his tunic and stretches" -steal: - table: man_steal - level: 1 - xp: 8 - speed: 4 -drops: - remains: "bones" - loot: - - item_id: "credits" - weight: 98 - quantity: 10 - - item_id: "credits" - weight: 2 - quantity: 150 - - item_id: "grimy_guam" - weight: 8 - - item_id: "grimy_marrentill" - weight: 4 - - item_id: "grimy_tarromin" - weight: 2 combat: - kind: combat - stats: - hp: 7 - attack: 1 - strength: 1 - defense: 1 - attack_type: crush - speed: 5 aggressive: false + attack_types: + - crush + combat_descriptions: + - is engaged in a fight to the death with %s + - is getting pummelled by %s + - is locked in combat with %s + - trades blows with %s + - circles warily around %s + kind: combat respawn_ticks: 30 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - crush_defense: 0 - ranged_defense: 0 - science_defense: 0 - slash_defense: 0 - stab_defense: 0 - size: small - combat_descriptions: - - "is engaged in a fight to the death with %s" - - "is getting pummelled by %s" - - "is locked in combat with %s" - - "trades blows with %s" - - "circles warily around %s" + size: small + stats: + attack: 1 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 1 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 7 + max_melee_hit: 1 + speed: 5 + strength: 1 +description: A shabby-looking man loitering in the town square. +drops: + loot: + - item_id: credits + quantity: 10 + weight: 98 + - item_id: credits + quantity: 150 + weight: 2 + - item_id: grimy_guam + weight: 8 + - item_id: grimy_marrentill + weight: 4 + - item_id: grimy_tarromin + weight: 2 + remains: bones +idle_descriptions: + - scribbles something in a small notebook + - gazes skyward at the clouds + - leans against a wall, looking bored + - scratches his head thoughtfully + - stares off into the distance + - adjusts his tunic and stretches +name: man +steal: + level: 1 + speed: 4 + table: man_steal + xp: 8 diff --git a/data/mobs/examples/molten_elemental.yaml b/data/mobs/examples/molten_elemental.yaml index 3c9b023..9c16f54 100644 --- a/data/mobs/examples/molten_elemental.yaml +++ b/data/mobs/examples/molten_elemental.yaml @@ -1,51 +1,54 @@ -name: molten elemental -description: "A flowing mass of liquid metal and superheated slag, roughly humanoid. The air around it shimmers with heat and droplets of molten alloy drip to the floor." -idle_descriptions: - - "bubbles and churns, throwing off sparks" - - "melts a nearby chunk of metal absent-mindedly" - - "pulses with an inner orange glow" - - "drips glowing droplets onto the floor" -drops: - remains: scrap - loot: - - item_id: credits - weight: 50 - quantity: 2000 - - item_id: biojunk - weight: 20 - quantity: 3 - - item_id: bloodjunk - weight: 15 - quantity: 2 - - table: herb_table_high - weight: 15 combat: - kind: combat - stats: - hp: 115 - attack: 48 - strength: 52 - defense: 52 - attack_type: crush - speed: 5 aggressive: true + attack_types: + - crush + combat_descriptions: + - hurls a glob of molten metal at %s + - engulfs %s in a wave of liquid fire + - smashes %s with a superheated limb + kind: combat respawn_ticks: 80 - bonuses: - attack_bonus: 40 - strength_bonus: 42 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 60 - slash_defense: 60 - crush_defense: 70 - science_defense: -30 - ranged_defense: 45 - weakness: hydro - size: large - combat_descriptions: - - "hurls a glob of molten metal at %s" - - "engulfs %s in a wave of liquid fire" - - "smashes %s with a superheated limb" + size: large + stats: + attack: 48 + bonuses: + attack_bonus: 40 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 42 + defense: 52 + defenses: + crush_defense: 70 + ranged_defense: 45 + science_defense: -30 + slash_defense: 60 + stab_defense: 60 + weakness: hydro + weakness_percent: 50 + hp: 115 + max_melee_hit: 10 + speed: 5 + strength: 52 +description: A flowing mass of liquid metal and superheated slag, roughly humanoid. The air around it shimmers with heat and droplets of molten alloy drip to the floor. +drops: + loot: + - item_id: credits + quantity: 2000 + weight: 50 + - item_id: biojunk + quantity: 3 + weight: 20 + - item_id: bloodjunk + quantity: 2 + weight: 15 + - table: herb_table_high + weight: 15 + remains: scrap +idle_descriptions: + - bubbles and churns, throwing off sparks + - melts a nearby chunk of metal absent-mindedly + - pulses with an inner orange glow + - drips glowing droplets onto the floor +name: molten elemental diff --git a/data/mobs/examples/moss_giant.yaml b/data/mobs/examples/moss_giant.yaml index 9b07a37..cff159a 100644 --- a/data/mobs/examples/moss_giant.yaml +++ b/data/mobs/examples/moss_giant.yaml @@ -1,41 +1,43 @@ -name: moss giant -description: "A towering creature covered in moss and vines, its rocky skin dripping with moisture." -idle_descriptions: - - "surveys its territory with dull eyes" - - "pulls a vine from its shoulder" - - "stamps the ground, sending tremors" - - "scratches its mossy hide" -drops: - remains: bones - loot: - - item_id: credits - weight: 50 - quantity: 120 - - item_id: credits - weight: 50 - quantity: 500 combat: - kind: combat - stats: - hp: 60 - attack: 12 - strength: 14 - defense: 10 - attack_type: crush - speed: 7 aggressive: true + attack_types: + - crush + kind: combat respawn_ticks: 45 - bonuses: - attack_bonus: 10 - strength_bonus: 12 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 15 - slash_defense: 12 - crush_defense: 18 - science_defense: -5 - ranged_defense: 15 - size: massive + size: massive + stats: + attack: 12 + bonuses: + attack_bonus: 10 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 12 + defense: 10 + defenses: + crush_defense: 18 + ranged_defense: 15 + science_defense: -5 + slash_defense: 12 + stab_defense: 15 + hp: 60 + max_melee_hit: 3 + speed: 7 + strength: 14 +description: A towering creature covered in moss and vines, its rocky skin dripping with moisture. +drops: + loot: + - item_id: credits + quantity: 120 + weight: 50 + - item_id: credits + quantity: 500 + weight: 50 + remains: bones +idle_descriptions: + - surveys its territory with dull eyes + - pulls a vine from its shoulder + - stamps the ground, sending tremors + - scratches its mossy hide +name: moss giant diff --git a/data/mobs/examples/mutant_rat.yaml b/data/mobs/examples/mutant_rat.yaml index 676b510..233bd50 100644 --- a/data/mobs/examples/mutant_rat.yaml +++ b/data/mobs/examples/mutant_rat.yaml @@ -1,48 +1,51 @@ -name: mutant rat -description: "A bloated rodent with glowing green eyes and exposed muscle tissue. Bio-engineering runoff has made it unnaturally aggressive." -idle_descriptions: - - "scurries frantically in circles" - - "gnaws on a rusted pipe" - - "emits a gurgling screech" - - "twitches unnaturally" -drops: - remains: bones - loot: - - item_id: credits - weight: 60 - quantity: 20 - - item_id: scrap - weight: 30 - - item_id: credits - weight: 10 - quantity: 60 combat: - kind: combat - stats: - hp: 32 - attack: 13 - strength: 13 - defense: 12 - attack_type: crush - speed: 4 aggressive: true + attack_types: + - crush + combat_descriptions: + - bites %s with glowing teeth + - slashes %s with mutated claws + - lunges at %s with unnatural speed + kind: combat respawn_ticks: 25 - bonuses: - attack_bonus: 8 - strength_bonus: 6 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 15 - slash_defense: 15 - crush_defense: 10 - science_defense: -10 - ranged_defense: 5 - weakness: bio - size: small - combat_descriptions: - - "bites %s with glowing teeth" - - "slashes %s with mutated claws" - - "lunges at %s with unnatural speed" + size: small + stats: + attack: 13 + bonuses: + attack_bonus: 8 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 6 + defense: 12 + defenses: + crush_defense: 10 + ranged_defense: 5 + science_defense: -10 + slash_defense: 15 + stab_defense: 15 + weakness: bio + weakness_percent: 50 + hp: 32 + max_melee_hit: 2 + speed: 4 + strength: 13 +description: A bloated rodent with glowing green eyes and exposed muscle tissue. Bio-engineering runoff has made it unnaturally aggressive. +drops: + loot: + - item_id: credits + quantity: 20 + weight: 60 + - item_id: scrap + weight: 30 + - item_id: credits + quantity: 60 + weight: 10 + remains: bones +idle_descriptions: + - scurries frantically in circles + - gnaws on a rusted pipe + - emits a gurgling screech + - twitches unnaturally +name: mutant rat diff --git a/data/mobs/examples/phantom.yaml b/data/mobs/examples/phantom.yaml index fad8167..34ddcdd 100644 --- a/data/mobs/examples/phantom.yaml +++ b/data/mobs/examples/phantom.yaml @@ -1,49 +1,51 @@ -name: phantom -description: "A semi-transparent entity that phases in and out of visible light. Its psychic attacks are devastating to anyone without a spectral visor." -idle_descriptions: - - "flickers between visible and invisible" - - "emits a low, resonant hum" - - "drifts through a wall and back again" - - "stares at you with hollow, glowing eyes" -drops: - remains: ectoplasm - loot: - - item_id: credits - weight: 60 - quantity: 500 - - item_id: spectral_visor - weight: 3 - - item_id: credits - weight: 37 - quantity: 1000 combat: - kind: combat - stats: - hp: 100 - attack: 35 - strength: 30 - defense: 25 - attack_type: crush - speed: 3 aggressive: true + assassin_level: 45 + attack_types: + - crush + combat_descriptions: + - blasts %s with a psychic wave + - phases through %s's defenses + - unleashes a spectral shriek at %s + damage_without: spectral_visor + kind: combat respawn_ticks: 50 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - assassin_level: 45 - damage_without: spectral_visor - size: medium - combat_descriptions: - - "blasts %s with a psychic wave" - - "phases through %s's defenses" - - "unleashes a spectral shriek at %s" + size: medium + stats: + attack: 35 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 25 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 100 + max_melee_hit: 4 + speed: 3 + strength: 30 +description: A semi-transparent entity that phases in and out of visible light. Its psychic attacks are devastating to anyone without a spectral visor. +drops: + loot: + - item_id: credits + quantity: 500 + weight: 60 + - item_id: spectral_visor + weight: 3 + - item_id: credits + quantity: 1000 + weight: 37 + remains: ectoplasm +idle_descriptions: + - flickers between visible and invisible + - emits a low, resonant hum + - drifts through a wall and back again + - stares at you with hollow, glowing eyes +name: phantom diff --git a/data/mobs/examples/plasma_wraith.yaml b/data/mobs/examples/plasma_wraith.yaml index 7241d9a..8fd8c2e 100644 --- a/data/mobs/examples/plasma_wraith.yaml +++ b/data/mobs/examples/plasma_wraith.yaml @@ -1,52 +1,57 @@ -name: plasma wraith -description: "A shimmering entity of contained plasma, vaguely humanoid in shape. It radiates intense heat and hums with barely contained energy." -idle_descriptions: - - "drifts silently, leaving heat shimmer in its wake" - - "pulses with internal light" - - "phases partially through a wall" - - "emits a low frequency hum" -drops: - remains: ectoplasm - loot: - - item_id: credits - weight: 50 - quantity: 400 - - item_id: chaosjunk - weight: 20 - quantity: 2 - - item_id: cosmicjunk - weight: 15 - quantity: 2 - - table: gem_table - weight: 15 combat: - kind: combat - stats: - hp: 56 - attack: 1 - strength: 1 - defense: 22 - science: 65 - attack_type: science - speed: 4 aggressive: true + attack_types: + - crush + - science + combat_descriptions: + - lashes %s with a whip of plasma + - engulfs %s in a superheated cloud + - discharges a concentrated beam at %s + kind: combat respawn_ticks: 60 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 30 - slash_defense: 30 - crush_defense: 25 - science_defense: 45 - ranged_defense: -10 - weakness: hydro - size: medium - combat_descriptions: - - "lashes %s with a whip of plasma" - - "engulfs %s in a superheated cloud" - - "discharges a concentrated beam at %s" + size: medium + stats: + attack: 40 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 22 + defenses: + crush_defense: 25 + ranged_defense: -10 + science_defense: 45 + slash_defense: 30 + stab_defense: 30 + weakness: hydro + weakness_percent: 50 + hp: 56 + max_melee_hit: 8 + max_science_hit: 12 + science: 65 + speed: 4 + strength: 40 +description: A shimmering entity of contained plasma, vaguely humanoid in shape. It radiates intense heat and hums with barely contained energy. +drops: + loot: + - item_id: credits + quantity: 400 + weight: 50 + - item_id: chaosjunk + quantity: 2 + weight: 20 + - item_id: cosmicjunk + quantity: 2 + weight: 15 + - table: gem_table + weight: 15 + remains: ectoplasm +idle_descriptions: + - drifts silently, leaving heat shimmer in its wake + - pulses with internal light + - phases partially through a wall + - emits a low frequency hum +name: plasma wraith diff --git a/data/mobs/examples/psionic_entity.yaml b/data/mobs/examples/psionic_entity.yaml index 1f270e6..9d8351c 100644 --- a/data/mobs/examples/psionic_entity.yaml +++ b/data/mobs/examples/psionic_entity.yaml @@ -1,55 +1,59 @@ -name: psionic entity -description: "A barely visible distortion in the air, only detectable by the pressure it exerts on your mind. When it focuses, you see a shimmering outline of something vast and ancient." -idle_descriptions: - - "probes the edges of your consciousness" - - "flickers between visible and invisible states" - - "emits a subsonic pulse you feel in your teeth" - - "momentarily shows you visions of another dimension" -drops: - remains: ectoplasm - loot: - - item_id: credits - weight: 40 - quantity: 5000 - - item_id: lawjunk - weight: 20 - quantity: 5 - - item_id: cosmicjunk - weight: 15 - quantity: 5 - - table: gem_table - weight: 15 - - item_id: spectral_visor - weight: 5 - - table: herb_table_high - weight: 5 combat: - kind: combat - stats: - hp: 128 - attack: 1 - strength: 1 - defense: 42 - science: 97 - attack_type: science - speed: 4 aggressive: true + attack_types: + - crush + - science + combat_descriptions: + - assaults %s's mind with psionic force + - tears at %s's perception of reality + - unleashes a devastating mental blast at %s + kind: combat respawn_ticks: 100 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 50 - slash_defense: 50 - crush_defense: 45 - science_defense: 70 - ranged_defense: -20 - size: large - combat_descriptions: - - "assaults %s's mind with psionic force" - - "tears at %s's perception of reality" - - "unleashes a devastating mental blast at %s" + size: large + stats: + attack: 50 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 42 + defenses: + crush_defense: 45 + ranged_defense: -20 + science_defense: 70 + slash_defense: 50 + stab_defense: 50 + hp: 128 + max_melee_hit: 10 + max_science_hit: 18 + science: 97 + speed: 4 + strength: 50 +description: A barely visible distortion in the air, only detectable by the pressure it exerts on your mind. When it focuses, you see a shimmering outline of something vast and ancient. +drops: + loot: + - item_id: credits + quantity: 5000 + weight: 40 + - item_id: lawjunk + quantity: 5 + weight: 20 + - item_id: cosmicjunk + quantity: 5 + weight: 15 + - table: gem_table + weight: 15 + - item_id: spectral_visor + weight: 5 + - table: herb_table_high + weight: 5 + remains: ectoplasm +idle_descriptions: + - probes the edges of your consciousness + - flickers between visible and invisible states + - emits a subsonic pulse you feel in your teeth + - momentarily shows you visions of another dimension +name: psionic entity diff --git a/data/mobs/examples/rat.yaml b/data/mobs/examples/rat.yaml index 7a92026..c5b92af 100644 --- a/data/mobs/examples/rat.yaml +++ b/data/mobs/examples/rat.yaml @@ -1,33 +1,35 @@ -name: rat -description: "A large, mangy rat with beady red eyes." -idle_descriptions: - - "sniffs around the ground" - - "nibbles on something" - - "scurries in circles" -drops: - remains: bones combat: - kind: combat - stats: - hp: 2 - attack: 1 - strength: 1 - defense: 1 - attack_type: crush - speed: 4 aggressive: false + attack_types: + - crush + kind: combat respawn_ticks: 20 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - crush_defense: 0 - ranged_defense: 0 - science_defense: 0 - slash_defense: 0 - stab_defense: 0 - size: small + size: small + stats: + attack: 1 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 1 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 2 + max_melee_hit: 1 + speed: 4 + strength: 1 +description: A large, mangy rat with beady red eyes. +drops: + remains: bones +idle_descriptions: + - sniffs around the ground + - nibbles on something + - scurries in circles +name: rat diff --git a/data/mobs/examples/scrap_bot.yaml b/data/mobs/examples/scrap_bot.yaml index 44d92aa..227033b 100644 --- a/data/mobs/examples/scrap_bot.yaml +++ b/data/mobs/examples/scrap_bot.yaml @@ -1,48 +1,50 @@ -name: scrap bot -description: "A malfunctioning maintenance robot that has gone rogue. Its chassis is covered in dents and rust, and one optical sensor flickers erratically." -idle_descriptions: - - "beeps erratically and twitches" - - "bumps into a wall and reverses" - - "emits a burst of static" - - "scans the floor with a flickering beam" -drops: - remains: scrap - loot: - - item_id: credits - weight: 60 - quantity: 40 - - item_id: scrap - weight: 30 - quantity: 2 - - item_id: credits - weight: 10 - quantity: 100 combat: - kind: combat - stats: - hp: 30 - attack: 15 - strength: 15 - defense: 14 - attack_type: crush - speed: 5 aggressive: true + attack_types: + - crush + combat_descriptions: + - rams %s with its chassis + - swings a broken manipulator arm at %s + - discharges a spark at %s + kind: combat respawn_ticks: 30 - bonuses: - attack_bonus: 10 - strength_bonus: 8 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 20 - slash_defense: 15 - crush_defense: 25 - science_defense: -5 - ranged_defense: 10 - size: medium - combat_descriptions: - - "rams %s with its chassis" - - "swings a broken manipulator arm at %s" - - "discharges a spark at %s" + size: medium + stats: + attack: 15 + bonuses: + attack_bonus: 10 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 8 + defense: 14 + defenses: + crush_defense: 25 + ranged_defense: 10 + science_defense: -5 + slash_defense: 15 + stab_defense: 20 + hp: 30 + max_melee_hit: 3 + speed: 5 + strength: 15 +description: A malfunctioning maintenance robot that has gone rogue. Its chassis is covered in dents and rust, and one optical sensor flickers erratically. +drops: + loot: + - item_id: credits + quantity: 40 + weight: 60 + - item_id: scrap + quantity: 2 + weight: 30 + - item_id: credits + quantity: 100 + weight: 10 + remains: scrap +idle_descriptions: + - beeps erratically and twitches + - bumps into a wall and reverses + - emits a burst of static + - scans the floor with a flickering beam +name: scrap bot diff --git a/data/mobs/examples/security_turret.yaml b/data/mobs/examples/security_turret.yaml index 05fce67..0403474 100644 --- a/data/mobs/examples/security_turret.yaml +++ b/data/mobs/examples/security_turret.yaml @@ -1,50 +1,55 @@ -name: security turret -description: "A ceiling-mounted auto-turret with twin barrels and a red targeting laser. It tracks you with mechanical precision." -idle_descriptions: - - "pans the room with a targeting laser" - - "emits a rhythmic clicking sound" - - "calibrates its targeting sensors" - - "beeps a warning tone" -drops: - remains: scrap - loot: - - item_id: credits - weight: 70 - quantity: 100 - - item_id: scrap - weight: 20 - quantity: 3 - - item_id: credits - weight: 10 - quantity: 250 combat: - kind: combat - stats: - hp: 44 - attack: 1 - strength: 1 - defense: 16 - ranged: 34 - attack_type: ranged - speed: 5 aggressive: true + attack_types: + - crush + - ranged + combat_descriptions: + - fires twin bolts at %s + - unleashes a burst of rounds at %s + - locks onto %s and opens fire + kind: combat respawn_ticks: 40 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 30 - slash_defense: 20 - crush_defense: 40 - science_defense: -20 - ranged_defense: 15 - weakness: solar - size: small - combat_descriptions: - - "fires twin bolts at %s" - - "unleashes a burst of rounds at %s" - - "locks onto %s and opens fire" + size: small + stats: + attack: 20 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 16 + defenses: + crush_defense: 40 + ranged_defense: 15 + science_defense: -20 + slash_defense: 20 + stab_defense: 30 + weakness: solar + weakness_percent: 50 + hp: 44 + max_melee_hit: 3 + max_ranged_hit: 4 + ranged: 34 + speed: 5 + strength: 20 +description: A ceiling-mounted auto-turret with twin barrels and a red targeting laser. It tracks you with mechanical precision. +drops: + loot: + - item_id: credits + quantity: 100 + weight: 70 + - item_id: scrap + quantity: 3 + weight: 20 + - item_id: credits + quantity: 250 + weight: 10 + remains: scrap +idle_descriptions: + - pans the room with a targeting laser + - emits a rhythmic clicking sound + - calibrates its targeting sensors + - beeps a warning tone +name: security turret diff --git a/data/mobs/examples/skeleton.yaml b/data/mobs/examples/skeleton.yaml index 2431215..f1d9ed5 100644 --- a/data/mobs/examples/skeleton.yaml +++ b/data/mobs/examples/skeleton.yaml @@ -1,40 +1,42 @@ -name: skeleton -description: "An animated pile of bones, held together by dark energy." -idle_descriptions: - - "rattles ominously" - - "clicks its jaw open and shut" - - "stares with empty eye sockets" -drops: - remains: bones - loot: - - item_id: credits - weight: 70 - quantity: 30 - - item_id: credits - weight: 30 - quantity: 100 combat: - kind: combat - stats: - hp: 22 - attack: 8 - strength: 6 - defense: 8 - attack_type: stab - speed: 5 aggressive: true + attack_types: + - stab + kind: combat respawn_ticks: 30 - bonuses: - attack_bonus: 6 - strength_bonus: 4 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 10 - slash_defense: 18 - crush_defense: -5 - science_defense: -8 - ranged_defense: 10 - size: medium + size: medium + stats: + attack: 8 + bonuses: + attack_bonus: 6 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 4 + defense: 8 + defenses: + crush_defense: -5 + ranged_defense: 10 + science_defense: -8 + slash_defense: 18 + stab_defense: 10 + hp: 22 + max_melee_hit: 2 + speed: 5 + strength: 6 +description: An animated pile of bones, held together by dark energy. +drops: + loot: + - item_id: credits + quantity: 30 + weight: 70 + - item_id: credits + quantity: 100 + weight: 30 + remains: bones +idle_descriptions: + - rattles ominously + - clicks its jaw open and shut + - stares with empty eye sockets +name: skeleton diff --git a/data/mobs/examples/slug.yaml b/data/mobs/examples/slug.yaml index 0429ee1..41aa849 100644 --- a/data/mobs/examples/slug.yaml +++ b/data/mobs/examples/slug.yaml @@ -1,44 +1,46 @@ -name: slug -description: "A bloated, translucent slug the size of a dog. Its skin glistens with toxic mucus. It cannot be killed by conventional means — only salt can destroy it." -idle_descriptions: - - "oozes along the floor leaving a slimy trail" - - "contracts and expands rhythmically" - - "extends its eyestalks toward you" - - "secretes a glob of toxic mucus" -drops: - remains: slug_mucus - loot: - - item_id: credits - weight: 100 - quantity: 15 combat: - kind: combat - stats: - hp: 15 - attack: 3 - strength: 3 - defense: 1 - attack_type: crush - speed: 6 aggressive: false + assassin_level: 1 + attack_types: + - crush + combat_descriptions: + - lunges slimily at %s + - sprays mucus toward %s + - writhes in combat with %s + finishing_blow: salt + kind: combat respawn_ticks: 25 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 0 - slash_defense: 0 - crush_defense: 0 - science_defense: 0 - ranged_defense: 0 - assassin_level: 1 - finishing_blow: salt - size: small - combat_descriptions: - - "lunges slimily at %s" - - "sprays mucus toward %s" - - "writhes in combat with %s" + size: small + stats: + attack: 3 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 1 + defenses: + crush_defense: 0 + ranged_defense: 0 + science_defense: 0 + slash_defense: 0 + stab_defense: 0 + hp: 15 + max_melee_hit: 1 + speed: 6 + strength: 3 +description: A bloated, translucent slug the size of a dog. Its skin glistens with toxic mucus. It cannot be killed by conventional means — only salt can destroy it. +drops: + loot: + - item_id: credits + quantity: 15 + weight: 100 + remains: slug_mucus +idle_descriptions: + - oozes along the floor leaving a slimy trail + - contracts and expands rhythmically + - extends its eyestalks toward you + - secretes a glob of toxic mucus +name: slug diff --git a/data/mobs/examples/solar_panel_frame.yaml b/data/mobs/examples/solar_panel_frame.yaml index 2685db0..a24e79c 100644 --- a/data/mobs/examples/solar_panel_frame.yaml +++ b/data/mobs/examples/solar_panel_frame.yaml @@ -1,44 +1,46 @@ -name: solar panel frame -description: "A half-assembled photovoltaic array on a steel frame. Panels, brackets and cabling are laid out, waiting to be fitted. This is work, not a fight — fix the panels into place to complete the array." -task: - verb: assemble - progress_noun: assembly - complete_message: "You bolt the final panel into place and the solar array hums to life!" -idle_descriptions: - - "stands half-assembled, panels waiting to be fitted" - - "has loose cabling dangling from an open junction box" - - "catches the light on its few mounted panels" -drops: - loot: - - item_id: credits - weight: 70 - quantity: 40 - - item_id: credits - weight: 25 - quantity: 90 - - item_id: coal - weight: 5 - quantity: 1 combat: - kind: task - stats: - hp: 40 - defense: 10 - attack_type: crush - speed: 5 aggressive: false + attack_types: + - crush + kind: task respawn_ticks: 40 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 40 - slash_defense: 40 - crush_defense: 0 - science_defense: 10 - ranged_defense: 10 - size: medium + size: medium + stats: + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 10 + defenses: + crush_defense: 0 + ranged_defense: 10 + science_defense: 10 + slash_defense: 40 + stab_defense: 40 + hp: 40 + max_melee_hit: 0 + speed: 5 +description: A half-assembled photovoltaic array on a steel frame. Panels, brackets and cabling are laid out, waiting to be fitted. This is work, not a fight — fix the panels into place to complete the array. +drops: + loot: + - item_id: credits + quantity: 40 + weight: 70 + - item_id: credits + quantity: 90 + weight: 25 + - item_id: coal + quantity: 1 + weight: 5 +idle_descriptions: + - stands half-assembled, panels waiting to be fitted + - has loose cabling dangling from an open junction box + - catches the light on its few mounted panels +name: solar panel frame +task: + complete_message: You bolt the final panel into place and the solar array hums to life! + progress_noun: assembly + verb: assemble diff --git a/data/mobs/examples/titan_sentinel.yaml b/data/mobs/examples/titan_sentinel.yaml index 6fb36d3..2192527 100644 --- a/data/mobs/examples/titan_sentinel.yaml +++ b/data/mobs/examples/titan_sentinel.yaml @@ -1,50 +1,53 @@ -name: titan sentinel -description: "A colossal automaton, the bigger brother of the iron sentinel. Its armor is reinforced adamantite alloy and its optics burn with crimson fire." -idle_descriptions: - - "stands motionless, gyros humming" - - "scans the area with a sweeping crimson beam" - - "vents superheated exhaust" - - "rotates its armored torso with a deep grinding sound" -drops: - loot: - - item_id: scrap - weight: 30 - quantity: 10 - - item_id: credits - weight: 50 - quantity: 8000 - - table: gem_table - weight: 15 - - item_id: deathjunk - weight: 5 - quantity: 5 combat: - kind: combat - stats: - hp: 132 - attack: 50 - strength: 52 - defense: 56 - attack_type: crush - speed: 5 aggressive: true + attack_types: + - crush + combat_descriptions: + - smashes %s with a titan-grade fist + - unleashes a devastating plasma barrage at %s + - drives %s into the ground with overwhelming force + kind: combat respawn_ticks: 120 - bonuses: - attack_bonus: 48 - strength_bonus: 45 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 90 - slash_defense: 90 - crush_defense: 80 - science_defense: -35 - ranged_defense: 60 - weakness: solar - size: massive - combat_descriptions: - - "smashes %s with a titan-grade fist" - - "unleashes a devastating plasma barrage at %s" - - "drives %s into the ground with overwhelming force" + size: massive + stats: + attack: 50 + bonuses: + attack_bonus: 48 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 45 + defense: 56 + defenses: + crush_defense: 80 + ranged_defense: 60 + science_defense: -35 + slash_defense: 90 + stab_defense: 90 + weakness: solar + weakness_percent: 50 + hp: 132 + max_melee_hit: 10 + speed: 5 + strength: 52 +description: A colossal automaton, the bigger brother of the iron sentinel. Its armor is reinforced adamantite alloy and its optics burn with crimson fire. +drops: + loot: + - item_id: scrap + quantity: 10 + weight: 30 + - item_id: credits + quantity: 8000 + weight: 50 + - table: gem_table + weight: 15 + - item_id: deathjunk + quantity: 5 + weight: 5 +idle_descriptions: + - stands motionless, gyros humming + - scans the area with a sweeping crimson beam + - vents superheated exhaust + - rotates its armored torso with a deep grinding sound +name: titan sentinel diff --git a/data/mobs/examples/veteran_merc.yaml b/data/mobs/examples/veteran_merc.yaml index 71e3826..0e187c1 100644 --- a/data/mobs/examples/veteran_merc.yaml +++ b/data/mobs/examples/veteran_merc.yaml @@ -1,51 +1,55 @@ -name: veteran merc -description: "A battle-hardened soldier with a high-powered rifle and tactical armor covered in campaign patches. Their cybernetic eye locks onto you instantly." -idle_descriptions: - - "checks their rifle's ammunition counter" - - "adjusts their tactical vest" - - "scans the area through their scope" - - "spits and mutters a war story" -drops: - remains: bones - loot: - - item_id: credits - weight: 55 - quantity: 1200 - - item_id: chaosjunk - weight: 15 - quantity: 2 - - item_id: deathjunk - weight: 15 - quantity: 2 - - table: herb_table_mid - weight: 15 combat: - kind: combat - stats: - hp: 93 - attack: 1 - strength: 1 - defense: 40 - ranged: 79 - attack_type: ranged - speed: 5 aggressive: true + attack_types: + - stab + - ranged + combat_descriptions: + - fires a high-caliber round at %s + - puts %s in their crosshairs and squeezes the trigger + - unleashes suppressive fire at %s + kind: combat respawn_ticks: 65 - bonuses: - attack_bonus: 0 - strength_bonus: 0 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 40 - slash_defense: 40 - crush_defense: 35 - science_defense: 20 - ranged_defense: 55 - size: medium - combat_descriptions: - - "fires a high-caliber round at %s" - - "puts %s in their crosshairs and squeezes the trigger" - - "unleashes suppressive fire at %s" + size: medium + stats: + attack: 55 + bonuses: + attack_bonus: 0 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 0 + defense: 40 + defenses: + crush_defense: 35 + ranged_defense: 55 + science_defense: 20 + slash_defense: 40 + stab_defense: 40 + hp: 93 + max_melee_hit: 8 + max_ranged_hit: 9 + ranged: 79 + speed: 5 + strength: 55 +description: A battle-hardened soldier with a high-powered rifle and tactical armor covered in campaign patches. Their cybernetic eye locks onto you instantly. +drops: + loot: + - item_id: credits + quantity: 1200 + weight: 55 + - item_id: chaosjunk + quantity: 2 + weight: 15 + - item_id: deathjunk + quantity: 2 + weight: 15 + - table: herb_table_mid + weight: 15 + remains: bones +idle_descriptions: + - checks their rifle's ammunition counter + - adjusts their tactical vest + - scans the area through their scope + - spits and mutters a war story +name: veteran merc diff --git a/data/mobs/examples/void_reaper.yaml b/data/mobs/examples/void_reaper.yaml index bf85470..4223f69 100644 --- a/data/mobs/examples/void_reaper.yaml +++ b/data/mobs/examples/void_reaper.yaml @@ -1,61 +1,64 @@ -name: void reaper -description: "A shadowy figure that seems to absorb light itself. Its form shifts between a hooded specter and an armored construct. A scythe of dark energy flickers in its grasp." -idle_descriptions: - - "flickers in and out of existence" - - "draws the warmth from the surrounding air" - - "whispers in a language that predates humanity" - - "reaches out with shadowy tendrils" -drops: - remains: ectoplasm - loot: - - item_id: credits - weight: 30 - quantity: 20000 - - item_id: law_identifier - weight: 5 - - item_id: death_identifier - weight: 5 - - item_id: blood_identifier - weight: 5 - - item_id: bloodjunk - weight: 20 - quantity: 10 - - table: gem_table - weight: 15 - - item_id: uncut_diamond - weight: 10 - quantity: 5 - - table: herb_table_high - weight: 10 combat: - kind: combat - stats: - hp: 170 - attack: 62 - strength: 68 - defense: 72 - attack_type: slash - speed: 3 aggressive: true + assassin_level: 80 + attack_types: + - slash + combat_descriptions: + - swings its void scythe at %s + - drains the life force from %s + - envelops %s in absolute darkness + finishing_blow: lawjunk + kind: combat respawn_ticks: 200 - bonuses: - attack_bonus: 60 - strength_bonus: 60 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 100 - slash_defense: 110 - crush_defense: 90 - science_defense: -45 - ranged_defense: 75 - weakness: bio - assassin_level: 80 - finishing_blow: lawjunk - size: massive - combat_descriptions: - - "swings its void scythe at %s" - - "drains the life force from %s" - - "envelops %s in absolute darkness" + size: massive + stats: + attack: 62 + bonuses: + attack_bonus: 60 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 60 + defense: 72 + defenses: + crush_defense: 90 + ranged_defense: 75 + science_defense: -45 + slash_defense: 110 + stab_defense: 100 + weakness: bio + weakness_percent: 50 + hp: 170 + max_melee_hit: 15 + speed: 3 + strength: 68 +description: A shadowy figure that seems to absorb light itself. Its form shifts between a hooded specter and an armored construct. A scythe of dark energy flickers in its grasp. +drops: + loot: + - item_id: credits + quantity: 20000 + weight: 30 + - item_id: law_identifier + weight: 5 + - item_id: death_identifier + weight: 5 + - item_id: blood_identifier + weight: 5 + - item_id: bloodjunk + quantity: 10 + weight: 20 + - table: gem_table + weight: 15 + - item_id: uncut_diamond + quantity: 5 + weight: 10 + - table: herb_table_high + weight: 10 + remains: ectoplasm +idle_descriptions: + - flickers in and out of existence + - draws the warmth from the surrounding air + - whispers in a language that predates humanity + - reaches out with shadowy tendrils +name: void reaper diff --git a/data/mobs/examples/war_drone.yaml b/data/mobs/examples/war_drone.yaml index 7255a45..e0136a9 100644 --- a/data/mobs/examples/war_drone.yaml +++ b/data/mobs/examples/war_drone.yaml @@ -1,51 +1,54 @@ -name: war drone -description: "An upgraded combat drone with reinforced plating and a heavier weapons array. Its targeting systems whir as it locks onto you." -idle_descriptions: - - "hovers aggressively, weapons armed" - - "emits a low, threatening hum" - - "paints a targeting reticle on the wall" - - "cycles through weapon modes with a click" -drops: - remains: circuit_board - loot: - - item_id: credits - weight: 60 - quantity: 200 - - item_id: scrap - weight: 20 - quantity: 4 - - item_id: credits - weight: 10 - quantity: 500 - - table: gem_table - weight: 10 combat: - kind: combat - stats: - hp: 65 - attack: 25 - strength: 28 - defense: 25 - attack_type: slash - speed: 4 aggressive: true + attack_types: + - slash + combat_descriptions: + - fires a plasma bolt at %s + - slashes at %s with a rotary blade + - launches a concussion grenade at %s + kind: combat respawn_ticks: 50 - bonuses: - attack_bonus: 20 - strength_bonus: 18 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 35 - slash_defense: 30 - crush_defense: 40 - science_defense: -10 - ranged_defense: 20 - weakness: solar - size: medium - combat_descriptions: - - "fires a plasma bolt at %s" - - "slashes at %s with a rotary blade" - - "launches a concussion grenade at %s" + size: medium + stats: + attack: 25 + bonuses: + attack_bonus: 20 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 18 + defense: 25 + defenses: + crush_defense: 40 + ranged_defense: 20 + science_defense: -10 + slash_defense: 30 + stab_defense: 35 + weakness: solar + weakness_percent: 50 + hp: 65 + max_melee_hit: 5 + speed: 4 + strength: 28 +description: An upgraded combat drone with reinforced plating and a heavier weapons array. Its targeting systems whir as it locks onto you. +drops: + loot: + - item_id: credits + quantity: 200 + weight: 60 + - item_id: scrap + quantity: 4 + weight: 20 + - item_id: credits + quantity: 500 + weight: 10 + - table: gem_table + weight: 10 + remains: circuit_board +idle_descriptions: + - hovers aggressively, weapons armed + - emits a low, threatening hum + - paints a targeting reticle on the wall + - cycles through weapon modes with a click +name: war drone diff --git a/data/mobs/examples/zombie.yaml b/data/mobs/examples/zombie.yaml index 77bb5ed..e104989 100644 --- a/data/mobs/examples/zombie.yaml +++ b/data/mobs/examples/zombie.yaml @@ -1,42 +1,45 @@ -name: zombie -description: "A shambling corpse reanimated by residual asteroid radiation." -idle_descriptions: - - "groans and shuffles forward" - - "reaches out with decaying arms" - - "stumbles and catches itself" - - "stares blankly ahead" -drops: - remains: bones - loot: - - item_id: credits - weight: 60 - quantity: 50 - - item_id: credits - weight: 40 - quantity: 200 combat: - kind: combat - stats: - hp: 30 - attack: 10 - strength: 8 - defense: 8 - attack_type: crush - speed: 6 aggressive: true + attack_types: + - crush + kind: combat respawn_ticks: 35 - bonuses: - attack_bonus: 4 - strength_bonus: 6 - science_bonus: 0 - science_percent_bonus: 0 - ranged_bonus: 0 - ranged_strength_bonus: 0 - defenses: - stab_defense: 8 - slash_defense: 20 - crush_defense: -8 - science_defense: -15 - ranged_defense: 8 - weakness: solar - size: medium + size: medium + stats: + attack: 10 + bonuses: + attack_bonus: 4 + ranged_bonus: 0 + ranged_strength_bonus: 0 + science_bonus: 0 + science_percent_bonus: 0 + strength_bonus: 6 + defense: 8 + defenses: + crush_defense: -8 + ranged_defense: 8 + science_defense: -15 + slash_defense: 20 + stab_defense: 8 + weakness: solar + weakness_percent: 50 + hp: 30 + max_melee_hit: 2 + speed: 6 + strength: 8 +description: A shambling corpse reanimated by residual asteroid radiation. +drops: + loot: + - item_id: credits + quantity: 50 + weight: 60 + - item_id: credits + quantity: 200 + weight: 40 + remains: bones +idle_descriptions: + - groans and shuffles forward + - reaches out with decaying arms + - stumbles and catches itself + - stares blankly ahead +name: zombie diff --git a/internal/admin/api_courses.go b/internal/admin/api_courses.go index facb8f2..64218cb 100644 --- a/internal/admin/api_courses.go +++ b/internal/admin/api_courses.go @@ -25,8 +25,8 @@ func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "invalid json"}) return } - id, _ := m["id"].(string) - if strings.TrimSpace(id) == "" { + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { writeJSON(w, map[string]any{"error": "missing id"}) return } diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go index 721a6f8..617908c 100644 --- a/internal/admin/api_items.go +++ b/internal/admin/api_items.go @@ -137,8 +137,8 @@ func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) return } - id, _ := m["id"].(string) - if strings.TrimSpace(id) == "" { + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) return } diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 561c1e8..e546a39 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -41,11 +41,11 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { }, nil, nil) type RoomEntry struct { - ID int `json:"id"` - X int `json:"x"` - Y int `json:"y"` - Name string `json:"name"` - Color string `json:"color"` + ID int `json:"id"` + X int `json:"x"` + Y int `json:"y"` + Name string `json:"name"` + Color string `json:"color"` } type LinkEntry struct { diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go index a575076..0646753 100644 --- a/internal/admin/api_mobs.go +++ b/internal/admin/api_mobs.go @@ -138,8 +138,8 @@ func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) return } - id, _ := m["id"].(string) - if strings.TrimSpace(id) == "" { + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) return } diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go index 9fedccd..e468c79 100644 --- a/internal/admin/api_modules.go +++ b/internal/admin/api_modules.go @@ -25,8 +25,8 @@ func (s *AdminServer) handleModules(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "invalid json"}) return } - id, _ := m["id"].(string) - if strings.TrimSpace(id) == "" { + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { writeJSON(w, map[string]any{"error": "missing id"}) return } diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go index 7bcda1c..340c87c 100644 --- a/internal/admin/api_objects.go +++ b/internal/admin/api_objects.go @@ -137,8 +137,8 @@ func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid json body"}`, http.StatusBadRequest) return } - id, _ := m["id"].(string) - if strings.TrimSpace(id) == "" { + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) return } diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 76bb11c..cf23761 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -374,14 +374,14 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { cA, okA := grid.Coord[body.From] cB, okB := grid.Coord[body.To] - if !okA || !okB { - writeJSON(w, map[string]any{"error": "one or both rooms are not reachable from the seed room"}) - return - } - if cA[2] != cB[2] { - writeJSON(w, map[string]any{"error": "rooms must be on same z-level"}) - return - } + if !okA || !okB { + writeJSON(w, map[string]any{"error": "one or both rooms are not reachable from the seed room"}) + return + } + if cA[2] != cB[2] { + writeJSON(w, map[string]any{"error": "rooms must be on same z-level"}) + return + } dx, dy := cB[0]-cA[0], cB[1]-cA[1] for d, delta := range world.DirectionDeltas3D { if delta[0] == dx && delta[1] == dy && delta[2] == 0 { diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go index bdb8295..0d3e201 100644 --- a/internal/admin/api_techs.go +++ b/internal/admin/api_techs.go @@ -25,8 +25,8 @@ func (s *AdminServer) handleTechs(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "invalid json"}) return } - id, _ := m["id"].(string) - if strings.TrimSpace(id) == "" { + id, ok := m["id"].(string) + if !ok || strings.TrimSpace(id) == "" { writeJSON(w, map[string]any{"error": "missing id"}) return } diff --git a/internal/admin/static/mobeditor.js b/internal/admin/static/mobeditor.js index 3d6f4d4..4895c6f 100644 --- a/internal/admin/static/mobeditor.js +++ b/internal/admin/static/mobeditor.js @@ -22,16 +22,18 @@ var MOB_SECTIONS = [ detect: function(d) { return d.combat; }, fields: [ ['kind', 'Kind', 'select', 'combat|task'], + ['aggressive', 'Aggressive', 'checkbox'], + ['respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], ['stats.hp', 'HP', 'number', '', 'narrow'], ['stats.attack', 'Attack', 'number', '', 'narrow'], ['stats.strength', 'Strength', 'number', '', 'narrow'], ['stats.defense', 'Defense', 'number', '', 'narrow'], ['stats.ranged', 'Ranged', 'number', '', 'narrow'], ['stats.science', 'Science', 'number', '', 'narrow'], - ['stats.attack_type', 'Atk Type', 'select', 'stab|slash|crush|ranged|science'], ['stats.speed', 'Speed', 'number', '', 'narrow'], - ['stats.aggressive', 'Aggressive', 'checkbox'], - ['stats.respawn_ticks', 'Respawn Ticks', 'number', '', 'narrow'], + ['stats.max_melee_hit', 'Max Melee Hit', 'number', '', 'narrow'], + ['stats.max_ranged_hit', 'Max Ranged Hit', 'number', '', 'narrow'], + ['stats.max_science_hit', 'Max Science Hit', 'number', '', 'narrow'], ['stats.bonuses.attack_bonus', 'Atk Bonus', 'number', '', 'narrow'], ['stats.bonuses.strength_bonus', 'Str Bonus', 'number', '', 'narrow'], ['stats.bonuses.science_bonus', 'Sci Bonus', 'number', '', 'narrow'], @@ -44,6 +46,7 @@ var MOB_SECTIONS = [ ['stats.defenses.science_defense', 'Sci Def', 'number', '', 'narrow'], ['stats.defenses.ranged_defense', 'Rng Def', 'number', '', 'narrow'], ['stats.defenses.weakness', 'Weakness', 'text'], + ['stats.defenses.weakness_percent', 'Weakness %', 'number', '', 'narrow'], ['assassin_level', 'Assassin Lvl', 'number', '', 'narrow'], ['finishing_blow', 'Finishing Blow', 'search', '', '', null, 'items'], ['damage_without', 'Damage Without', 'search', '', '', null, 'items'], @@ -271,6 +274,52 @@ function renderMobCoreCard(data) { return h; } +// normalizeAttackTypes coerces a mob attack_types value (array) into an array of +// strings, tolerating a legacy scalar string for display only. +function normalizeAttackTypes(v) { + if (Array.isArray(v)) return v.slice(); + if (typeof v === 'string' && v) return [v]; + return []; +} + +// renderMobAttackTypeWidget renders the Atk Type selector: one required melee +// type (radio: stab/slash/crush) plus optional ranged/science (checkboxes). +function renderMobAttackTypeWidget(data) { + var combat = data.combat || {}; + var types = normalizeAttackTypes(combat.attack_types); + var melee = types.filter(function(t) { return t === 'stab' || t === 'slash' || t === 'crush'; })[0] || 'crush'; + var hasRanged = types.indexOf('ranged') >= 0; + var hasScience = types.indexOf('science') >= 0; + + var h = '
'; + h += 'Atk Type'; + h += '
'; + ['stab','slash','crush'].forEach(function(m) { + h += ''; + }); + h += '
'; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += '
'; + return h; +} + +// collectMobAttackType reads the Atk Type widget into an array: the selected +// melee type first, then any optional ranged/science types. +function collectMobAttackType() { + var melee = 'crush'; + var r = document.querySelector('input[name="mob_atktype_melee"]:checked'); + if (r) melee = r.value; + var out = [melee]; + var rng = document.getElementById('mob_atktype_ranged'); + var sci = document.getElementById('mob_atktype_science'); + if (rng && rng.checked) out.push('ranged'); + if (sci && sci.checked) out.push('science'); + return out; +} + function renderMobCombatCard(data) { var sec = MOB_SECTIONS.find(function(s) { return s.id === 'combat'; }); if (!sec) return ''; @@ -278,8 +327,12 @@ function renderMobCombatCard(data) { sec.fields.forEach(function(f) { byKey[f[0]] = f; }); var h = '
'; h += renderMobField(sec, byKey['kind'], data, -1); + h += renderMobField(sec, byKey['aggressive'], data, -1); + h += renderMobField(sec, byKey['respawn_ticks'], data, -1); h += '
'; + h += renderMobAttackTypeWidget(data); + h += '
'; h += 'Stats'; h += '
'; @@ -287,8 +340,11 @@ function renderMobCombatCard(data) { statKeys.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; - var statKeys2 = ['stats.attack_type','stats.speed','stats.aggressive','stats.respawn_ticks']; - statKeys2.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); + h += renderMobField(sec, byKey['stats.speed'], data, -1); + h += '
'; + h += '
'; + var statKeys3 = ['stats.max_melee_hit','stats.max_ranged_hit','stats.max_science_hit']; + statKeys3.forEach(function(k) { h += renderMobField(sec, byKey[k], data, -1); }); h += '
'; h += '
'; @@ -310,6 +366,7 @@ function renderMobCombatCard(data) { h += ''; h += '
'; h += renderMobField(sec, byKey['stats.defenses.weakness'], data, -1); + h += renderMobField(sec, byKey['stats.defenses.weakness_percent'], data, -1); h += '
'; h += ''; @@ -523,12 +580,15 @@ function mobAddSection() { if (id === 'combat') { mobData[sec.path] = { kind: 'combat', + aggressive: false, + attack_types: ['crush'], + respawn_ticks: 30, stats: { hp: 1, attack: 1, strength: 1, defense: 1, - attack_type: 'crush', speed: 5, - aggressive: false, respawn_ticks: 30, + speed: 5, + max_melee_hit: 0, max_ranged_hit: 0, max_science_hit: 0, bonuses: {attack_bonus:0, strength_bonus:0, science_bonus:0, science_percent_bonus:0, ranged_bonus:0, ranged_strength_bonus:0}, - defenses: {stab_defense:0, slash_defense:0, crush_defense:0, science_defense:0, ranged_defense:0} + defenses: {stab_defense:0, slash_defense:0, crush_defense:0, science_defense:0, ranged_defense:0, weakness_percent:0} }, size: 'small' }; @@ -610,6 +670,11 @@ function saveMob() { hasValues = ced_saveSectionFields(sec, sectionObj, mobCardPath, mobFieldID) || hasValues; } + if (sec.id === 'combat' && document.querySelector('input[name="mob_atktype_melee"]')) { + sectionObj['attack_types'] = collectMobAttackType(); + hasValues = true; + } + if (sec.subtables) { sec.subtables.forEach(function(st) { if (ced_saveSubtable(sec, st, sectionObj, mobCardPath, mobFieldIDSub)) hasValues = true; diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 8f75d30..fb91a15 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -42,12 +42,12 @@ func (c ChangeDesc) ShortDesc() string { } type UndoInfo struct { - CanUndo bool `json:"can_undo"` - CanRedo bool `json:"can_redo"` - UndoDesc string `json:"undo_desc"` - RedoDesc string `json:"redo_desc"` - StackSize int `json:"stack_size"` - RedoSize int `json:"redo_size"` + CanUndo bool `json:"can_undo"` + CanRedo bool `json:"can_redo"` + UndoDesc string `json:"undo_desc"` + RedoDesc string `json:"redo_desc"` + StackSize int `json:"stack_size"` + RedoSize int `json:"redo_size"` } type UndoStack struct { diff --git a/internal/behavior/types.go b/internal/behavior/types.go index 29e2631..5fdebdb 100644 --- a/internal/behavior/types.go +++ b/internal/behavior/types.go @@ -28,30 +28,30 @@ func WordPrefixMatch(input, name string) bool { type ActionType string const ( - TypeGather ActionType = "gather" - TypeSteal ActionType = "steal" - TypeTalk ActionType = "talk" - TypeUse ActionType = "use" - TypeBurn ActionType = "burn" - TypeStoke ActionType = "stoke" - TypeSearch ActionType = "search" - TypeIdentify ActionType = "identify" - TypePlant ActionType = "plant" - TypeHarvest ActionType = "harvest" - TypeRake ActionType = "rake" - TypeWater ActionType = "water" - TypeCure ActionType = "cure" - TypeObstacle ActionType = "obstacle" - TypeFletch ActionType = "fletch" - TypeClean ActionType = "cleaning" - TypeCook ActionType = "cook" - TypeSmelt ActionType = "smelting" - TypeSmith ActionType = "smith" - TypeCraft ActionType = "craft" - TypeCombine ActionType = "combine" - TypeMix ActionType = "mix" - TypeConstruct ActionType = "construct" - TypeTriggerModule ActionType = "trigger_module" + TypeGather ActionType = "gather" + TypeSteal ActionType = "steal" + TypeTalk ActionType = "talk" + TypeUse ActionType = "use" + TypeBurn ActionType = "burn" + TypeStoke ActionType = "stoke" + TypeSearch ActionType = "search" + TypeIdentify ActionType = "identify" + TypePlant ActionType = "plant" + TypeHarvest ActionType = "harvest" + TypeRake ActionType = "rake" + TypeWater ActionType = "water" + TypeCure ActionType = "cure" + TypeObstacle ActionType = "obstacle" + TypeFletch ActionType = "fletch" + TypeClean ActionType = "cleaning" + TypeCook ActionType = "cook" + TypeSmelt ActionType = "smelting" + TypeSmith ActionType = "smith" + TypeCraft ActionType = "craft" + TypeCombine ActionType = "combine" + TypeMix ActionType = "mix" + TypeConstruct ActionType = "construct" + TypeTriggerModule ActionType = "trigger_module" TypeUseInteraction ActionType = "use_interaction" ) @@ -72,23 +72,23 @@ func (a *Action) Advance() bool { } type GatherData struct { - ObjDefID string - InstanceKey string - InstanceIdx int - Wait float64 - DepleteTimer bool - Step int - Verb string - ToolName string + ObjDefID string + InstanceKey string + InstanceIdx int + Wait float64 + DepleteTimer bool + Step int + Verb string + ToolName string } type ProductionData struct { - ItemID string - Phase int - TicksPerCycle float64 - StartMessage string - EndMessage string - Remaining int + ItemID string + Phase int + TicksPerCycle float64 + StartMessage string + EndMessage string + Remaining int } type StealData struct { @@ -175,20 +175,20 @@ type SearchData struct { } type CombineData struct { - ItemID string - Phase int - StepIndex int - Remaining int - StartMessage string - EndMessage string + ItemID string + Phase int + StepIndex int + Remaining int + StartMessage string + EndMessage string TicksPerCycle float64 } type FletchData struct { - ItemID string - Phase int + ItemID string + Phase int TicksPerCycle float64 - Remaining int + Remaining int } type CleanData struct { diff --git a/internal/combat/formulas.go b/internal/combat/formulas.go index f4e1ba2..f165923 100644 --- a/internal/combat/formulas.go +++ b/internal/combat/formulas.go @@ -2,8 +2,24 @@ package combat import "math/rand" -func EffectiveRoll(level int, styleBonus int, equipBonus int) int { - effective := level + styleBonus + 8 +func PlayerEffective(level, styleBonus int) int { + return level + styleBonus + 8 +} + +func NPCEffective(level int) int { + return level + 9 +} + +// ScienceEffective is the effective-level formula for science attacks. It is +// intentionally the same as NPCEffective (level + 9) and is applied to BOTH the +// attacker and defender of a science-mod exchange (see scienceAttack), giving a +// symmetric science-vs-science model with no attack styles. It is kept as a +// distinct function to document that intent independently of NPCEffective. +func ScienceEffective(level int) int { + return level + 9 +} + +func AttackRoll(effective, equipBonus int) int { return effective * (equipBonus + 64) } @@ -17,13 +33,11 @@ func HitChance(attackRoll, defenseRoll int) float64 { } func HitCheck(attackRoll, defenseRoll int) bool { - chance := HitChance(attackRoll, defenseRoll) - return rand.Float64() < chance + return rand.Float64() < HitChance(attackRoll, defenseRoll) } -func MaxHit(level int, styleBonus int, equipBonus int) int { - effective := level + styleBonus + 8 - hit := (effective * (equipBonus + 64)) / 512 +func MaxHit(effective, equipStrBonus int) int { + hit := (effective*(equipStrBonus+64) + 320) / 640 if hit < 1 { hit = 1 } @@ -34,7 +48,11 @@ func RollDamage(maxHit int) int { if maxHit <= 0 { return 0 } - return 1 + rand.Intn(maxHit) + d := rand.Intn(maxHit + 1) + if d < 1 { + d = 1 + } + return d } func AttackStyleBonus(style string) (attack, strength, defense int) { @@ -69,15 +87,15 @@ func RangedStyleBonus(style string) (ranged, defense int) { func SelectBonus(attackType string, stab, slash, crush, science, ranged int) int { switch attackType { - case "stab": + case AttackStab: return stab - case "slash": + case AttackSlash: return slash - case "crush": + case AttackCrush: return crush - case "science": + case AttackScience: return science - case "ranged": + case AttackRanged: return ranged default: return crush diff --git a/internal/combat/types.go b/internal/combat/types.go new file mode 100644 index 0000000..bb2a897 --- /dev/null +++ b/internal/combat/types.go @@ -0,0 +1,36 @@ +package combat + +// Attack type identifiers shared across combat, mobs, weapons, hazards and +// validation. These are the canonical string values stored in YAML. +const ( + AttackStab = "stab" + AttackSlash = "slash" + AttackCrush = "crush" + AttackRanged = "ranged" + AttackScience = "science" + + // DefaultAttackType is the fallback when no attack type is specified. + DefaultAttackType = AttackCrush +) + +// MeleeAttackTypes lists the melee attack types (exactly one of which a mob +// must have). +var MeleeAttackTypes = []string{AttackStab, AttackSlash, AttackCrush} + +// AllAttackTypes lists every valid attack type. +var AllAttackTypes = []string{AttackStab, AttackSlash, AttackCrush, AttackRanged, AttackScience} + +// IsMeleeType reports whether t is a melee attack type. +func IsMeleeType(t string) bool { + return t == AttackStab || t == AttackSlash || t == AttackCrush +} + +// IsValidAttackType reports whether t is any recognized attack type. +func IsValidAttackType(t string) bool { + switch t { + case AttackStab, AttackSlash, AttackCrush, AttackRanged, AttackScience: + return true + default: + return false + } +} diff --git a/internal/combat/types_test.go b/internal/combat/types_test.go new file mode 100644 index 0000000..a999eb6 --- /dev/null +++ b/internal/combat/types_test.go @@ -0,0 +1,30 @@ +package combat + +import "testing" + +func TestIsMeleeType(t *testing.T) { + melee := []string{AttackStab, AttackSlash, AttackCrush} + for _, m := range melee { + if !IsMeleeType(m) { + t.Errorf("IsMeleeType(%q) = false, want true", m) + } + } + for _, nm := range []string{AttackRanged, AttackScience, "", "magic"} { + if IsMeleeType(nm) { + t.Errorf("IsMeleeType(%q) = true, want false", nm) + } + } +} + +func TestIsValidAttackType(t *testing.T) { + for _, v := range AllAttackTypes { + if !IsValidAttackType(v) { + t.Errorf("IsValidAttackType(%q) = false, want true", v) + } + } + for _, iv := range []string{"", "magic", "melee"} { + if IsValidAttackType(iv) { + t.Errorf("IsValidAttackType(%q) = true, want false", iv) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9afc2d1..fd0638d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,18 +7,18 @@ import ( ) type Config struct { - TickLength int `yaml:"tick_length"` - StartingRoom int `yaml:"starting_room"` - StartupValidation ValidationConfig `yaml:"startup_validation"` - GameConstants GameConstants `yaml:"game_constants"` - DefaultColors ColorsConfig `yaml:"default_colors"` - TLS TLSConfig `yaml:"tls"` - Telnet TelnetConfig `yaml:"telnet"` - TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"` - HTTP HTTPConfig `yaml:"http"` - HTTPS HTTPSConfig `yaml:"https"` - AdminHTTP AdminHTTPConfig `yaml:"admin_http"` - AdminHTTPS AdminHTTPSConfig `yaml:"admin_https"` + TickLength int `yaml:"tick_length"` + StartingRoom int `yaml:"starting_room"` + StartupValidation ValidationConfig `yaml:"startup_validation"` + GameConstants GameConstants `yaml:"game_constants"` + DefaultColors ColorsConfig `yaml:"default_colors"` + TLS TLSConfig `yaml:"tls"` + Telnet TelnetConfig `yaml:"telnet"` + TelnetTLS TelnetTLSConfig `yaml:"telnet_tls"` + HTTP HTTPConfig `yaml:"http"` + HTTPS HTTPSConfig `yaml:"https"` + AdminHTTP AdminHTTPConfig `yaml:"admin_http"` + AdminHTTPS AdminHTTPSConfig `yaml:"admin_https"` } // GameConstants holds tunable game-wide numbers that would otherwise be diff --git a/internal/game/act.go b/internal/game/act.go index 6d4a208..9e07886 100644 --- a/internal/game/act.go +++ b/internal/game/act.go @@ -236,13 +236,13 @@ func (g *Game) AdvanceActions() { g.advanceObstacle(sess, p) case behavior.TypeTalk: g.advanceTalk(sess, p) - case behavior.TypeTriggerModule: - g.advanceTriggerModule(sess, p) - case behavior.TypeUseInteraction: - g.advanceUseInteraction(sess, p) - case behavior.TypeCombine: - g.advanceCombine(sess, p) - default: + case behavior.TypeTriggerModule: + g.advanceTriggerModule(sess, p) + case behavior.TypeUseInteraction: + g.advanceUseInteraction(sess, p) + case behavior.TypeCombine: + g.advanceCombine(sess, p) + default: if productionActionTypes[string(p.Action.Type)] { g.advanceProduction(sess, p) } diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go index db7fee1..3d744db 100644 --- a/internal/game/act_combine.go +++ b/internal/game/act_combine.go @@ -32,12 +32,12 @@ func (g *Game) startCombine(sess *net.Session, p *player.Player, item *item.Item TargetID: item.ID, TargetName: outputName, Data: &behavior.CombineData{ - ItemID: item.ID, - Phase: 0, - StepIndex: 0, - Remaining: count, - StartMessage: startMsg, - EndMessage: endMsg, + ItemID: item.ID, + Phase: 0, + StepIndex: 0, + Remaining: count, + StartMessage: startMsg, + EndMessage: endMsg, TicksPerCycle: craft.TicksPerCycle, }, WaitLeft: engine.ToTicks(1), diff --git a/internal/game/act_farm.go b/internal/game/act_farm.go index 6060dca..8ed0d1b 100644 --- a/internal/game/act_farm.go +++ b/internal/game/act_farm.go @@ -113,8 +113,8 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { if diseased { g.setPlayerFlag(p, prefix+"_dead", true) g.setPlayerFlag(p, prefix+"_diseased", false) - sess.WriteLine(g.colorize(sess, "farm_disease", - fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID)))) + sess.WriteLine(g.colorize(sess, "farm_disease", + fmt.Sprintf("Your %s has died from disease!", g.seedDisplayName(sess, seedID)))) g.AccountStore.SaveCharacter(p) continue } @@ -138,23 +138,23 @@ func (g *Game) advanceFarmGrowth(sess *net.Session, p *player.Player) { g.setPlayerFlag(p, prefix+"_stage", stage) g.setPlayerFlag(p, prefix+"_ready", true) g.setPlayerFlag(p, prefix+"_watered", false) - sess.WriteLine(g.colorize(sess, "farm_grow", - fmt.Sprintf("Your %s is fully grown and ready to harvest!", - g.seedDisplayName(sess, seedID)))) + sess.WriteLine(g.colorize(sess, "farm_grow", + fmt.Sprintf("Your %s is fully grown and ready to harvest!", + g.seedDisplayName(sess, seedID)))) } else { if !watered && rand.Float64() < 0.10 { g.setPlayerFlag(p, prefix+"_stage", stage) g.setPlayerFlag(p, prefix+"_diseased", true) g.setPlayerFlag(p, prefix+"_watered", false) - sess.WriteLine(g.colorize(sess, "farm_disease", - fmt.Sprintf("Your %s has become diseased!", - g.seedDisplayName(sess, seedID)))) + sess.WriteLine(g.colorize(sess, "farm_disease", + fmt.Sprintf("Your %s has become diseased!", + g.seedDisplayName(sess, seedID)))) } else { g.setPlayerFlag(p, prefix+"_stage", stage) g.setPlayerFlag(p, prefix+"_watered", false) - sess.WriteLine(g.colorize(sess, "farm_grow", - fmt.Sprintf("Your %s has grown to stage %d/%d.", - g.seedDisplayName(sess, seedID), stage, maxStages))) + sess.WriteLine(g.colorize(sess, "farm_grow", + fmt.Sprintf("Your %s has grown to stage %d/%d.", + g.seedDisplayName(sess, seedID), stage, maxStages))) } } g.AccountStore.SaveCharacter(p) diff --git a/internal/game/act_gather.go b/internal/game/act_gather.go index c34c6ae..f7a63f6 100644 --- a/internal/game/act_gather.go +++ b/internal/game/act_gather.go @@ -114,12 +114,12 @@ func (g *Game) startGather(sess *net.Session, p *player.Player, obj *object.Obje } d := &behavior.GatherData{ - ObjDefID: obj.ID, - InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), - InstanceIdx: st.Index + 1, - Wait: wait, - Verb: verb, - ToolName: toolName, + ObjDefID: obj.ID, + InstanceKey: g.World.ObjStateKey(st.RoomID, st.DefID, st.Index), + InstanceIdx: st.Index + 1, + Wait: wait, + Verb: verb, + ToolName: toolName, } if cfg.DepleteTimer > 0 { d.DepleteTimer = true @@ -225,14 +225,14 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) { } g.AccountStore.SaveCharacter(p) - msg := drop.SuccessMessage - coloredName := g.itemColorize(sess, itemDef, itemName) - if msg == "" { - msg = fmt.Sprintf("You manage to get some %s.", coloredName) - } else { - msg = strings.ReplaceAll(msg, "%n", coloredName) - msg = color.ExpandTags(g.colorMode(sess), msg) - } + msg := drop.SuccessMessage + coloredName := g.itemColorize(sess, itemDef, itemName) + if msg == "" { + msg = fmt.Sprintf("You manage to get some %s.", coloredName) + } else { + msg = strings.ReplaceAll(msg, "%n", coloredName) + msg = color.ExpandTags(g.colorMode(sess), msg) + } if xp > 0 && p.OptionBool("xp_drops") { msg += g.formatXpDropSingle(sess, p, player.SkillName(cfg.Skill), xp) } diff --git a/internal/game/act_state.go b/internal/game/act_state.go index c4ccaba..efc5f7d 100644 --- a/internal/game/act_state.go +++ b/internal/game/act_state.go @@ -81,11 +81,11 @@ func (g *Game) playerActionDisplay(p *player.Player) string { return "mixing some " + a.TargetName case behavior.TypeConstruct: return "constructing some " + a.TargetName - case behavior.TypeTriggerModule: - return "triggering " + a.TargetName - case behavior.TypeUseInteraction: - return "using " + a.TargetName - } + case behavior.TypeTriggerModule: + return "triggering " + a.TargetName + case behavior.TypeUseInteraction: + return "using " + a.TargetName + } } if a := p.BackgroundAction; a != nil { diff --git a/internal/game/act_steal.go b/internal/game/act_steal.go index f5d3dbf..a64bc96 100644 --- a/internal/game/act_steal.go +++ b/internal/game/act_steal.go @@ -37,18 +37,18 @@ var stallGuardTalk = &behavior.TalkConfig{ }, "bribe": { Messages: []string{"Smart choice. Hand over 500 credits and we'll forget this happened."}, - Action: &behavior.NodeAction{Credits: -500}, - Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}}, + Action: &behavior.NodeAction{Credits: -500}, + Options: []behavior.TalkOption{{Text: "\"Fine, take it.\""}}, }, "jail": { Messages: []string{"Off to the detention cell with you!"}, - Action: &behavior.NodeAction{Teleport: 162}, - Options: []behavior.TalkOption{{Text: "(You are dragged away)"}}, + Action: &behavior.NodeAction{Teleport: 162}, + Options: []behavior.TalkOption{{Text: "(You are dragged away)"}}, }, "fight": { Messages: []string{"Then defend yourself!"}, - Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}}, - Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}}, + Action: &behavior.NodeAction{SetFlags: map[string]any{"guard_hostile": true}}, + Options: []behavior.TalkOption{{Text: "(The guard attacks!)"}}, }, }, } diff --git a/internal/game/act_use.go b/internal/game/act_use.go index 46ca304..dcddd52 100644 --- a/internal/game/act_use.go +++ b/internal/game/act_use.go @@ -81,8 +81,8 @@ func (g *Game) advanceUse(sess *net.Session, p *player.Player) { skillLevel := p.Level(player.SkillName(cfg.Skill)) chance := behavior.SuccessChance(*cfg.Success, skillLevel, cfg.Level) if rand.Float64() >= chance { - if cfg.FailMessage != "" { - sess.WriteLine(cfg.FailMessage) + if cfg.FailMessage != "" { + sess.WriteLine(cfg.FailMessage) } p.Action.WaitLeft = engine.ToTicks(cfg.TicksPerCycle) return diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go index d60bb05..16e7587 100644 --- a/internal/game/cmd_attack.go +++ b/internal/game/cmd_attack.go @@ -43,7 +43,7 @@ func (g *Game) respawnMob(instanceID string) { return } homeRoom := inst.HomeRoomID - inst.RoomID = homeRoom + g.MobStore.SetInstanceRoom(instanceID, homeRoom) inst.HP = inst.MaxHP g.MobStore.RollIdleDescription(inst) diff --git a/internal/game/cmd_dig.go b/internal/game/cmd_dig.go index 0691b90..96a5421 100644 --- a/internal/game/cmd_dig.go +++ b/internal/game/cmd_dig.go @@ -263,7 +263,6 @@ func findNextRoomID(currentRoomPath string) (int, error) { } } - func (g *Game) buildGridFrom(fromRoomID int) (coord map[int][3]int, roomAt map[[3]int]int) { roomIndex := g.World.RoomIndex() rg := world.BuildGrid(fromRoomID, diff --git a/internal/game/cmd_room_insert.go b/internal/game/cmd_room_insert.go index 6164e50..5c24d60 100644 --- a/internal/game/cmd_room_insert.go +++ b/internal/game/cmd_room_insert.go @@ -124,7 +124,7 @@ func (g *Game) roomInsert(sess *net.Session, args []string) { {Text: "A featureless room."}, }, Exits: map[world.ExitDir]world.ExitDef{ - dir: {Room: targetID}, + dir: {Room: targetID}, oppositeDir: {Room: p.RoomID}, }, } diff --git a/internal/game/cmd_smelt.go b/internal/game/cmd_smelt.go index 4500c64..5f7a7b3 100644 --- a/internal/game/cmd_smelt.go +++ b/internal/game/cmd_smelt.go @@ -23,7 +23,7 @@ func (g *Game) doSmelt(sess *net.Session, input string) { return } - items := g.CraftIndex.BySubtype("smelting") + items := g.CraftIndex.BySubtype("smelting") if input == "" { g.showSmeltMenu(sess, p, items, stationDefID, stationName) diff --git a/internal/game/cmd_stats.go b/internal/game/cmd_stats.go index 0430c6e..6942272 100644 --- a/internal/game/cmd_stats.go +++ b/internal/game/cmd_stats.go @@ -17,27 +17,10 @@ func (g *Game) doStats(sess *net.Session) { p := sess.Player totals := g.playerEquipBonuses(p) - attackType := "crush" weaponName := "unarmed" - var weaponType item.EquipmentType - var weaponDef *item.ItemDef - if itemID, ok := p.Equipment[item.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - weaponDef = def - weaponName = def.Name - weaponType = def.EquipmentType() - if def.AttackType() != "" { - attackType = def.AttackType() - } else if def.EquipmentType() == item.EquipRangedWeapon { - attackType = "ranged" - } else if def.EquipmentType() == item.EquipScienceWeapon { - attackType = "science" - } - } - } - + attackType, weaponType, weaponDef := g.resolvePlayerAttackType(p) if weaponDef != nil { - weaponName = g.itemColorize(sess, weaponDef, weaponName) + weaponName = g.itemColorize(sess, weaponDef, weaponDef.Name) } sess.WriteLines( "", @@ -60,15 +43,15 @@ func (g *Game) doStats(sess *net.Session) { var attRoll, maxHitVal int if weaponType == item.EquipRangedWeapon { rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) - attRoll = combat.EffectiveRoll(p.Level(player.Ranged), rangedBonus, totals.RangedAttack) - maxHitVal = combat.MaxHit(p.Level(player.Ranged), rangedBonus, totals.RangedStrength) + attRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Ranged), rangedBonus), totals.RangedAttack) + maxHitVal = combat.MaxHit(combat.PlayerEffective(p.Level(player.Ranged), rangedBonus), totals.RangedStrength) } else { attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) equipAtt := combat.SelectBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) - attRoll = combat.EffectiveRoll(p.Level(player.Accuracy), attBonus, equipAtt) - maxHitVal = combat.MaxHit(p.Level(player.Strength), strBonus, totals.StrengthBonus) + attRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Accuracy), attBonus), equipAtt) + maxHitVal = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength), strBonus), totals.StrengthBonus) } sess.WriteLines( diff --git a/internal/game/cmd_trigger_combat.go b/internal/game/cmd_trigger_combat.go index cce9781..ecb4fbb 100644 --- a/internal/game/cmd_trigger_combat.go +++ b/internal/game/cmd_trigger_combat.go @@ -10,6 +10,14 @@ import ( "thehouseoficarus/internal/world" ) +// scienceAttack resolves a player science-mod attack against a mob. +// +// Unlike melee/ranged (which roll the player's accuracy vs the mob's Defense), +// science uses a symmetric science-vs-science model: both the attack roll and +// the mob's defense roll use ScienceEffective, keyed off the player's Science +// level and the mob's Science stat (plus ScienceDefense) respectively — the +// mob's Defense stat is not involved. Science has no attack styles, so no style +// bonus applies. func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.MobInstance, mod *ModDef) bool { if p.Level(player.Science) < mod.Level { sess.WriteLine(fmt.Sprintf("You need level %d science to trigger %s.", mod.Level, mod.Name)) @@ -24,17 +32,31 @@ func (g *Game) scienceAttack(sess *net.Session, p *player.Player, mob *world.Mob equipSciBonus := g.totalScienceAttack(p) effectiveScience := p.Level(player.Science) + g.techLevelBonus(p, "science") + g.buffLevelBonus(p, "science") - attRoll := combat.EffectiveRoll(effectiveScience, 0, equipSciBonus) + attRoll := combat.AttackRoll(combat.ScienceEffective(effectiveScience), equipSciBonus) - mobSciDef := mob.ScienceDefense - defRoll := combat.EffectiveRoll(mob.Defense, 0, mobSciDef) + defRoll := combat.AttackRoll(combat.ScienceEffective(mob.Science), mob.ScienceDefense) - if mob.Weakness == mod.Element { - attRoll = attRoll * 13 / 10 + if mob.Weakness == mod.Element && mob.WeaknessPercent > 0 { + attRoll = int(float64(attRoll) * (1.0 + float64(mob.WeaknessPercent)/100.0)) } if combat.HitCheck(attRoll, defRoll) { - dmg := combat.RollDamage(mod.MaxHit) + totals := g.playerEquipBonuses(p) + maxHit := mod.MaxHit + if totals.ScienceDamage > 0 { + maxHit = int(float64(maxHit) * (1.0 + float64(totals.ScienceDamage)/100.0)) + } + // Weakness is an additive bonus computed off the mod's base MaxHit (not + // the science-damage-boosted maxHit), so equipment and weakness stack + // additively. With no science-damage gear this equals a ×(1+pct) boost, + // matching the multiplicative weakness applied to the attack roll above. + if mob.Weakness == mod.Element && mob.WeaknessPercent > 0 { + maxHit += int(float64(mod.MaxHit) * float64(mob.WeaknessPercent) / 100.0) + } + if maxHit < 1 { + maxHit = 1 + } + dmg := combat.RollDamage(maxHit) if dmg < 0 { dmg = 0 } diff --git a/internal/game/cmd_trigger_enchant.go b/internal/game/cmd_trigger_enchant.go index 0279b4e..62a3c48 100644 --- a/internal/game/cmd_trigger_enchant.go +++ b/internal/game/cmd_trigger_enchant.go @@ -41,6 +41,7 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, g.cancelAction(p) } + inputID := inv.ItemID inv.ItemID = outputID g.triggerModReward(sess, p, mod, 1.0) @@ -50,8 +51,8 @@ func (g *Game) triggerEnchant(sess *net.Session, p *player.Player, mod *ModDef, if outputDef != nil { outputName = outputDef.Name } - inputDef, _ := g.ItemStore.Load(inv.ItemID) - inputName := inv.ItemID + inputDef, _ := g.ItemStore.Load(inputID) + inputName := inputID if inputDef != nil { inputName = inputDef.Name } diff --git a/internal/game/cmd_trigger_utility.go b/internal/game/cmd_trigger_utility.go index 5a4dcc7..d06551a 100644 --- a/internal/game/cmd_trigger_utility.go +++ b/internal/game/cmd_trigger_utility.go @@ -170,7 +170,7 @@ func (g *Game) triggerSuperheat(sess *net.Session, p *player.Player, mod *ModDef } var match *item.ItemDef - for _, item := range g.CraftIndex.BySubtype("smelting") { + for _, item := range g.CraftIndex.BySubtype("smelting") { if craftMatchesEntry(item.FirstCraft(), inv.ItemID) { match = item break diff --git a/internal/game/cmd_use.go b/internal/game/cmd_use.go index 72de3d6..8540074 100644 --- a/internal/game/cmd_use.go +++ b/internal/game/cmd_use.go @@ -417,7 +417,7 @@ func (g *Game) doUseItemOnTarget(sess *net.Session, p *player.Player, itemAName, } func (g *Game) isGrimyHerb(itemID string) bool { - items := g.CraftIndex.BySubtype("cleaning") + items := g.CraftIndex.BySubtype("cleaning") for _, item := range items { for _, e := range item.FirstCraft().Ingredients { for _, id := range e.Items { diff --git a/internal/game/combat_attack.go b/internal/game/combat_attack.go index ddc3b8f..6c314ff 100644 --- a/internal/game/combat_attack.go +++ b/internal/game/combat_attack.go @@ -8,7 +8,6 @@ import ( "strings" "thehouseoficarus/internal/behavior" - "thehouseoficarus/internal/color" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/engine" "thehouseoficarus/internal/item" @@ -213,13 +212,13 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn if mod != nil && g.hasJunkCost(p, mod) { g.scienceAttack(sess, p, currentMob, mod) } else if mod != nil { - sess.WriteLine(g.colorize(sess, "error", - fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name))) + sess.WriteLine(g.colorize(sess, "error", + fmt.Sprintf("You're out of junk for %s!! You flail with your fists in desperation!", mod.Name))) g.playerAttackUnarmed(sess, p, currentMob) } else { p.AutotriggerMod = "" - sess.WriteLine(g.colorize(sess, "error", - "No autotrigger set! Set a module with the 'autotrigger' command.")) + sess.WriteLine(g.colorize(sess, "error", + "No autotrigger set! Set a module with the 'autotrigger' command.")) g.playerAttackUnarmed(sess, p, currentMob) } } else { @@ -289,20 +288,29 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI } } -func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.EquipmentType, attRoll int, defRoll int, maxHit int) { - attackType = "crush" +// resolvePlayerAttackType determines the player's attack type and main-hand +// weapon type (and the weapon def, if any) from their equipped weapon, +// defaulting to crush when unarmed or when the weapon specifies no type. +func (g *Game) resolvePlayerAttackType(p *player.Player) (attackType string, weaponType item.EquipmentType, def *item.ItemDef) { + attackType = combat.DefaultAttackType if itemID, ok := p.Equipment[item.SlotMainHand]; ok { - if def, err := g.ItemStore.Load(itemID); err == nil { - if def.AttackType() != "" { - attackType = def.AttackType() - } else if def.EquipmentType() == item.EquipRangedWeapon { - attackType = "ranged" - } else if def.EquipmentType() == item.EquipScienceWeapon { - attackType = "science" + if d, err := g.ItemStore.Load(itemID); err == nil { + def = d + weaponType = d.EquipmentType() + if at := d.AttackType(); at != "" { + attackType = at + } else if weaponType == item.EquipRangedWeapon { + attackType = combat.AttackRanged + } else if weaponType == item.EquipScienceWeapon { + attackType = combat.AttackScience } - weaponType = def.EquipmentType() } } + return +} + +func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstance) (attackType string, weaponType item.EquipmentType, attRoll int, defRoll int, maxHit int) { + attackType, weaponType, _ = g.resolvePlayerAttackType(p) totals := g.playerEquipBonuses(p) isRanged := weaponType == item.EquipRangedWeapon @@ -311,26 +319,26 @@ func (g *Game) calculatePlayerAttackRoll(p *player.Player, mob *world.MobInstanc rangedBonus, _ := combat.RangedStyleBonus(string(p.AttackStyle)) equipAttack := totals.RangedAttack effectiveRanged := p.Level(player.Ranged) + g.techLevelBonus(p, "ranged") + g.buffLevelBonus(p, "ranged") - attRoll = combat.EffectiveRoll(effectiveRanged, rangedBonus, equipAttack) + attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveRanged, rangedBonus), equipAttack) mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(effectiveRanged, rangedBonus, totals.RangedStrength) + defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus) + maxHit = combat.MaxHit(combat.PlayerEffective(effectiveRanged, rangedBonus), totals.RangedStrength) } else { attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) equipAttack := combat.SelectBonus(attackType, totals.StabAttack, totals.SlashAttack, totals.CrushAttack, totals.ScienceAttack, totals.RangedAttack) effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") - attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, equipAttack) + attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveAccuracy, attBonus), equipAttack) mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, totals.StrengthBonus) + defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus) + maxHit = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus), totals.StrengthBonus) } return } @@ -355,13 +363,13 @@ func (g *Game) calculateUnarmedAttackRoll(p *player.Player, mob *world.MobInstan attBonus, strBonus, _ := combat.AttackStyleBonus(string(p.AttackStyle)) effectiveAccuracy := p.Level(player.Accuracy) + g.techLevelBonus(p, "accuracy") + g.buffLevelBonus(p, "accuracy") - attRoll = combat.EffectiveRoll(effectiveAccuracy, attBonus, 0) + attRoll = combat.AttackRoll(combat.PlayerEffective(effectiveAccuracy, attBonus), 0) mobDefBonus := combat.SelectBonus(attackType, mob.StabDefense, mob.SlashDefense, mob.CrushDefense, mob.ScienceDefense, mob.RangedDefense) - defRoll = combat.EffectiveRoll(mob.Defense, 0, mobDefBonus) - maxHit = combat.MaxHit(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus, 0) + defRoll = combat.AttackRoll(combat.NPCEffective(mob.Defense), mobDefBonus) + maxHit = combat.MaxHit(combat.PlayerEffective(p.Level(player.Strength)+g.techLevelBonus(p, "strength")+g.buffLevelBonus(p, "strength"), strBonus), 0) return } @@ -380,14 +388,9 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo } if mob.FinishingBlow != "" && mob.HP == 1 { - fbDef, _ := g.ItemStore.Load(mob.FinishingBlow) - fbName := mob.FinishingBlow - if fbDef != nil { - fbName = fbDef.Name - } sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf("%s resists death! Use %s on it to finish it off.", - mobDisplayName(mob, false), fbName))) + mobDisplayName(mob, false), g.itemDisplayName(mob.FinishingBlow)))) } isRanged := weaponType == item.EquipRangedWeapon @@ -401,19 +404,11 @@ func (g *Game) applyPlayerHit(sess *net.Session, p *player.Player, mob *world.Mo g.writeTaskProgress(sess, p, mob, dmg, gains) } else { mobName := mobDisplayName(mob, true) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } - w := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)) - if w2 := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)); w2 > w { - w = w2 - } + attacker := mobDisplayNameCap(mob, true) prefix := fmt.Sprintf("You hit %s for %s damage.", g.colorize(sess, "mob", mobName), g.colorize(sess, "damage_dealt", fmt.Sprint(dmg))) - visLen := color.VisibleLen(prefix) - if visLen < w { - prefix += strings.Repeat(" ", w-visLen+1) - } + prefix = padCombatPrefix(prefix, + fmt.Sprintf("You hit %s for %d damage.", mobName, 999), + fmt.Sprintf("%s hits you for %d damage.", attacker, 999)) hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, mob.HP, mob.MaxHP), mob.HP, mob.MaxHP) line := prefix + hpSuffix + g.formatXpDrop(sess, p, gains) sess.WriteLine(line) diff --git a/internal/game/combat_mob.go b/internal/game/combat_mob.go index c64e393..a3fc040 100644 --- a/internal/game/combat_mob.go +++ b/internal/game/combat_mob.go @@ -13,42 +13,114 @@ import ( "thehouseoficarus/internal/world" ) +// mobMeleeType returns the mob's single melee attack type (stab/slash/crush), +// defaulting to crush if the mob has none configured. +func mobMeleeType(mob *world.MobInstance) string { + for _, t := range mob.AttackTypes { + if combat.IsMeleeType(t) { + return t + } + } + return combat.DefaultAttackType +} + +// mobEffectiveMaxScienceHit returns the mob's max science hit with its science +// percent bonus applied, matching the calculation used in calculateMobAttack. +func mobEffectiveMaxScienceHit(mob *world.MobInstance) int { + base := mob.MaxScienceHit + if mob.SciencePercentBonus > 0 { + base = int(float64(base) * (1.0 + float64(mob.SciencePercentBonus)/100.0)) + } + if base < 1 { + base = 1 + } + return base +} + +// mobStrongestRangedScience returns whichever of "ranged"/"science" the mob +// possesses with the higher max hit, or "" if the mob has neither. +func mobStrongestRangedScience(mob *world.MobInstance) string { + hasRanged, hasScience := false, false + for _, t := range mob.AttackTypes { + switch t { + case combat.AttackRanged: + hasRanged = true + case combat.AttackScience: + hasScience = true + } + } + switch { + case hasRanged && hasScience: + if mobEffectiveMaxScienceHit(mob) > mob.MaxRangedHit { + return combat.AttackScience + } + return combat.AttackRanged + case hasRanged: + return combat.AttackRanged + case hasScience: + return combat.AttackScience + default: + return "" + } +} + +// effectiveMobAttackType resolves which single attack type the mob uses for an +// attack against this player right now. Normally the mob uses its melee type; +// when the player is safespotted from melee the mob switches to its strongest +// ranged/science type. Returns "" if the mob cannot attack (melee blocked and no +// ranged/science fallback). +func (g *Game) effectiveMobAttackType(p *player.Player, mob *world.MobInstance) string { + if g.isSafespotted(p.Name) && g.safespotBlocksMelee(p, mob) { + return mobStrongestRangedScience(mob) + } + return mobMeleeType(mob) +} + func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInstance) { - if g.isSafespotted(p.Name) && g.safespotBlocksMob(p, mob) { + attackType := g.effectiveMobAttackType(p, mob) + if attackType == "" { return } - attRoll, defRoll := g.calculateMobAttack(p, mob) + attRoll, defRoll, maxHit := g.calculateMobAttack(p, mob, attackType) if combat.HitCheck(attRoll, defRoll) { - g.applyMobHit(sess, p, mob) + g.applyMobHit(sess, p, mob, attackType, maxHit) } else { g.applyMobMiss(sess, mob) } } -func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance) (attRoll int, defRoll int) { +func (g *Game) calculateMobAttack(p *player.Player, mob *world.MobInstance, mobAttackType string) (attRoll, defRoll, maxHit int) { _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) - mobAttackType := mob.AttackType if mobAttackType == "" { - mobAttackType = "crush" + mobAttackType = combat.DefaultAttackType } - attRoll = combat.EffectiveRoll(mob.Attack, 0, mob.AttackBonus) + switch mobAttackType { + case combat.AttackRanged: + attRoll = combat.AttackRoll(combat.NPCEffective(mob.Ranged), mob.RangedBonus) + maxHit = mob.MaxRangedHit + case combat.AttackScience: + attRoll = combat.AttackRoll(combat.ScienceEffective(mob.Science), mob.ScienceBonus) + maxHit = mobEffectiveMaxScienceHit(mob) + default: + attRoll = combat.AttackRoll(combat.NPCEffective(mob.Attack), mob.AttackBonus) + maxHit = mob.MaxMeleeHit + } totals := g.playerEquipBonuses(p) equipDef := combat.SelectBonus(mobAttackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) - defRoll = combat.EffectiveRoll(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus, equipDef) + defRoll = combat.AttackRoll(combat.PlayerEffective(p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), defStyleBonus), equipDef) return } -func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance) { - maxHit := combat.MaxHit(mob.Strength, 0, mob.StrengthBonus) +func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobInstance, attackType string, maxHit int) { dmg := combat.RollDamage(maxHit) - dmg = g.applyTechProtection(p, mob, dmg) + dmg = g.applyTechProtection(p, attackType, dmg) if mob.DamageWithout != "" { hasProtection := false @@ -66,15 +138,8 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn cs := g.Combat.Get(p.Name) if cs != nil && !cs.DamageWarningShown { cs.DamageWarningShown = true - fbDef, _ := g.ItemStore.Load(mob.DamageWithout) - fbName := mob.DamageWithout - if fbDef != nil { - fbName = fbDef.Name - } - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } + fbName := g.itemDisplayName(mob.DamageWithout) + attacker := mobDisplayNameCap(mob, true) sess.WriteLine(g.colorize(sess, "warning", fmt.Sprintf(" %s's attack is extra effective! Equip %s for protection.", attacker, fbName))) @@ -89,20 +154,12 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn p.StartRegen() g.AccountStore.SaveCharacter(p) - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name - } + attacker := mobDisplayNameCap(mob, true) mobName := mobDisplayName(mob, true) - w := len(fmt.Sprintf("%s hits you for %d damage.", attacker, 999)) - if w2 := len(fmt.Sprintf("You hit %s for %d damage.", mobName, 999)); w2 > w { - w = w2 - } prefix := fmt.Sprintf("%s hits you for %s damage.", g.colorize(sess, "mob", attacker), g.colorize(sess, "damage_taken", fmt.Sprint(dmg))) - visLen := color.VisibleLen(prefix) - if visLen < w { - prefix += strings.Repeat(" ", w-visLen+1) - } + prefix = padCombatPrefix(prefix, + fmt.Sprintf("%s hits you for %d damage.", attacker, 999), + fmt.Sprintf("You hit %s for %d damage.", mobName, 999)) hpSuffix := fmt.Sprintf("%s [%2d/%d]", g.hpBar(sess, p.HP, p.MaxHP()), p.HP, p.MaxHP()) sess.WriteLine(prefix + hpSuffix) @@ -123,11 +180,23 @@ func (g *Game) applyMobHit(sess *net.Session, p *player.Player, mob *world.MobIn } } -func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { - attacker := mob.Name - if !mob.Unique { - attacker = "The " + mob.Name +// padCombatPrefix right-pads a colored damage line so a trailing HP bar aligns +// with the mirror-image line (player-hit vs mob-hit). plainSelf/plainOther are +// the uncolored versions of both lines (using a 999 damage placeholder) and are +// used only to compute the alignment width. +func padCombatPrefix(prefix, plainSelf, plainOther string) string { + w := len(plainSelf) + if len(plainOther) > w { + w = len(plainOther) + } + if visLen := color.VisibleLen(prefix); visLen < w { + prefix += strings.Repeat(" ", w-visLen+1) } + return prefix +} + +func (g *Game) applyMobMiss(sess *net.Session, mob *world.MobInstance) { + attacker := mobDisplayNameCap(mob, true) sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf("%s misses you.", attacker))) } @@ -139,102 +208,108 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst return } - if mob != nil && mob.HP <= 0 { - isTask := mob.IsTask() - p.Stats.RecordMobKill(mob.DefID) + if mob == nil || mob.HP > 0 { + return + } - if isTask { - complete := mob.CompleteMessage - if complete == "" { - complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true)) - } + isTask := mob.IsTask() + p.Stats.RecordMobKill(mob.DefID) + + g.announceKill(sess, p, mob, isTask) + g.awardKillDrops(sess, p, mob, isTask) + g.scheduleMobRespawn(mob) + g.writePrompt(sess) +} + +// announceKill prints the victory line to the killer and broadcasts to the room. +func (g *Game) announceKill(sess *net.Session, p *player.Player, mob *world.MobInstance, isTask bool) { + if isTask { + complete := mob.CompleteMessage + if complete == "" { + complete = fmt.Sprintf("You finish your work on %s!", mobDisplayName(mob, true)) + } sess.WriteLine(g.colorize(sess, "victory", complete)) } else { sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("You have defeated %s!", mobDisplayName(mob, true)))) - g.onAssassinKill(sess, p, mob) - } + g.onAssassinKill(sess, p, mob) + } - if g.Hub != nil { - for _, other := range g.Hub.PlayersInRoom(p.RoomID) { - if other != sess && other.Player != nil { - if isTask { - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false)))) - } else { - op := other.Player - mobLvl := mobCombatLevel(mob) - levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) - other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) - } - } - } + if g.Hub == nil { + return + } + for _, other := range g.Hub.PlayersInRoom(p.RoomID) { + if other == sess || other.Player == nil { + continue + } + if isTask { + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s finishes working on %s.", p.Name, mobDisplayName(mob, false)))) + } else { + mobLvl := mobCombatLevel(mob) + levelStr := g.levelColorize(other, other.Player.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl)) + other.WriteLine(g.colorize(other, "broadcast", fmt.Sprintf("%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))) } + } +} - dropLabel := func() string { - if isTask { - return "You receive:" - } - dropper := mob.Name - if !mob.Unique { - dropper = "The " + mob.Name - } - return dropper + " drops:" - }() - - if mob.Drops.Remains != "" { - g.World.AddReservedItem(p.RoomID, mob.Drops.Remains, 1, p.Name) - def, _ := g.ItemStore.Load(mob.Drops.Remains) - name := mob.Drops.Remains - if def != nil { - name = def.Name - } - coloredName := g.itemColorize(sess, def, name) +// awardKillDrops resolves the mob's remains and loot table onto the ground, +// reserved for the killer, and reports each drop. +func (g *Game) awardKillDrops(sess *net.Session, p *player.Player, mob *world.MobInstance, isTask bool) { + dropLabel := "You receive:" + if !isTask { + dropLabel = mobDisplayNameCap(mob, true) + " drops:" + } + + writeDrop := func(itemID string, qty int) { + g.World.AddReservedItem(p.RoomID, itemID, qty, p.Name) + def, _ := g.ItemStore.Load(itemID) + name := itemID + if def != nil { + name = def.Name + } + coloredName := g.itemColorize(sess, def, name) + if qty > 1 { + sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName)) + } else { sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) } + } - if len(mob.Drops.Loot) > 0 { - for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) { - if entry.ItemID == "" { - continue - } - qty := entry.Quantity - if qty <= 0 { - qty = 1 - } - g.World.AddReservedItem(p.RoomID, entry.ItemID, qty, p.Name) - def, _ := g.ItemStore.Load(entry.ItemID) - name := entry.ItemID - if def != nil { - name = def.Name - } - coloredName := g.itemColorize(sess, def, name) - if qty > 1 { - sess.WriteLine(fmt.Sprintf("%s %d x %s", g.colorize(sess, "drop_message", dropLabel), qty, coloredName)) - } else { - sess.WriteLine(fmt.Sprintf("%s %s", g.colorize(sess, "drop_message", dropLabel), coloredName)) - } - } - } + if mob.Drops.Remains != "" { + writeDrop(mob.Drops.Remains, 1) + } - respawnTicks := engine.ToTicks(mob.RespawnTicks) - if respawnTicks <= 0 { - respawnTicks = engine.ToTicks(30) + for _, entry := range behavior.ResolveDropList(g.DataDir, mob.Drops.Loot) { + if entry.ItemID == "" { + continue } - instanceID := mob.InstanceID - if mob.SpawnedByTrigger { - g.Ticks.Subscribe(10, func() bool { - g.MobStore.RemoveInstance(instanceID) - return false - }) - } else { - g.Ticks.Subscribe(respawnTicks, func() bool { - g.respawnMob(instanceID) - return false - }) + qty := entry.Quantity + if qty <= 0 { + qty = 1 } - g.writePrompt(sess) + writeDrop(entry.ItemID, qty) } } +// scheduleMobRespawn removes trigger-spawned mobs or schedules a normal respawn. +func (g *Game) scheduleMobRespawn(mob *world.MobInstance) { + instanceID := mob.InstanceID + if mob.SpawnedByTrigger { + g.Ticks.Subscribe(10, func() bool { + g.MobStore.RemoveInstance(instanceID) + return false + }) + return + } + respawnTicks := engine.ToTicks(mob.RespawnTicks) + if respawnTicks <= 0 { + respawnTicks = engine.ToTicks(30) + } + g.Ticks.Subscribe(respawnTicks, func() bool { + g.respawnMob(instanceID) + return false + }) +} + // killPlayer handles a player death from any source (combat or a room hazard). // mob may be nil (e.g. a hazard kill); it is only used for Dead Man's Switch // retribution. @@ -256,8 +331,8 @@ func (g *Game) killPlayer(sess *net.Session, p *player.Player, mob *world.MobIns if mob.HP < 0 { mob.HP = 0 } - sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!", - mobDisplayName(mob, true), retDmg)) + sess.WriteLine(fmt.Sprintf("Dead Man's Switch activates! %s takes %d damage!", + mobDisplayName(mob, true), retDmg)) } } } @@ -316,8 +391,8 @@ func (g *Game) awardCombatXP(sess *net.Session, p *player.Player, dmg int, isRan return gains } +// stopCombat ends the player's combat if any. Combat.Leave no-ops when the +// player is not engaged. func (g *Game) stopCombat(playerName string) { - if cs := g.Combat.Get(playerName); cs != nil { - g.Combat.Leave(playerName) - } + g.Combat.Leave(playerName) } diff --git a/internal/game/combat_mob_test.go b/internal/game/combat_mob_test.go new file mode 100644 index 0000000..28427b7 --- /dev/null +++ b/internal/game/combat_mob_test.go @@ -0,0 +1,61 @@ +package game + +import ( + "testing" + + "thehouseoficarus/internal/world" +) + +func TestMobMeleeType(t *testing.T) { + cases := []struct { + name string + types []string + want string + }{ + {"single crush", []string{"crush"}, "crush"}, + {"melee plus ranged", []string{"stab", "ranged"}, "stab"}, + {"ranged first", []string{"ranged", "slash"}, "slash"}, + {"no melee defaults crush", []string{"ranged"}, "crush"}, + {"empty defaults crush", nil, "crush"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mob := &world.MobInstance{AttackTypes: c.types} + if got := mobMeleeType(mob); got != c.want { + t.Errorf("mobMeleeType(%v) = %q, want %q", c.types, got, c.want) + } + }) + } +} + +func TestMobStrongestRangedScience(t *testing.T) { + cases := []struct { + name string + types []string + maxRanged int + maxScience int + sciencePct int + want string + }{ + {"melee only", []string{"crush"}, 0, 0, 0, ""}, + {"ranged only", []string{"crush", "ranged"}, 5, 0, 0, "ranged"}, + {"science only", []string{"crush", "science"}, 0, 7, 0, "science"}, + {"both science higher", []string{"crush", "ranged", "science"}, 5, 8, 0, "science"}, + {"both ranged higher", []string{"crush", "ranged", "science"}, 10, 8, 0, "ranged"}, + {"both tie prefers ranged", []string{"crush", "ranged", "science"}, 8, 8, 0, "ranged"}, + {"science percent tips it", []string{"crush", "ranged", "science"}, 10, 8, 50, "science"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + mob := &world.MobInstance{ + AttackTypes: c.types, + MaxRangedHit: c.maxRanged, + MaxScienceHit: c.maxScience, + SciencePercentBonus: c.sciencePct, + } + if got := mobStrongestRangedScience(mob); got != c.want { + t.Errorf("mobStrongestRangedScience() = %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/game/core_equip.go b/internal/game/core_equip.go index a297466..7a1c1bc 100644 --- a/internal/game/core_equip.go +++ b/internal/game/core_equip.go @@ -33,7 +33,7 @@ func (g *Game) playerEquipBonuses(p *player.Player) item.ItemStats { func (g *Game) playerWeaponSpeed(p *player.Player) float64 { if itemID, ok := p.Equipment[item.SlotMainHand]; ok { def, err := g.ItemStore.Load(itemID) - if err == nil && def.Speed()> 0 { + if err == nil && def.Speed() > 0 { return def.Speed() } } diff --git a/internal/game/core_login_char.go b/internal/game/core_login_char.go index b1d3d26..90c06be 100644 --- a/internal/game/core_login_char.go +++ b/internal/game/core_login_char.go @@ -51,7 +51,13 @@ func (g *Game) handleNewCharName(sess *net.Session, input string) { return } - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error creating character: %v", err)) + sess.State = net.StateMenu + g.showMenu(sess) + return + } acc.Characters = append(acc.Characters, name) g.AccountStore.SaveAccount(acc) sess.Account.Characters = acc.Characters @@ -188,7 +194,12 @@ func (g *Game) handleRenameCharName(sess *net.Session, input string) { g.AccountStore.SaveCharacter(p) } - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error renaming: %v", err)) + g.showMenu(sess) + return + } for i, c := range acc.Characters { if c == oldName { acc.Characters[i] = newName @@ -250,7 +261,13 @@ func (g *Game) handleDeleteChar(sess *net.Session, input string) { return } - acc, _ := g.AccountStore.LoadAccount(sess.Account.Name) + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + sess.WriteLine(fmt.Sprintf("Error deleting: %v", err)) + sess.PendingChar = "" + g.showMenu(sess) + return + } var newChars []string for _, c := range acc.Characters { if c != sess.PendingChar { diff --git a/internal/game/core_utils.go b/internal/game/core_utils.go index 4c087e8..2855bcf 100644 --- a/internal/game/core_utils.go +++ b/internal/game/core_utils.go @@ -44,6 +44,27 @@ func mobDisplayName(m *world.MobInstance, definite bool) string { return "a " + m.Name } +// mobDisplayNameCap is like mobDisplayName but capitalizes the article for use +// at the start of a sentence (e.g. "The goblin hits you."). +func mobDisplayNameCap(m *world.MobInstance, definite bool) string { + if m.Unique { + return m.Name + } + if definite { + return "The " + m.Name + } + return "A " + m.Name +} + +// itemDisplayName returns an item's display name for the given id, falling back +// to the id itself if the item cannot be loaded. +func (g *Game) itemDisplayName(itemID string) string { + if def, err := g.ItemStore.Load(itemID); err == nil && def != nil { + return def.Name + } + return itemID +} + func mobCombatLevel(m *world.MobInstance) int { base := float64(m.Defense+m.MaxHP) / 4.0 melee := float64(m.Attack+m.Strength) / 4.0 diff --git a/internal/game/look_target.go b/internal/game/look_target.go index a93d969..bf130be 100644 --- a/internal/game/look_target.go +++ b/internal/game/look_target.go @@ -351,7 +351,7 @@ func (g *Game) showItemStats(sess *net.Session, def *item.ItemDef) { if def.AttackType() != "" { sess.WriteLine(fmt.Sprintf("Attack type: %s", def.AttackType())) } - if def.Speed()> 0 { + if def.Speed() > 0 { sess.WriteLine(fmt.Sprintf("Speed: %.0f", def.Speed())) } if len(def.Requirements) > 0 { diff --git a/internal/game/map_test.go b/internal/game/map_test.go index 712202b..159214c 100644 --- a/internal/game/map_test.go +++ b/internal/game/map_test.go @@ -83,10 +83,10 @@ func TestMapConnectorGlyphs(t *testing.T) { wantAbsent: []string{"X", "<", ">"}, }, { - name: "outward open, inward blocked -> outward arrow (dist rules)", - room1: "name: One\nexits:\n east: 2\n", - room2: "name: Two\nexits:\n west: 1\n north: 3\n", - room3: condSouth, + name: "outward open, inward blocked -> outward arrow (dist rules)", + room1: "name: One\nexits:\n east: 2\n", + room2: "name: Two\nexits:\n west: 1\n north: 3\n", + room3: condSouth, flagOpen: false, // dist[room1]=0, dist[room2]=1, dist[room3]=2. // Link 2↔3: near=2 (dist1), far=3 (dist2). diff --git a/internal/game/render_map.go b/internal/game/render_map.go index b4b8c93..179277a 100644 --- a/internal/game/render_map.go +++ b/internal/game/render_map.go @@ -10,15 +10,15 @@ import ( ) type mapGlyphs struct { - topLeft, topRight rune - bottomLeft, bottomRight rune - side rune - topFill rune - connectorH, connectorV rune - upArrow, downArrow rune - leftArrow, rightArrow rune - upRight, upLeft rune - downRight, downLeft rune + topLeft, topRight rune + bottomLeft, bottomRight rune + side rune + topFill rune + connectorH, connectorV rune + upArrow, downArrow rune + leftArrow, rightArrow rune + upRight, upLeft rune + downRight, downLeft rune connectorNE, connectorNW rune } @@ -203,7 +203,6 @@ func buildTinyMap(g *Game, sess *net.Session, roomID int, mg mapGlyphs) []string } } - cur, _ := loadRoom(g, roomID) if cur != nil { _, hasUp := exitTarget(cur, world.Up) diff --git a/internal/game/sys_hazard.go b/internal/game/sys_hazard.go index 971da99..6e9c40d 100644 --- a/internal/game/sys_hazard.go +++ b/internal/game/sys_hazard.go @@ -75,19 +75,19 @@ func (g *Game) hasEquipped(p *player.Player, itemID string) bool { func (g *Game) rollHazard(sess *net.Session, p *player.Player, hz *world.HazardDef) { attackType := hz.AttackType if attackType == "" { - attackType = "crush" + attackType = combat.DefaultAttackType } - attRoll := combat.EffectiveRoll(hz.Attack, 0, hz.AttackBonus) + attRoll := combat.AttackRoll(combat.NPCEffective(hz.Attack), hz.AttackBonus) _, _, defStyleBonus := combat.AttackStyleBonus(string(p.AttackStyle)) totals := g.playerEquipBonuses(p) equipDef := combat.SelectBonus(attackType, totals.StabDefense, totals.SlashDefense, totals.CrushDefense, totals.ScienceDefense, totals.RangedDefense) - defRoll := combat.EffectiveRoll( + defRoll := combat.AttackRoll(combat.PlayerEffective( p.Level(player.Defense)+g.techLevelBonus(p, "defense")+g.buffLevelBonus(p, "defense"), - defStyleBonus, equipDef) + defStyleBonus), equipDef) if combat.HitCheck(attRoll, defRoll) { g.applyHazardHit(sess, p, hz) diff --git a/internal/game/sys_safespot.go b/internal/game/sys_safespot.go index cdb954b..41fe19f 100644 --- a/internal/game/sys_safespot.go +++ b/internal/game/sys_safespot.go @@ -50,7 +50,11 @@ func (g *Game) safespotBlocksHazard(p *player.Player) bool { return g.isSafespotted(p.Name) } -func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool { +// safespotBlocksMelee reports whether the player's active safespot shields them +// from this mob's melee attacks (based on cover size vs mob size). It does NOT +// consider the mob's attack types — a mob whose melee is blocked may still be +// able to attack with ranged/science (see effectiveMobAttackType). +func (g *Game) safespotBlocksMelee(p *player.Player, mob *world.MobInstance) bool { ss, ok := g.safespot.Get(p.Name) if !ok || !ss.Active || ss.HideCountdown > 0 { return false @@ -61,15 +65,17 @@ func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool return false } - if !blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) { - return false - } + return blocksMob(objDef.Safespot.MaxBlockSize, mob.Size) +} - if mob.AttackType == "ranged" || mob.AttackType == "science" { +// safespotBlocksMob reports whether the player's active safespot fully prevents +// this mob from attacking at all. This is true only when the safespot blocks the +// mob's melee AND the mob has no ranged/science attack type to switch to. +func (g *Game) safespotBlocksMob(p *player.Player, mob *world.MobInstance) bool { + if !g.safespotBlocksMelee(p, mob) { return false } - - return true + return mobStrongestRangedScience(mob) == "" } func (g *Game) sessionInRoom(name string, roomSessions []*net.Session) *net.Session { diff --git a/internal/game/sys_science.go b/internal/game/sys_science.go index bded8c4..a616b42 100644 --- a/internal/game/sys_science.go +++ b/internal/game/sys_science.go @@ -25,15 +25,15 @@ const ( ) type ModDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Level int `yaml:"level"` - MaxHit int `yaml:"max_hit"` - BaseXP int `yaml:"base_xp"` - JunkCost map[string]int `yaml:"junk_cost"` - Category ModCategory `yaml:"category"` - Element string `yaml:"element"` - Destination int `yaml:"destination"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Level int `yaml:"level"` + MaxHit int `yaml:"max_hit"` + BaseXP int `yaml:"base_xp"` + JunkCost map[string]int `yaml:"junk_cost"` + Category ModCategory `yaml:"category"` + Element string `yaml:"element"` + Destination int `yaml:"destination"` Sequence []behavior.ModTriggerStep `yaml:"sequence"` } @@ -44,8 +44,7 @@ var modByID map[string]*ModDef func (g *Game) LoadMods() error { dir := filepath.Join(g.DataDir, "modules") AllMods = nil - modByID = make(map[string]*ModDef) - behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { var m ModDef if err := yaml.Unmarshal(data, &m); err != nil { return nil @@ -53,7 +52,9 @@ func (g *Game) LoadMods() error { m.ID = id AllMods = append(AllMods, &m) return nil - }) + }); err != nil { + return err + } modByID = make(map[string]*ModDef, len(AllMods)) for _, m := range AllMods { modByID[m.ID] = m diff --git a/internal/game/sys_technology.go b/internal/game/sys_technology.go index 9e6a635..c7214af 100644 --- a/internal/game/sys_technology.go +++ b/internal/game/sys_technology.go @@ -8,9 +8,9 @@ import ( "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" - "thehouseoficarus/internal/world" ) type TechEffects struct { @@ -44,8 +44,7 @@ var techByID map[string]*TechDef func (g *Game) LoadTechs() error { dir := filepath.Join(g.DataDir, "techs") AllTechs = nil - techByID = make(map[string]*TechDef) - behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + if err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { var t TechDef if err := yaml.Unmarshal(data, &t); err != nil { return nil @@ -53,7 +52,9 @@ func (g *Game) LoadTechs() error { t.ID = id AllTechs = append(AllTechs, &t) return nil - }) + }); err != nil { + return err + } techByID = make(map[string]*TechDef, len(AllTechs)) for _, t := range AllTechs { techByID[t.ID] = t @@ -199,8 +200,8 @@ func (g *Game) tryRecharge(sess *net.Session, p *player.Player, input string) bo return false } -func (g *Game) applyTechProtection(p *player.Player, mob *world.MobInstance, dmg int) int { - return g.damageAfterTechProtection(p, mob.AttackType, dmg) +func (g *Game) applyTechProtection(p *player.Player, attackType string, dmg int) int { + return g.damageAfterTechProtection(p, attackType, dmg) } // damageAfterTechProtection reduces incoming damage if the player has the @@ -220,16 +221,16 @@ func (g *Game) damageAfterTechProtection(p *player.Player, attackType string, dm } if attackType == "" { - attackType = "crush" + attackType = combat.DefaultAttackType } var protectTechID string - switch attackType { - case "stab", "slash", "crush": + switch { + case combat.IsMeleeType(attackType): protectTechID = "protect_melee" - case "ranged": + case attackType == combat.AttackRanged: protectTechID = "protect_ranged" - case "science": + case attackType == combat.AttackScience: protectTechID = "protect_science" } diff --git a/internal/game/sys_triggers.go b/internal/game/sys_triggers.go index cd1bbd3..7c77fc9 100644 --- a/internal/game/sys_triggers.go +++ b/internal/game/sys_triggers.go @@ -267,8 +267,8 @@ func (g *Game) spawnWorldTriggerMob(cfg *world.SpawnMobConfig, roomID int) { inst.ResetDespawnCounter(engine.ToTicks(cfg.DespawnTicks)) if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(roomID) { - sess.WriteLine(g.colorize(sess, "broadcast", - mobDisplayName(inst, true)+" appears!")) + sess.WriteLine(g.colorize(sess, "broadcast", + mobDisplayName(inst, true)+" appears!")) } } } @@ -287,8 +287,8 @@ func (g *Game) despawnTriggerMobs(mobID string, owner string) { } if g.Hub != nil { for _, sess := range g.Hub.PlayersInRoom(inst.RoomID) { - sess.WriteLine(g.colorize(sess, "broadcast", - fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true)))) + sess.WriteLine(g.colorize(sess, "broadcast", + fmt.Sprintf("%s vanishes.", mobDisplayName(inst, true)))) } } g.MobStore.RemoveInstance(inst.InstanceID) diff --git a/internal/game/tick.go b/internal/game/tick.go index eba8438..4db5b58 100644 --- a/internal/game/tick.go +++ b/internal/game/tick.go @@ -111,7 +111,7 @@ func (g *Game) WanderTick() { toRoom: toRoom, level: mobCombatLevel(inst), }) - inst.RoomID = toRoom + g.MobStore.SetInstanceRoom(inst.InstanceID, toRoom) } // Object wandering (fishing spots, etc.) @@ -270,8 +270,8 @@ func (g *Game) TechTick() { if p.Battery <= 0 { p.Battery = 0 p.DeactivateAllTechs() - sess.WriteLine(g.colorize(sess, "tech_depleted", - "Your battery is depleted! All tech has been disabled.")) + sess.WriteLine(g.colorize(sess, "tech_depleted", + "Your battery is depleted! All tech has been disabled.")) g.writePrompt(sess) } } diff --git a/internal/item/item.go b/internal/item/item.go index 8edae92..77f462a 100644 --- a/internal/item/item.go +++ b/internal/item/item.go @@ -91,25 +91,25 @@ type ItemDef struct { Tool *ToolDef `yaml:"tool,omitempty"` Requirements map[string]int `yaml:"requirements,omitempty"` - Quality int `yaml:"quality"` - MaxQuality int `yaml:"max_quality"` + Quality int `yaml:"quality"` + MaxQuality int `yaml:"max_quality"` Craft CraftList `yaml:"craft,omitempty"` - SearchTable string `yaml:"search_table"` - SearchTicks float64 `yaml:"search_ticks"` - SearchMessage string `yaml:"search_message"` + SearchTable string `yaml:"search_table"` + SearchTicks float64 `yaml:"search_ticks"` + SearchMessage string `yaml:"search_message"` HealValue int `yaml:"heal_value"` EatMessage string `yaml:"eat_message"` - FarmPatchType string `yaml:"farm_patch_type"` - FarmLevel int `yaml:"farm_level"` - FarmPlantXP int `yaml:"farm_plant_xp"` - FarmHarvestXP int `yaml:"farm_harvest_xp"` - FarmStages int `yaml:"farm_stages"` - FarmProduct string `yaml:"farm_product"` - FarmMinYield int `yaml:"farm_min_yield"` - FarmMaxYield int `yaml:"farm_max_yield"` + FarmPatchType string `yaml:"farm_patch_type"` + FarmLevel int `yaml:"farm_level"` + FarmPlantXP int `yaml:"farm_plant_xp"` + FarmHarvestXP int `yaml:"farm_harvest_xp"` + FarmStages int `yaml:"farm_stages"` + FarmProduct string `yaml:"farm_product"` + FarmMinYield int `yaml:"farm_min_yield"` + FarmMaxYield int `yaml:"farm_max_yield"` PotionEffect string `yaml:"potion_effect"` PotionBonus int `yaml:"potion_bonus"` @@ -276,22 +276,22 @@ type SuccessFormula struct { } type CraftDef struct { - Type string `yaml:"type"` - Subtype string `yaml:"subtype,omitempty"` - Level int `yaml:"level"` - XP int `yaml:"xp"` - TicksPerCycle float64 `yaml:"ticks_per_cycle"` - Station []string `yaml:"station"` - Tool string `yaml:"tool"` - Ingredients []IngredientEntry `yaml:"ingredients"` - OutputQty int `yaml:"output_qty"` - Fail string `yaml:"fail"` - SuccessMessage string `yaml:"success_message"` - FailMessage string `yaml:"fail_message"` - StartMessage string `yaml:"start_message"` - EndMessage string `yaml:"end_message"` - Steps []CraftStep `yaml:"steps"` - Success *SuccessFormula `yaml:"success"` + Type string `yaml:"type"` + Subtype string `yaml:"subtype,omitempty"` + Level int `yaml:"level"` + XP int `yaml:"xp"` + TicksPerCycle float64 `yaml:"ticks_per_cycle"` + Station []string `yaml:"station"` + Tool string `yaml:"tool"` + Ingredients []IngredientEntry `yaml:"ingredients"` + OutputQty int `yaml:"output_qty"` + Fail string `yaml:"fail"` + SuccessMessage string `yaml:"success_message"` + FailMessage string `yaml:"fail_message"` + StartMessage string `yaml:"start_message"` + EndMessage string `yaml:"end_message"` + Steps []CraftStep `yaml:"steps"` + Success *SuccessFormula `yaml:"success"` } func (c *CraftDef) EffectiveSkill() string { diff --git a/internal/item/store.go b/internal/item/store.go index b6e5567..e50e6e9 100644 --- a/internal/item/store.go +++ b/internal/item/store.go @@ -4,12 +4,14 @@ import ( "fmt" "os" "path/filepath" + "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) type ItemStore struct { + mu sync.Mutex dataDir string pathIndex map[string]string cache map[string]*ItemDef @@ -25,6 +27,8 @@ func NewItemStore(dataDir string) *ItemStore { } func (s *ItemStore) Load(id string) (*ItemDef, error) { + s.mu.Lock() + defer s.mu.Unlock() if def, ok := s.cache[id]; ok { return def, nil } @@ -46,6 +50,8 @@ func (s *ItemStore) Load(id string) (*ItemDef, error) { } func (s *ItemStore) LoadAll() ([]*ItemDef, error) { + s.mu.Lock() + defer s.mu.Unlock() dir := filepath.Join(s.dataDir, "items") var defs []*ItemDef err := behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { @@ -65,12 +71,21 @@ func (s *ItemStore) LoadAll() ([]*ItemDef, error) { return defs, err } +// PathIndex returns a copy of the id→path index, safe to iterate concurrently. func (s *ItemStore) PathIndex() map[string]string { - return s.pathIndex + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]string, len(s.pathIndex)) + for id, p := range s.pathIndex { + out[id] = p + } + return out } func (s *ItemStore) IDSet() map[string]bool { - ids := make(map[string]bool) + s.mu.Lock() + defer s.mu.Unlock() + ids := make(map[string]bool, len(s.pathIndex)) for id := range s.pathIndex { ids[id] = true } @@ -78,6 +93,8 @@ func (s *ItemStore) IDSet() map[string]bool { } func (s *ItemStore) Reload(dataDir string) { + s.mu.Lock() + defer s.mu.Unlock() s.cache = make(map[string]*ItemDef) s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "items")) } diff --git a/internal/object/store.go b/internal/object/store.go index 61cb007..997c324 100644 --- a/internal/object/store.go +++ b/internal/object/store.go @@ -4,12 +4,14 @@ import ( "fmt" "os" "path/filepath" + "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) type ObjectStore struct { + mu sync.Mutex dataDir string pathIndex map[string]string cache map[string]*ObjectDef @@ -24,6 +26,8 @@ func NewObjectStore(dataDir string) *ObjectStore { } func (s *ObjectStore) Load(id string) (*ObjectDef, error) { + s.mu.Lock() + defer s.mu.Unlock() if def, ok := s.cache[id]; ok { return def, nil } @@ -44,12 +48,21 @@ func (s *ObjectStore) Load(id string) (*ObjectDef, error) { return &def, nil } +// PathIndex returns a copy of the id→path index, safe to iterate concurrently. func (s *ObjectStore) PathIndex() map[string]string { - return s.pathIndex + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]string, len(s.pathIndex)) + for id, p := range s.pathIndex { + out[id] = p + } + return out } func (s *ObjectStore) IDSet() map[string]bool { - ids := make(map[string]bool) + s.mu.Lock() + defer s.mu.Unlock() + ids := make(map[string]bool, len(s.pathIndex)) for id := range s.pathIndex { ids[id] = true } @@ -57,6 +70,8 @@ func (s *ObjectStore) IDSet() map[string]bool { } func (s *ObjectStore) Reload(dataDir string) { + s.mu.Lock() + defer s.mu.Unlock() s.cache = make(map[string]*ObjectDef) s.pathIndex = behavior.BuildPathIndex(filepath.Join(dataDir, "objects")) } diff --git a/internal/validate/checks.go b/internal/validate/checks.go index a8f973f..ccf996c 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -7,6 +7,7 @@ import ( "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/color" + "thehouseoficarus/internal/combat" "thehouseoficarus/internal/world" ) @@ -292,6 +293,57 @@ func validateRoomObjectCollisions(roomID int, entries []roomObjEntry) []Issue { return issues } +// validateMobAttackTypes enforces the mob attack_types spec: a non-empty list +// containing exactly one melee type (stab/slash/crush) plus at most one each of +// the optional ranged/science types, with no invalid or duplicate entries. +func validateMobAttackTypes(id string, types []string) []Issue { + var issues []Issue + + if len(types) == 0 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: missing attack_types (must be a list with exactly one of stab/slash/crush, optionally plus ranged/science)", id), + }) + return issues + } + + meleeCount := 0 + seen := map[string]bool{} + for _, t := range types { + if !combat.IsValidAttackType(t) { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: invalid attack_types entry %q (must be stab/slash/crush/ranged/science)", id, t), + }) + continue + } + if seen[t] { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: duplicate attack_types entry %q", id, t), + }) + continue + } + seen[t] = true + if combat.IsMeleeType(t) { + meleeCount++ + } + } + + if meleeCount != 1 { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "reference", + Message: fmt.Sprintf("Mob %q: attack_types must contain exactly one melee type (stab/slash/crush), found %d", id, meleeCount), + }) + } + + return issues +} + func validateMobs(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() @@ -336,6 +388,10 @@ func validateMobs(s Source) []Issue { }) } + if def.Combat != nil && def.Combat.Kind != "task" { + issues = append(issues, validateMobAttackTypes(id, def.Combat.AttackTypes)...) + } + if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] { issues = append(issues, Issue{ Level: "ERROR", @@ -427,7 +483,6 @@ func validateMobs(s Source) []Issue { func validateHazards(s Source) []Issue { var issues []Issue itemIDs := s.Items.IDSet() - validTypes := map[string]bool{"stab": true, "slash": true, "crush": true, "ranged": true, "science": true} for id := range s.World.HazardIndex() { def, err := s.World.LoadHazard(id) @@ -439,7 +494,7 @@ func validateHazards(s Source) []Issue { }) continue } - if def.AttackType != "" && !validTypes[def.AttackType] { + if def.AttackType != "" && !combat.IsValidAttackType(def.AttackType) { issues = append(issues, Issue{ Level: "ERROR", Type: "reference", diff --git a/internal/validate/grid_test.go b/internal/validate/grid_test.go index 716cec1..e40c50b 100644 --- a/internal/validate/grid_test.go +++ b/internal/validate/grid_test.go @@ -141,13 +141,13 @@ func TestGrid3DTwistUpDown(t *testing.T) { // path2: 1 east→A(1,0,0) up→B(1,0,1) east→C(2,0,1) south→4 wants (2,1,1) // 4 already at (1,1,1) from path1 → twist. rooms := map[int]string{ - 1: "exits:\n up: 2\n east: 6\n", - 2: "exits:\n south: 3\n down: 1\n", - 3: "exits:\n east: 4\n north: 2\n", - 4: "name: four\n", - 6: "exits:\n up: 7\n west: 1\n", - 7: "exits:\n east: 8\n down: 6\n", - 8: "exits:\n south: 4\n west: 7\n", + 1: "exits:\n up: 2\n east: 6\n", + 2: "exits:\n south: 3\n down: 1\n", + 3: "exits:\n east: 4\n north: 2\n", + 4: "name: four\n", + 6: "exits:\n up: 7\n west: 1\n", + 7: "exits:\n east: 8\n down: 6\n", + 8: "exits:\n south: 4\n west: 7\n", } issues := runGridCheck(t, rooms, 1) if !containsMsg(issues, "Grid twist") { diff --git a/internal/validate/mob_attacktype_test.go b/internal/validate/mob_attacktype_test.go new file mode 100644 index 0000000..1464d3f --- /dev/null +++ b/internal/validate/mob_attacktype_test.go @@ -0,0 +1,31 @@ +package validate + +import "testing" + +func TestValidateMobAttackTypes(t *testing.T) { + cases := []struct { + name string + types []string + wantError bool + }{ + {"single melee", []string{"crush"}, false}, + {"melee plus ranged", []string{"stab", "ranged"}, false}, + {"melee plus both", []string{"slash", "ranged", "science"}, false}, + {"empty", nil, true}, + {"no melee", []string{"ranged"}, true}, + {"two melee", []string{"stab", "crush"}, true}, + {"invalid entry", []string{"crush", "magic"}, true}, + {"duplicate", []string{"crush", "ranged", "ranged"}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + issues := validateMobAttackTypes("test_mob", c.types) + if c.wantError && len(issues) == 0 { + t.Errorf("validateMobAttackTypes(%v): expected error, got none", c.types) + } + if !c.wantError && len(issues) != 0 { + t.Errorf("validateMobAttackTypes(%v): expected no error, got %v", c.types, issues) + } + }) + } +} diff --git a/internal/world/mob.go b/internal/world/mob.go index 19ec935..05650c0 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -40,47 +40,51 @@ type MobCombatBonuses struct { } type MobCombatDefenses struct { - StabDefense int `yaml:"stab_defense"` - SlashDefense int `yaml:"slash_defense"` - CrushDefense int `yaml:"crush_defense"` - ScienceDefense int `yaml:"science_defense"` - RangedDefense int `yaml:"ranged_defense"` - Weakness string `yaml:"weakness"` + StabDefense int `yaml:"stab_defense"` + SlashDefense int `yaml:"slash_defense"` + CrushDefense int `yaml:"crush_defense"` + ScienceDefense int `yaml:"science_defense"` + RangedDefense int `yaml:"ranged_defense"` + Weakness string `yaml:"weakness"` + WeaknessPercent int `yaml:"weakness_percent"` } type MobCombatStats struct { - HP int `yaml:"hp"` - Attack int `yaml:"attack"` - Strength int `yaml:"strength"` - Defense int `yaml:"defense"` - Ranged int `yaml:"ranged"` - Science int `yaml:"science"` - AttackType string `yaml:"attack_type"` - Speed float64 `yaml:"speed"` - Aggressive bool `yaml:"aggressive"` - RespawnTicks float64 `yaml:"respawn_ticks"` - Bonuses MobCombatBonuses `yaml:"bonuses"` - Defenses MobCombatDefenses `yaml:"defenses"` + HP int `yaml:"hp"` + Attack int `yaml:"attack"` + Strength int `yaml:"strength"` + Defense int `yaml:"defense"` + Ranged int `yaml:"ranged"` + Science int `yaml:"science"` + Speed float64 `yaml:"speed"` + MaxMeleeHit int `yaml:"max_melee_hit"` + MaxRangedHit int `yaml:"max_ranged_hit"` + MaxScienceHit int `yaml:"max_science_hit"` + Bonuses MobCombatBonuses `yaml:"bonuses"` + Defenses MobCombatDefenses `yaml:"defenses"` } type MobCombat struct { - Kind string `yaml:"kind"` - Stats MobCombatStats `yaml:"stats"` - AssassinLevel int `yaml:"assassin_level"` - FinishingBlow string `yaml:"finishing_blow"` - DamageWithout string `yaml:"damage_without"` - Size string `yaml:"size"` - CombatDescriptions []string `yaml:"combat_descriptions"` + Kind string `yaml:"kind"` + AttackTypes []string `yaml:"attack_types"` + Aggressive bool `yaml:"aggressive"` + RespawnTicks float64 `yaml:"respawn_ticks"` + Stats MobCombatStats `yaml:"stats"` + AssassinLevel int `yaml:"assassin_level"` + FinishingBlow string `yaml:"finishing_blow"` + DamageWithout string `yaml:"damage_without"` + Size string `yaml:"size"` + CombatDescriptions []string `yaml:"combat_descriptions"` } type MobDef struct { - ID string `yaml:"id"` - Name string `yaml:"name"` - Description string `yaml:"description"` - IdleDescriptions []string `yaml:"idle_descriptions"` - Protected bool `yaml:"protected"` - Unique bool `yaml:"unique"` - Drops MobDropTable `yaml:"drops"` + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + IdleDescriptions []string `yaml:"idle_descriptions"` + Protected bool `yaml:"protected"` + Unique bool `yaml:"unique"` + Drops MobDropTable `yaml:"drops"` Steal *MobSteal `yaml:"steal,omitempty"` Task *MobTask `yaml:"task,omitempty"` @@ -98,35 +102,39 @@ func (d *MobDef) HasShop() bool { return d.Shop != nil } func (d *MobDef) IsTask() bool { return d.Combat != nil && d.Combat.Kind == "task" } type MobInstance struct { - InstanceID string - DefID string - Name string - Description string - HP int - MaxHP int - Attack int - Strength int - Defense int - Ranged int - Science int - Speed float64 - Aggressive bool - Protected bool - Unique bool - RespawnTicks float64 - RoomID int - HomeRoomID int - Drops MobDropTable - IdleDescription string - CombatDescriptions []string - WanderRooms []int - WanderInterval float64 - WanderTickCounter int - regenerateTick int + InstanceID string + DefID string + Name string + Description string + HP int + MaxHP int + Attack int + Strength int + Defense int + Ranged int + Science int + Speed float64 + Aggressive bool + Protected bool + Unique bool + RespawnTicks float64 + RoomID int + HomeRoomID int + Drops MobDropTable + IdleDescription string + CombatDescriptions []string + WanderRooms []int + WanderInterval float64 + WanderTickCounter int + regenerateTick int AttackBonus int StrengthBonus int - AttackType string + AttackTypes []string + + RangedBonus int + ScienceBonus int + SciencePercentBonus int StabDefense int SlashDefense int @@ -134,11 +142,16 @@ type MobInstance struct { ScienceDefense int RangedDefense int - Weakness string - StealTable string - StealLevel int - StealXP int - StealSpeed float64 + Weakness string + WeaknessPercent int + + MaxMeleeHit int + MaxRangedHit int + MaxScienceHit int + StealTable string + StealLevel int + StealXP int + StealSpeed float64 AssassinLevel int FinishingBlow string @@ -186,13 +199,20 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in respawnTicks := 0.0 attackBonus := 0 strengthBonus := 0 - attackType := "" + rangedBonus := 0 + scienceBonus := 0 + sciencePercentBonus := 0 + var attackType []string stabDef := 0 slashDef := 0 crushDef := 0 scienceDef := 0 rangedDef := 0 weakness := "" + weaknessPercent := 0 + maxMeleeHit := 0 + maxRangedHit := 0 + maxScienceHit := 0 assassinLevel := 0 finishingBlow := "" damageWithout := "" @@ -208,6 +228,10 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in stealSpeed := 0.0 if def.Combat != nil { + aggressive = def.Combat.Aggressive + respawnTicks = def.Combat.RespawnTicks + attackType = append([]string(nil), def.Combat.AttackTypes...) + cs := def.Combat.Stats hp = cs.HP attack = cs.Attack @@ -216,17 +240,21 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in ranged = cs.Ranged science = cs.Science speed = cs.Speed - aggressive = cs.Aggressive - respawnTicks = cs.RespawnTicks attackBonus = cs.Bonuses.AttackBonus strengthBonus = cs.Bonuses.StrengthBonus - attackType = cs.AttackType + rangedBonus = cs.Bonuses.RangedBonus + scienceBonus = cs.Bonuses.ScienceBonus + sciencePercentBonus = cs.Bonuses.SciencePercentBonus stabDef = cs.Defenses.StabDefense slashDef = cs.Defenses.SlashDefense crushDef = cs.Defenses.CrushDefense scienceDef = cs.Defenses.ScienceDefense rangedDef = cs.Defenses.RangedDefense weakness = cs.Defenses.Weakness + weaknessPercent = cs.Defenses.WeaknessPercent + maxMeleeHit = cs.MaxMeleeHit + maxRangedHit = cs.MaxRangedHit + maxScienceHit = cs.MaxScienceHit assassinLevel = def.Combat.AssassinLevel finishingBlow = def.Combat.FinishingBlow @@ -253,50 +281,57 @@ func NewMobInstance(def *MobDef, instanceID string, roomID int, wanderRooms []in hp = 1 } inst := &MobInstance{ - InstanceID: instanceID, - DefID: def.ID, - Name: def.Name, - Description: def.Description, - HP: hp, - MaxHP: hp, - Attack: attack, - Strength: strength, - Defense: defense, - Ranged: ranged, - Science: science, - Speed: speed, - Aggressive: aggressive, - Protected: def.Protected, - Unique: def.Unique, - RespawnTicks: respawnTicks, - RoomID: roomID, - HomeRoomID: roomID, - WanderRooms: wanderRooms, - WanderInterval: wanderInterval, - Drops: def.Drops, - AttackBonus: attackBonus, - StrengthBonus: strengthBonus, - AttackType: attackType, - StabDefense: stabDef, - SlashDefense: slashDef, - CrushDefense: crushDef, - ScienceDefense: scienceDef, - RangedDefense: rangedDef, - Weakness: weakness, - StealTable: stealTable, - StealLevel: stealLevel, - StealXP: stealXP, - StealSpeed: stealSpeed, - AssassinLevel: assassinLevel, - FinishingBlow: finishingBlow, - DamageWithout: damageWithout, - Size: size, - Kind: kind, - Verb: verb, - ProgressNoun: progressNoun, - CompleteMessage: completeMessage, - CombatDescriptions: combatDescriptions, - TalkConfig: def.Talk, + InstanceID: instanceID, + DefID: def.ID, + Name: def.Name, + Description: def.Description, + HP: hp, + MaxHP: hp, + Attack: attack, + Strength: strength, + Defense: defense, + Ranged: ranged, + Science: science, + Speed: speed, + Aggressive: aggressive, + Protected: def.Protected, + Unique: def.Unique, + RespawnTicks: respawnTicks, + RoomID: roomID, + HomeRoomID: roomID, + WanderRooms: wanderRooms, + WanderInterval: wanderInterval, + Drops: def.Drops, + AttackBonus: attackBonus, + StrengthBonus: strengthBonus, + AttackTypes: attackType, + RangedBonus: rangedBonus, + ScienceBonus: scienceBonus, + SciencePercentBonus: sciencePercentBonus, + StabDefense: stabDef, + SlashDefense: slashDef, + CrushDefense: crushDef, + ScienceDefense: scienceDef, + RangedDefense: rangedDef, + Weakness: weakness, + WeaknessPercent: weaknessPercent, + MaxMeleeHit: maxMeleeHit, + MaxRangedHit: maxRangedHit, + MaxScienceHit: maxScienceHit, + StealTable: stealTable, + StealLevel: stealLevel, + StealXP: stealXP, + StealSpeed: stealSpeed, + AssassinLevel: assassinLevel, + FinishingBlow: finishingBlow, + DamageWithout: damageWithout, + Size: size, + Kind: kind, + Verb: verb, + ProgressNoun: progressNoun, + CompleteMessage: completeMessage, + CombatDescriptions: combatDescriptions, + TalkConfig: def.Talk, } if def.Shop != nil { inst.Shop = def.Shop @@ -434,6 +469,17 @@ func (s *MobStore) AllInstances() []*MobInstance { return out } +// SetInstanceRoom updates a mob instance's room under the store lock, so it is +// safe against concurrent readers (MobsInRoom, RemoveMobsInRoom) in other +// goroutines. +func (s *MobStore) SetInstanceRoom(instanceID string, roomID int) { + s.mu.Lock() + defer s.mu.Unlock() + if inst := s.instances[instanceID]; inst != nil { + inst.RoomID = roomID + } +} + func (s *MobStore) MobsInRoom(roomID int) []*MobInstance { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/world/room.go b/internal/world/room.go index 7cc1f08..9c2e52f 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -202,24 +202,24 @@ func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error { func (ro RoomObject) MarshalYAML() (interface{}, error) { if ro.Local != nil { type inlineObj struct { - Name string `yaml:"name"` - Aliases []string `yaml:"aliases,omitempty"` - Color string `yaml:"color,omitempty"` - Hidden bool `yaml:"hidden,omitempty"` - InRoomDescription string `yaml:"inroom_description,omitempty"` - RemovalItem string `yaml:"removal_item,omitempty"` - Description behavior.DescList `yaml:"description,omitempty"` - UseInteractions []object.UseInteraction `yaml:"use_interactions,omitempty"` - StealTable string `yaml:"steal_table,omitempty"` - StealLevel int `yaml:"steal_level,omitempty"` - StealXP int `yaml:"steal_xp,omitempty"` - StealSpeed float64 `yaml:"steal_speed,omitempty"` - GuardMob string `yaml:"guard_mob,omitempty"` - Gather *behavior.GatherConfig `yaml:"gather,omitempty"` - Talk *behavior.TalkConfig `yaml:"talk,omitempty"` - Use *behavior.UseConfig `yaml:"use,omitempty"` - Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` - OnLook *behavior.NodeAction `yaml:"on_look,omitempty"` + Name string `yaml:"name"` + Aliases []string `yaml:"aliases,omitempty"` + Color string `yaml:"color,omitempty"` + Hidden bool `yaml:"hidden,omitempty"` + InRoomDescription string `yaml:"inroom_description,omitempty"` + RemovalItem string `yaml:"removal_item,omitempty"` + Description behavior.DescList `yaml:"description,omitempty"` + UseInteractions []object.UseInteraction `yaml:"use_interactions,omitempty"` + StealTable string `yaml:"steal_table,omitempty"` + StealLevel int `yaml:"steal_level,omitempty"` + StealXP int `yaml:"steal_xp,omitempty"` + StealSpeed float64 `yaml:"steal_speed,omitempty"` + GuardMob string `yaml:"guard_mob,omitempty"` + Gather *behavior.GatherConfig `yaml:"gather,omitempty"` + Talk *behavior.TalkConfig `yaml:"talk,omitempty"` + Use *behavior.UseConfig `yaml:"use,omitempty"` + Safespot *object.SafespotConfig `yaml:"safespot,omitempty"` + OnLook *behavior.NodeAction `yaml:"on_look,omitempty"` } def := ro.Local return inlineObj{ diff --git a/internal/world/trigger.go b/internal/world/trigger.go index 7c06be4..8e74ff7 100644 --- a/internal/world/trigger.go +++ b/internal/world/trigger.go @@ -33,10 +33,10 @@ type TriggerStep struct { } type SpawnMobConfig struct { - ID string `yaml:"id"` - OwnerOnly bool `yaml:"owner_only"` - DespawnOnLeave bool `yaml:"despawn_on_leave"` - DespawnRooms []int `yaml:"despawn_rooms"` + ID string `yaml:"id"` + OwnerOnly bool `yaml:"owner_only"` + DespawnOnLeave bool `yaml:"despawn_on_leave"` + DespawnRooms []int `yaml:"despawn_rooms"` DespawnTicks float64 `yaml:"despawn_ticks"` } diff --git a/internal/world/world.go b/internal/world/world.go index 3dfa99b..b582faf 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -37,7 +37,9 @@ func New(dataDir string) *World { } func (w *World) LoadRoom(id int) (*Room, error) { + w.mu.Lock() path, ok := w.roomPathIndex[id] + w.mu.Unlock() if !ok { return nil, fmt.Errorf("read room %d: no such room", id) } @@ -119,7 +121,9 @@ func (w *World) SeedGroundItems(roomID int) { } func (w *World) RoomIndex() map[int]bool { - ids := make(map[int]bool) + w.mu.Lock() + defer w.mu.Unlock() + ids := make(map[int]bool, len(w.roomPathIndex)) for id := range w.roomPathIndex { ids[id] = true } @@ -139,6 +143,8 @@ func (w *World) AddRoomPath(id int, path string) { } func (w *World) GetRoomPath(id int) (string, bool) { + w.mu.Lock() + defer w.mu.Unlock() path, ok := w.roomPathIndex[id] return path, ok } -- cgit v1.2.3