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 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 _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 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: apply_body_part_damage(defender, hit_slot, damage) 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, )