322 lines
13 KiB
Python
322 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import random
|
|
from dataclasses import dataclass
|
|
from typing import Protocol, Sequence, TypeVar
|
|
|
|
from engine.aim import apply_cone_deviation
|
|
from engine.character import Ailment, BodyPart, Character
|
|
from engine.defs import DefRegistry
|
|
from engine.item import ItemDef
|
|
from engine.map import rotate_vector
|
|
from engine.world import World
|
|
|
|
SKILL_CHECK_DIE = 20
|
|
BASE_DIFFICULTY = 10
|
|
UNARMED_SKILL = "athletics"
|
|
BLOCK_SKILL = "athletics"
|
|
|
|
# Ordered easiest/biggest -> hardest/smallest target. A precise (high-rolling) attacker reaches
|
|
# further down this list; a scattershot hit lands on whatever's first (torso - biggest target).
|
|
BODY_PART_DIFFICULTY_ORDER = ("torso", "left_arm", "right_arm", "left_leg", "right_leg", "head")
|
|
PRECISION_PER_SLOT = 4 # attacker_check points needed to reach one slot further down the list
|
|
|
|
# How precise the attacker's raw d20 roll needs to be (independent of skill, which already
|
|
# drives whether/how hard the hit lands) for a hit to do more than just chip integrity: a
|
|
# solid roll bruises, a near-perfect one breaks something - a fracture anywhere, or
|
|
# brain_trauma instead if it lands on the head. See resources/defs/ailments.json for how each
|
|
# of these then feeds into relevant ability checks (Character.ailment_penalty).
|
|
IMPACT_TRAUMA_ROLL_THRESHOLD = 15
|
|
FRACTURE_ROLL_THRESHOLD = 19
|
|
|
|
COMBAT_AILMENT_SEVERITY = {
|
|
"impact_trauma": 0.4,
|
|
"fracture": 0.8,
|
|
"brain_trauma": 0.9,
|
|
}
|
|
COMBAT_AILMENT_NAMES = {
|
|
"impact_trauma": "Impact Trauma",
|
|
"fracture": "Fracture",
|
|
"brain_trauma": "Brain Trauma",
|
|
}
|
|
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
class DiceRoller(Protocol):
|
|
"""What combat needs from a random source - satisfied by random.Random and fakes alike."""
|
|
|
|
def randint(self, a: int, b: int) -> int: ...
|
|
def choice(self, seq: Sequence[_T]) -> _T: ...
|
|
def uniform(self, a: float, b: float) -> float: ...
|
|
|
|
|
|
@dataclass
|
|
class AttackResult:
|
|
hit: bool
|
|
roll: int
|
|
effective_skill: float
|
|
weapon_bonus: float
|
|
damage: float
|
|
skill_id: str
|
|
target_slot: str | None
|
|
blocked_check: bool # True if this was resolved as an opposed check against a blocking defender
|
|
armor_reduction: float
|
|
|
|
|
|
@dataclass
|
|
class ShotResult:
|
|
"""Outcome of a ranged shot: either it lands as an AttackResult, is blocked, or misses entirely."""
|
|
|
|
hit: bool
|
|
blocked: bool
|
|
ricocheted: bool
|
|
attack: AttackResult | None
|
|
impact: tuple[int, int, int] | None
|
|
target_entity_id: str | None
|
|
|
|
|
|
def apply_body_part_damage(character: Character, slot: str, amount: float) -> BodyPart | None:
|
|
"""Damages a body part in place, creating an override from the default if none exists yet.
|
|
|
|
Integrity hitting zero marks the part removed (severed) - the same 'removed' flag the
|
|
bleeding tick and skill_penalty already understand, so combat plugs directly into both.
|
|
"""
|
|
override = character.get_or_create_body_part_override(slot)
|
|
if override is None:
|
|
return None
|
|
override.integrity = max(0.0, override.integrity - amount)
|
|
if override.integrity <= 0.0:
|
|
override.removed = True
|
|
return override
|
|
|
|
|
|
def combat_ailment_for_roll(roll: int, slot: str) -> str | None:
|
|
"""Which ailment (if any) a hit this precise inflicts, on top of the flat integrity damage
|
|
every hit already does: a normal hit (low roll) just chips integrity, a solidly-rolled hit
|
|
also bruises (impact_trauma), and a near-perfect roll breaks something - a fracture on any
|
|
slot, or brain_trauma instead if it lands on the head. Driven by the raw d20 roll (not the
|
|
full attacker_check, which skill/luck can inflate arbitrarily) so the tiers stay meaningful
|
|
regardless of how skilled either fighter is.
|
|
"""
|
|
if roll >= FRACTURE_ROLL_THRESHOLD:
|
|
return "brain_trauma" if slot == "head" else "fracture"
|
|
if roll >= IMPACT_TRAUMA_ROLL_THRESHOLD:
|
|
return "impact_trauma"
|
|
return None
|
|
|
|
|
|
def apply_combat_ailment(body_part: BodyPart, ailment_id: str) -> Ailment:
|
|
"""Adds a combat-inflicted ailment to a body part override, or refreshes it (to whichever
|
|
severity is worse) if the same ailment is already present - getting hit hard enough for a
|
|
second fracture doesn't stack two separate fractures on top of each other.
|
|
"""
|
|
severity = COMBAT_AILMENT_SEVERITY[ailment_id]
|
|
existing = next((a for a in body_part.ailments if a.id == ailment_id), None)
|
|
if existing is not None:
|
|
existing.severity = max(existing.severity, severity)
|
|
return existing
|
|
ailment = Ailment(id=ailment_id, name=COMBAT_AILMENT_NAMES[ailment_id], severity=severity)
|
|
body_part.ailments.append(ailment)
|
|
return ailment
|
|
|
|
|
|
def roll_skill_check(
|
|
character: Character, skill_id: str, registry: DefRegistry, rng: DiceRoller
|
|
) -> tuple[int, float]:
|
|
"""1d20 + the character's skill level, reduced by damage to relevant body parts and organs."""
|
|
base_skill = character.bio.get(skill_id, 0)
|
|
effective_skill = base_skill * (1.0 - character.total_check_penalty(skill_id, registry))
|
|
roll = rng.randint(1, SKILL_CHECK_DIE)
|
|
return roll, effective_skill
|
|
|
|
|
|
def choose_hit_slot(defender: Character, precision_check: float) -> str | None:
|
|
"""Which body part gets hit is itself driven by a skill check: `precision_check` (the
|
|
attacker's roll + effective skill from the main hit) selects how far down the
|
|
easiest-to-hardest target list the attacker was precise enough to reach.
|
|
"""
|
|
candidates = [slot for slot in BODY_PART_DIFFICULTY_ORDER if defender.get_body_part(slot) is not None]
|
|
extra_slots = [p.slot for p in defender.default_body_parts if p.slot not in BODY_PART_DIFFICULTY_ORDER]
|
|
candidates += [slot for slot in extra_slots if defender.get_body_part(slot) is not None]
|
|
if not candidates:
|
|
return None
|
|
index = min(len(candidates) - 1, max(0, int(precision_check // PRECISION_PER_SLOT)))
|
|
return candidates[index]
|
|
|
|
|
|
def defender_armor(defender: Character, registry: DefRegistry) -> float:
|
|
"""Flat damage reduction summed across all currently-worn clothing."""
|
|
total = 0.0
|
|
for piece in defender.resolved_clothing().values():
|
|
if piece is None:
|
|
continue
|
|
total += registry.get_item_def(piece.item_def_id).armor_reduction
|
|
return total
|
|
|
|
|
|
def resolve_hit(
|
|
attacker: Character,
|
|
defender: Character,
|
|
registry: DefRegistry,
|
|
weapon_def: ItemDef | None,
|
|
target_slot: str | None,
|
|
rng: DiceRoller,
|
|
) -> AttackResult:
|
|
skill_id = (weapon_def.weapon_skill if weapon_def else None) or UNARMED_SKILL
|
|
roll, effective_skill = roll_skill_check(attacker, skill_id, registry, rng)
|
|
weapon_bonus = weapon_def.weapon_damage if weapon_def else 0.0
|
|
attacker_check = roll + effective_skill
|
|
|
|
blocked_check = defender.is_blocking
|
|
if blocked_check:
|
|
_block_roll, block_effective = roll_skill_check(defender, BLOCK_SKILL, registry, rng)
|
|
shield = defender.wielded_shield(registry)
|
|
block_bonus = shield.shield_block_bonus if shield else 0.0
|
|
difficulty = _block_roll + block_effective + block_bonus
|
|
else:
|
|
difficulty = BASE_DIFFICULTY
|
|
|
|
margin = attacker_check - difficulty
|
|
hit = margin > 0
|
|
armor = defender_armor(registry=registry, defender=defender)
|
|
damage = max(0.0, margin + weapon_bonus - armor) if hit else 0.0
|
|
|
|
hit_slot = None
|
|
if hit:
|
|
hit_slot = target_slot if target_slot is not None else choose_hit_slot(defender, attacker_check)
|
|
if hit_slot is not None:
|
|
hit_part = apply_body_part_damage(defender, hit_slot, damage)
|
|
ailment_id = combat_ailment_for_roll(roll, hit_slot)
|
|
if ailment_id is not None and hit_part is not None:
|
|
apply_combat_ailment(hit_part, ailment_id)
|
|
|
|
return AttackResult(
|
|
hit=hit,
|
|
roll=roll,
|
|
effective_skill=effective_skill,
|
|
weapon_bonus=weapon_bonus,
|
|
damage=damage,
|
|
skill_id=skill_id,
|
|
target_slot=hit_slot,
|
|
blocked_check=blocked_check,
|
|
armor_reduction=armor,
|
|
)
|
|
|
|
|
|
def perform_attack(
|
|
attacker: Character,
|
|
defender: Character,
|
|
registry: DefRegistry,
|
|
weapon_def: ItemDef | None = None,
|
|
target_slot: str | None = None,
|
|
rng: DiceRoller | None = None,
|
|
) -> AttackResult:
|
|
"""A live melee attack: skill check (weapon's governing skill, or unarmed) + weapon bonus,
|
|
minus the defender's armor, vs either a flat difficulty or - if the defender is blocking -
|
|
an opposed check (boosted by a wielded shield). On a hit, damage lands on a body part
|
|
(random if target_slot is unset).
|
|
"""
|
|
return resolve_hit(attacker, defender, registry, weapon_def, target_slot, rng or random.Random())
|
|
|
|
|
|
def find_ranged_target(
|
|
world: World, map_id: str, x: int, y: int, z: int, dx: int, dy: int, dz: int, max_range: int
|
|
) -> tuple[Character | None, tuple[int, int, int], int]:
|
|
"""Walks a straight line up to max_range tiles; stops at the first entity, wall, or map edge.
|
|
|
|
Returns (target_or_None, impact_position, steps_traveled).
|
|
"""
|
|
map_ = world.maps[map_id]
|
|
cx, cy, cz = x, y, z
|
|
steps = 0
|
|
for step in range(1, max_range + 1):
|
|
nx, ny, nz = cx + dx, cy + dy, cz + dz
|
|
if not map_.in_bounds(nx, ny, nz):
|
|
return None, (cx, cy, cz), steps
|
|
cx, cy, cz = nx, ny, nz
|
|
steps = step
|
|
target = world.entity_at(map_id, cx, cy, cz)
|
|
if isinstance(target, Character):
|
|
return target, (cx, cy, cz), steps
|
|
if not map_.get_tile(cx, cy, cz).is_walkable(world.registry):
|
|
return None, (cx, cy, cz), steps
|
|
return None, (cx, cy, cz), steps
|
|
|
|
|
|
def _is_blocked_by_shield(target: Character, projectile_type: str | None, registry: DefRegistry) -> bool:
|
|
if not target.is_blocking:
|
|
return False
|
|
shield = target.wielded_shield(registry)
|
|
return shield is not None and projectile_type is not None and projectile_type in shield.blockable_projectile_types
|
|
|
|
|
|
def perform_shoot(
|
|
shooter: Character,
|
|
world: World,
|
|
registry: DefRegistry,
|
|
weapon_def: ItemDef,
|
|
direction: tuple[int, int, int],
|
|
rng: DiceRoller | None = None,
|
|
cone_degrees: float = 0.0,
|
|
projectile_type: str | None = None,
|
|
) -> ShotResult:
|
|
"""A live ranged shot along `direction` (unit vector) up to the weapon's range.
|
|
|
|
If `cone_degrees` > 0, the actual direction is randomized within that cone first (see
|
|
engine/aim.py) - callers determine the cone from the weapon's base spread minus skill and
|
|
attachment reductions (see resources/logic/ranged_combat.py's effective_aim_cone). If it
|
|
reaches a blocking defender whose wielded shield covers `projectile_type` (defaults to the
|
|
weapon's own projectile_type, e.g. for simple non-ammo weapons; ammo-based weapons should
|
|
pass the loaded ammo's ammo_type here instead), the shot is blocked and ricochets off at a
|
|
+/-90 degree deflection for whatever range is left; anything else just breaks through and
|
|
resolves as a normal hit. A ricocheted shot that gets blocked again simply stops (one bounce).
|
|
"""
|
|
assert shooter.position is not None
|
|
rng = rng or random.Random()
|
|
effective_projectile_type = projectile_type if projectile_type is not None else weapon_def.projectile_type
|
|
dx, dy, dz = direction
|
|
if cone_degrees > 0:
|
|
dx, dy = apply_cone_deviation(dx, dy, cone_degrees, rng)
|
|
|
|
target, impact, steps = find_ranged_target(
|
|
world, shooter.position.map_id, shooter.position.x, shooter.position.y, shooter.position.z,
|
|
dx, dy, dz, weapon_def.weapon_range,
|
|
)
|
|
if target is None:
|
|
return ShotResult(hit=False, blocked=False, ricocheted=False, attack=None, impact=impact, target_entity_id=None)
|
|
|
|
if not _is_blocked_by_shield(target, effective_projectile_type, registry):
|
|
attack = resolve_hit(shooter, target, registry, weapon_def, None, rng)
|
|
return ShotResult(
|
|
hit=attack.hit, blocked=False, ricocheted=False, attack=attack, impact=impact, target_entity_id=target.entity_id
|
|
)
|
|
|
|
remaining_range = weapon_def.weapon_range - steps
|
|
if remaining_range <= 0:
|
|
return ShotResult(
|
|
hit=False, blocked=True, ricocheted=True, attack=None, impact=impact, target_entity_id=target.entity_id
|
|
)
|
|
|
|
bounce_dx, bounce_dy = rotate_vector(dx, dy, rng.choice([1, 3]))
|
|
bounced_target, bounced_impact, _steps = find_ranged_target(
|
|
world, target.position.map_id, impact[0], impact[1], impact[2], bounce_dx, bounce_dy, 0, remaining_range
|
|
)
|
|
if bounced_target is None:
|
|
return ShotResult(hit=False, blocked=True, ricocheted=True, attack=None, impact=bounced_impact, target_entity_id=None)
|
|
|
|
if _is_blocked_by_shield(bounced_target, effective_projectile_type, registry):
|
|
return ShotResult(
|
|
hit=False, blocked=True, ricocheted=True, attack=None, impact=bounced_impact, target_entity_id=bounced_target.entity_id
|
|
)
|
|
|
|
attack = resolve_hit(shooter, bounced_target, registry, weapon_def, None, rng)
|
|
return ShotResult(
|
|
hit=attack.hit,
|
|
blocked=True,
|
|
ricocheted=True,
|
|
attack=attack,
|
|
impact=bounced_impact,
|
|
target_entity_id=bounced_target.entity_id,
|
|
)
|