From 67c9435d9e2a8f3105d0e4de8d741753aff953c9 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 15 Jul 2026 13:35:59 +0200 Subject: [PATCH] Initial commit: top-down RPG engine (wgpu-py + glfw) 3D tile maps with embedded/rotating sub-maps, continuous movement, a body-part/organ health system with bespoke per-species anatomy, grid inventories, clothing with species/arm-count fit rules, melee/ranged combat with blocking/knockback/ammo, weather, staircases, a sample time-warp ability with cooldowns, multiplayer scaffolding, keyboard/ mouse/gamepad input, and persistent characters via SQLite. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015mFzQQZYexNaSB69e2oU95 --- .gitignore | 5 + config/controller_bindings.conf | 22 ++ config/keybindings.conf | 31 ++ engine/__init__.py | 0 engine/aim.py | 42 +++ engine/camera.py | 42 +++ engine/character.py | 446 +++++++++++++++++++++++ engine/character_creator.py | 113 ++++++ engine/character_library.py | 435 ++++++++++++++++++++++ engine/config.py | 66 ++++ engine/db.py | 428 ++++++++++++++++++++++ engine/defs.py | 223 ++++++++++++ engine/entity.py | 43 +++ engine/gamepad.py | 122 +++++++ engine/input.py | 174 +++++++++ engine/inventory.py | 59 +++ engine/item.py | 54 +++ engine/map.py | 166 +++++++++ engine/map_loader.py | 47 +++ engine/network/__init__.py | 0 engine/network/client.py | 43 +++ engine/network/protocol.py | 13 + engine/network/server.py | 76 ++++ engine/placeholder_textures.py | 43 +++ engine/render/__init__.py | 0 engine/render/device.py | 16 + engine/render/pipeline.py | 150 ++++++++ engine/render/renderer.py | 125 +++++++ engine/render/shaders/textured_quad.wgsl | 42 +++ engine/render/texture.py | 37 ++ engine/render/window.py | 14 + engine/skills.py | 27 ++ engine/species.py | 31 ++ engine/start_screen.py | 67 ++++ engine/tile.py | 60 +++ engine/timing.py | 63 ++++ engine/ui.py | 111 ++++++ engine/world.py | 192 ++++++++++ main.py | 190 ++++++++++ pyproject.toml | 3 + pyrightconfig.json | 5 + requirements-dev.txt | 2 + requirements.txt | 4 + resources/__init__.py | 0 resources/defs/body_parts.json | 291 +++++++++++++++ resources/defs/entities.json | 38 ++ resources/defs/items.json | 284 +++++++++++++++ resources/defs/maps/demo_world.json | 11 + resources/defs/maps/harbor_world.json | 11 + resources/defs/maps/ship_small.json | 20 + resources/defs/maps/ship_test.json | 294 +++++++++++++++ resources/defs/maps/storm_field.json | 19 + resources/defs/organs.json | 136 +++++++ resources/defs/species.json | 137 +++++++ resources/defs/tiles.json | 20 + resources/logic/__init__.py | 0 resources/logic/abilities.py | 44 +++ resources/logic/actions.py | 145 ++++++++ resources/logic/combat.py | 269 ++++++++++++++ resources/logic/companion.py | 30 ++ resources/logic/construction.py | 118 ++++++ resources/logic/health_ticks.py | 18 + resources/logic/knockback.py | 108 ++++++ resources/logic/organs.py | 52 +++ resources/logic/ranged_combat.py | 234 ++++++++++++ resources/logic/steering.py | 61 ++++ resources/logic/weather.py | 122 +++++++ server.py | 26 ++ tests/test_abilities.py | 79 ++++ tests/test_ability_bar.py | 113 ++++++ tests/test_ability_cooldowns.py | 137 +++++++ tests/test_actions.py | 141 +++++++ tests/test_aim.py | 95 +++++ tests/test_armor.py | 65 ++++ tests/test_blocking.py | 79 ++++ tests/test_character_creator.py | 195 ++++++++++ tests/test_character_fallback.py | 45 +++ tests/test_character_health.py | 121 ++++++ tests/test_character_library.py | 179 +++++++++ tests/test_character_menu.py | 94 +++++ tests/test_clothing_fit.py | 151 ++++++++ tests/test_combat.py | 130 +++++++ tests/test_companion.py | 118 ++++++ tests/test_config.py | 48 +++ tests/test_construction.py | 119 ++++++ tests/test_continuous_movement.py | 117 ++++++ tests/test_db_roundtrip.py | 142 ++++++++ tests/test_defs_loading.py | 115 ++++++ tests/test_gamepad.py | 99 +++++ tests/test_health_ticks.py | 60 +++ tests/test_implants.py | 105 ++++++ tests/test_input.py | 198 ++++++++++ tests/test_interaction.py | 112 ++++++ tests/test_inventory.py | 49 +++ tests/test_knockback.py | 232 ++++++++++++ tests/test_leap.py | 66 ++++ tests/test_map_embedding.py | 141 +++++++ tests/test_network.py | 86 +++++ tests/test_organs.py | 202 ++++++++++ tests/test_ranged_combat.py | 268 ++++++++++++++ tests/test_ship_test_map.py | 181 +++++++++ tests/test_shooting.py | 158 ++++++++ tests/test_species_samples.py | 210 +++++++++++ tests/test_speed.py | 60 +++ tests/test_staircases.py | 95 +++++ tests/test_start_screen.py | 123 +++++++ tests/test_steering.py | 109 ++++++ tests/test_time_warp.py | 191 ++++++++++ tests/test_timing.py | 51 +++ tests/test_weather.py | 184 ++++++++++ tests/test_wielding.py | 88 +++++ 111 files changed, 11671 insertions(+) create mode 100644 .gitignore create mode 100644 config/controller_bindings.conf create mode 100644 config/keybindings.conf create mode 100644 engine/__init__.py create mode 100644 engine/aim.py create mode 100644 engine/camera.py create mode 100644 engine/character.py create mode 100644 engine/character_creator.py create mode 100644 engine/character_library.py create mode 100644 engine/config.py create mode 100644 engine/db.py create mode 100644 engine/defs.py create mode 100644 engine/entity.py create mode 100644 engine/gamepad.py create mode 100644 engine/input.py create mode 100644 engine/inventory.py create mode 100644 engine/item.py create mode 100644 engine/map.py create mode 100644 engine/map_loader.py create mode 100644 engine/network/__init__.py create mode 100644 engine/network/client.py create mode 100644 engine/network/protocol.py create mode 100644 engine/network/server.py create mode 100644 engine/placeholder_textures.py create mode 100644 engine/render/__init__.py create mode 100644 engine/render/device.py create mode 100644 engine/render/pipeline.py create mode 100644 engine/render/renderer.py create mode 100644 engine/render/shaders/textured_quad.wgsl create mode 100644 engine/render/texture.py create mode 100644 engine/render/window.py create mode 100644 engine/skills.py create mode 100644 engine/species.py create mode 100644 engine/start_screen.py create mode 100644 engine/tile.py create mode 100644 engine/timing.py create mode 100644 engine/ui.py create mode 100644 engine/world.py create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 pyrightconfig.json create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 resources/__init__.py create mode 100644 resources/defs/body_parts.json create mode 100644 resources/defs/entities.json create mode 100644 resources/defs/items.json create mode 100644 resources/defs/maps/demo_world.json create mode 100644 resources/defs/maps/harbor_world.json create mode 100644 resources/defs/maps/ship_small.json create mode 100644 resources/defs/maps/ship_test.json create mode 100644 resources/defs/maps/storm_field.json create mode 100644 resources/defs/organs.json create mode 100644 resources/defs/species.json create mode 100644 resources/defs/tiles.json create mode 100644 resources/logic/__init__.py create mode 100644 resources/logic/abilities.py create mode 100644 resources/logic/actions.py create mode 100644 resources/logic/combat.py create mode 100644 resources/logic/companion.py create mode 100644 resources/logic/construction.py create mode 100644 resources/logic/health_ticks.py create mode 100644 resources/logic/knockback.py create mode 100644 resources/logic/organs.py create mode 100644 resources/logic/ranged_combat.py create mode 100644 resources/logic/steering.py create mode 100644 resources/logic/weather.py create mode 100644 server.py create mode 100644 tests/test_abilities.py create mode 100644 tests/test_ability_bar.py create mode 100644 tests/test_ability_cooldowns.py create mode 100644 tests/test_actions.py create mode 100644 tests/test_aim.py create mode 100644 tests/test_armor.py create mode 100644 tests/test_blocking.py create mode 100644 tests/test_character_creator.py create mode 100644 tests/test_character_fallback.py create mode 100644 tests/test_character_health.py create mode 100644 tests/test_character_library.py create mode 100644 tests/test_character_menu.py create mode 100644 tests/test_clothing_fit.py create mode 100644 tests/test_combat.py create mode 100644 tests/test_companion.py create mode 100644 tests/test_config.py create mode 100644 tests/test_construction.py create mode 100644 tests/test_continuous_movement.py create mode 100644 tests/test_db_roundtrip.py create mode 100644 tests/test_defs_loading.py create mode 100644 tests/test_gamepad.py create mode 100644 tests/test_health_ticks.py create mode 100644 tests/test_implants.py create mode 100644 tests/test_input.py create mode 100644 tests/test_interaction.py create mode 100644 tests/test_inventory.py create mode 100644 tests/test_knockback.py create mode 100644 tests/test_leap.py create mode 100644 tests/test_map_embedding.py create mode 100644 tests/test_network.py create mode 100644 tests/test_organs.py create mode 100644 tests/test_ranged_combat.py create mode 100644 tests/test_ship_test_map.py create mode 100644 tests/test_shooting.py create mode 100644 tests/test_species_samples.py create mode 100644 tests/test_speed.py create mode 100644 tests/test_staircases.py create mode 100644 tests/test_start_screen.py create mode 100644 tests/test_steering.py create mode 100644 tests/test_time_warp.py create mode 100644 tests/test_timing.py create mode 100644 tests/test_weather.py create mode 100644 tests/test_wielding.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41be794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +data/*.sqlite3 +resources/textures/**/*.png diff --git a/config/controller_bindings.conf b/config/controller_bindings.conf new file mode 100644 index 0000000..fef5c81 --- /dev/null +++ b/config/controller_bindings.conf @@ -0,0 +1,22 @@ +; Each action maps to a comma-separated list of gamepad buttons - add as many as you like to +; the same action. Recognized tokens (standard GLFW gamepad mapping): gp_a, gp_b, gp_x, gp_y, +; gp_left_bumper, gp_right_bumper, gp_left_trigger, gp_right_trigger (analog triggers, treated +; as pressed past a threshold), gp_back, gp_start, gp_guide, gp_left_thumb, gp_right_thumb, +; gp_dpad_up, gp_dpad_down, gp_dpad_left, gp_dpad_right. The left stick always drives movement +; directly (not rebindable here) - see engine/gamepad.py's AXIS_LEFT_X/AXIS_LEFT_Y. + +[actions] +leap = gp_a +block = gp_left_bumper + +[hands] +activate_right_hand = gp_right_bumper +activate_left_hand = gp_x +activate_right_hand_2 = gp_right_trigger +activate_left_hand_2 = gp_left_trigger + +[abilities] +select_ability_1 = gp_dpad_up +select_ability_2 = gp_dpad_right +select_ability_3 = gp_dpad_down +select_ability_4 = gp_dpad_left diff --git a/config/keybindings.conf b/config/keybindings.conf new file mode 100644 index 0000000..001a014 --- /dev/null +++ b/config/keybindings.conf @@ -0,0 +1,31 @@ +; Each action maps to a comma-separated list of keys/buttons - add as many as you like to the +; same action. Recognized tokens: printable keys ("w", "f"), named keys ("space", "ArrowUp"), +; and mouse buttons ("mouse_left", "mouse_right"), optionally prefixed "shift+". + +[movement] +move_up = w, ArrowUp +move_down = s, ArrowDown +move_left = a, ArrowLeft +move_right = d, ArrowRight + +[actions] +leap = space +block = b + +[hands] +activate_right_hand = mouse_left +activate_left_hand = mouse_right +activate_right_hand_2 = shift+mouse_left +activate_left_hand_2 = shift+mouse_right + +[abilities] +select_ability_1 = 1 +select_ability_2 = 2 +select_ability_3 = 3 +select_ability_4 = 4 +select_ability_5 = 5 +select_ability_6 = 6 +select_ability_7 = 7 +select_ability_8 = 8 +select_ability_9 = 9 +select_ability_0 = 0 diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/aim.py b/engine/aim.py new file mode 100644 index 0000000..4ecf8ed --- /dev/null +++ b/engine/aim.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import math + +# Clockwise from east, matching the y-down world convention used throughout (see camera.py). +_EIGHT_WAY_DIRECTIONS: tuple[tuple[int, int], ...] = ( + (1, 0), + (1, 1), + (0, 1), + (-1, 1), + (-1, 0), + (-1, -1), + (0, -1), + (1, -1), +) + + +def snap_to_8way(dx: float, dy: float) -> tuple[int, int]: + """Snaps a free direction to the nearest of the 8 compass directions. + + The mouse can point anywhere ("no fixed angles" while aiming), but combat/interaction still + resolves against the tile grid, so this is where continuous aim meets discrete tile logic. + """ + if dx == 0.0 and dy == 0.0: + return 0, 1 + angle = math.atan2(dy, dx) + index = round(angle / (math.pi / 4)) % 8 + return _EIGHT_WAY_DIRECTIONS[index] + + +def apply_cone_deviation(dx: int, dy: int, cone_degrees: float, rng) -> tuple[int, int]: + """Randomly deviates a direction within +/- cone_degrees/2, snapped back to the grid. + + This is where "the exact direction of the shot is randomized" within the aim cone happens - + the caller (e.g. perform_shoot) supplies the cone's size (from skill + weapon mods). + """ + if cone_degrees <= 0: + return dx, dy + base_angle = math.atan2(dy, dx) + deviation = math.radians(rng.uniform(-cone_degrees / 2, cone_degrees / 2)) + angle = base_angle + deviation + return snap_to_8way(math.cos(angle), math.sin(angle)) diff --git a/engine/camera.py b/engine/camera.py new file mode 100644 index 0000000..c7e6dba --- /dev/null +++ b/engine/camera.py @@ -0,0 +1,42 @@ +from __future__ import annotations + + +class OrthoCamera: + """Top-down orthographic camera; world space is tile units with y increasing downward.""" + + def __init__(self, viewport_w: int, viewport_h: int, tiles_visible_y: float = 12.0): + self.x = 0.0 + self.y = 0.0 + self.viewport_w = viewport_w + self.viewport_h = viewport_h + self.tiles_visible_y = tiles_visible_y + + def set_target(self, x: float, y: float) -> None: + self.x, self.y = x, y + + def resize(self, viewport_w: int, viewport_h: int) -> None: + self.viewport_w, self.viewport_h = viewport_w, viewport_h + + def _world_bounds(self) -> tuple[float, float, float, float]: + half_h = self.tiles_visible_y / 2 + aspect = self.viewport_w / max(self.viewport_h, 1) + half_w = half_h * aspect + return self.x - half_w, self.x + half_w, self.y - half_h, self.y + half_h + + def scale_offset(self) -> tuple[tuple[float, float], tuple[float, float]]: + """Returns (scale, offset) such that ndc = world * scale + offset.""" + left, right, top, bottom = self._world_bounds() + + scale_x = 2.0 / (right - left) + scale_y = -2.0 / (bottom - top) + offset_x = -left * scale_x - 1.0 + offset_y = 1.0 - top * scale_y + + return (scale_x, scale_y), (offset_x, offset_y) + + def screen_to_world(self, px: float, py: float) -> tuple[float, float]: + """Unprojects a canvas logical-pixel position (0,0 top-left) into world tile coords.""" + left, right, top, bottom = self._world_bounds() + wx = left + (px / max(self.viewport_w, 1)) * (right - left) + wy = top + (py / max(self.viewport_h, 1)) * (bottom - top) + return wx, wy diff --git a/engine/character.py b/engine/character.py new file mode 100644 index 0000000..2fd50bb --- /dev/null +++ b/engine/character.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, Sequence, TypeVar + +from engine.entity import Entity +from engine.inventory import Inventory +from engine.skills import default_bio + + +@dataclass +class Ailment: + id: str + name: str + severity: float = 1.0 + progress: float = 0.0 # 0-100 meter for ailments that advance over time (e.g. heatstroke) + + +class _SlottedOverride(Protocol): + slot: str + removed: bool + + +_T = TypeVar("_T", bound=_SlottedOverride) + + +def _resolve_slot(overrides: Sequence[_T], defaults: Sequence[_T], slot: str) -> _T | None: + """Shared by body parts and clothing: an override wins (or explicitly removes), else fall back to the default.""" + for item in overrides: + if item.slot == slot: + return None if item.removed else item + for item in defaults: + if item.slot == slot: + return item + return None + + +@dataclass +class Organ: + """A character's actual organ instance, living inside a specific body part (e.g. the heart + lives in the torso). Independent of BodyPart's one-per-slot model - a single body part can + host several organs at once. + """ + + organ_def_id: str + integrity: float = 100.0 + removed: bool = False + + +@dataclass(frozen=True) +class OrganDef: + id: str + name: str + host_slot: str # which body part slot this organ normally lives in (e.g. "torso") + check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.6, "speed": 0.4} + affects_organs: dict[str, float] = field(default_factory=dict) # other organ_id -> damage/tick per damage-fraction + + +@dataclass +class BodyPart: + """A character's actual body part instance (as opposed to its static def).""" + + slot: str + part_def_id: str + integrity: float = 100.0 + ailments: list[Ailment] = field(default_factory=list) + organs: list[Organ] = field(default_factory=list) + removed: bool = False + + +@dataclass(frozen=True) +class BodyPartDef: + id: str + slot: str + texture: str | None = None + color: tuple[int, int, int] = (255, 0, 255) + bleeding_weight: float = 0.0 + bleeding_speed: float = 0.0 + skill_weights: dict[str, float] = field(default_factory=dict) + speed_factor: float = 0.0 # this part's contribution to movement speed when present (e.g. a leg) + + +@dataclass +class ClothingPiece: + """A worn item instance. Mirrors BodyPart's override/fallback pattern (see _resolve_slot).""" + + slot: str + item_def_id: str + removed: bool = False + inventory: Inventory | None = None # lazily created if the item def grants one (e.g. a backpack) + + +# Hand slots map to the arm body part that must be present to use them. The "_2" pair only +# applies to species with 4 arm slots (e.g. left_arm_2/right_arm_2) - a plain 2-armed human +# simply never has those hands available (wield_item fails the body-part check for them). +HAND_TO_ARM_SLOT = { + "right_hand": "right_arm", + "left_hand": "left_arm", + "right_hand_2": "right_arm_2", + "left_hand_2": "left_arm_2", +} +HAND_PAIRS = { + "right_hand": "left_hand", + "left_hand": "right_hand", + "right_hand_2": "left_hand_2", + "left_hand_2": "right_hand_2", +} + +ARM_SLOTS = ("left_arm", "right_arm", "left_arm_2", "right_arm_2") + + +@dataclass +class HeldItem: + hand_slot: str + item_def_id: str + attachments: list[str] = field(default_factory=list) # attached mod item_def_ids (scopes, stocks, mags, ...) + loaded_ammo_type: str | None = None + loaded_ammo_count: int = 0 + + +class Character(Entity): + def __init__( + self, + *args, + species_id: str | None = None, + gender: str | None = None, + body_parts: list[BodyPart] | None = None, + default_body_parts: list[BodyPart] | None = None, + clothing: list[ClothingPiece] | None = None, + default_clothing: list[ClothingPiece] | None = None, + held_items: list[HeldItem] | None = None, + implants: list[str] | None = None, + is_blocking: bool = False, + strength: int = 10, + blood_percentage: float = 100.0, + mental_stats: dict[str, float] | None = None, + bio: dict[str, int] | None = None, + ability_cooldowns: dict[str, float] | None = None, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.species_id = species_id + self.gender = gender # open marker; valid values are species-defined, not enforced here + self.body_parts: list[BodyPart] = body_parts or [] + self.default_body_parts: list[BodyPart] = default_body_parts or [] + self.clothing: list[ClothingPiece] = clothing or [] + self.default_clothing: list[ClothingPiece] = default_clothing or [] + self.held_items: list[HeldItem] = held_items or [] + self.implants: list[str] = implants or [] # installed implant item_def_ids - no hand/body slot needed + self.is_blocking = is_blocking + self.strength = strength + self.blood_percentage = blood_percentage + self.mental_stats: dict[str, float] | None = mental_stats # optional: NPC/player mechanics like insanity + self.bio: dict[str, int] = bio if bio is not None else default_bio() + self.ability_cooldowns: dict[str, float] = ability_cooldowns or {} # ability_id -> game-clock time it's ready again + + def is_ability_ready(self, ability_id: str, now: float) -> bool: + return now >= self.ability_cooldowns.get(ability_id, 0.0) + + def start_ability_cooldown(self, ability_id: str, now: float, duration: float) -> None: + if duration > 0.0: + self.ability_cooldowns[ability_id] = now + duration + + def get_body_part(self, slot: str) -> BodyPart | None: + return _resolve_slot(self.body_parts, self.default_body_parts, slot) + + def get_or_create_body_part_override(self, slot: str) -> BodyPart | None: + """The override entry for `slot`, creating one (copied from the current resolved state, + including its ailments and organs) if none exists yet. Anything that needs to mutate a + body part in place (damage, ailments, organs, ...) should go through this rather than + get_body_part, which may return a shared default instance that must not be mutated + directly. + """ + for part in self.body_parts: + if part.slot == slot: + return part + current = self.get_body_part(slot) + if current is None: + return None + override = BodyPart( + slot=slot, + part_def_id=current.part_def_id, + integrity=current.integrity, + ailments=list(current.ailments), + organs=[Organ(o.organ_def_id, o.integrity, o.removed) for o in current.organs], + ) + self.body_parts.append(override) + return override + + def resolved_body_parts(self) -> dict[str, BodyPart | None]: + slots = {p.slot for p in self.default_body_parts} | {p.slot for p in self.body_parts} + return {slot: self.get_body_part(slot) for slot in slots} + + def get_clothing(self, slot: str) -> ClothingPiece | None: + return _resolve_slot(self.clothing, self.default_clothing, slot) + + def resolved_clothing(self) -> dict[str, ClothingPiece | None]: + slots = {p.slot for p in self.default_clothing} | {p.slot for p in self.clothing} + return {slot: self.get_clothing(slot) for slot in slots} + + def arm_slot_count(self) -> int: + """How many arm slots this character currently has present (a missing/removed arm + doesn't count) - used to check whether a sleeved garment fits, see wear_item. + """ + return sum(1 for slot in ARM_SLOTS if self.get_body_part(slot) is not None) + + def wear_item(self, slot: str, item_def_id: str, registry) -> bool: + """Puts on a clothing item in the given slot, replacing whatever was already there. + + Fails (no-op) if the item doesn't belong in that slot, if the wearer's species' + body_type isn't one the item is tailored for at all (see ItemDef.compatible_body_types - + e.g. a dog can't wear a human's jacket, full stop, regardless of sleeves), or - for a + sleeved garment within a compatible body type (see ItemDef.arm_slots_required) - the + wearer has more arms than the garment has sleeves for. A garment tailored for more arms + than the wearer has still fits fine, the extra sleeves just go unused (e.g. a 2-armed + human can wear a 4-armed reptilian's jacket, both being bipeds), but the reverse doesn't + work (a 4-armed wearer can't fit into a 2-sleeve jacket). + """ + item_def = registry.get_item_def(item_def_id) + if item_def.clothing_slot != slot: + return False + if item_def.compatible_body_types and self.species_id is not None: + species = registry.get_species_def(self.species_id) + if species.body_type not in item_def.compatible_body_types: + return False + if item_def.arm_slots_required > 0 and self.arm_slot_count() > item_def.arm_slots_required: + return False + self.clothing = [c for c in self.clothing if c.slot != slot] + self.clothing.append(ClothingPiece(slot, item_def_id)) + return True + + def take_off(self, slot: str) -> None: + self.clothing = [c for c in self.clothing if c.slot != slot] + + def get_equipped_inventory(self, slot: str, registry) -> Inventory | None: + """Returns the inventory granted by a worn item (e.g. a backpack), creating it on first access.""" + piece = self.get_clothing(slot) + if piece is None: + return None + item_def = registry.get_item_def(piece.item_def_id) + if item_def.grants_inventory_size is None: + return None + if piece.inventory is None: + piece.inventory = Inventory(*item_def.grants_inventory_size) + return piece.inventory + + def get_held_item(self, hand_slot: str) -> HeldItem | None: + return next((h for h in self.held_items if h.hand_slot == hand_slot), None) + + def install_implant(self, item_def_id: str, registry) -> bool: + """Installs a slotless implant - no hand or body slot needed, unlike wielding/clothing.""" + item_def = registry.get_item_def(item_def_id) + if not item_def.is_implant or item_def_id in self.implants: + return False + self.implants.append(item_def_id) + return True + + def uninstall_implant(self, item_def_id: str) -> None: + if item_def_id in self.implants: + self.implants.remove(item_def_id) + + def wield_item(self, hand_slot: str, item_def_id: str, registry) -> bool: + """Puts an item in the given hand; two-handed items also occupy its paired hand. + + Fails (no-op) if the corresponding arm is missing, or (for two-handed items) the + paired hand's arm is missing too. + """ + item_def = registry.get_item_def(item_def_id) + arm_slot = HAND_TO_ARM_SLOT.get(hand_slot) + if arm_slot is None or self.get_body_part(arm_slot) is None: + return False + + hands_needed = [hand_slot] + if item_def.hands_required >= 2: + paired = HAND_PAIRS.get(hand_slot) + paired_arm = HAND_TO_ARM_SLOT.get(paired) if paired else None + if paired is None or paired_arm is None or self.get_body_part(paired_arm) is None: + return False + hands_needed.append(paired) + + self.held_items = [h for h in self.held_items if h.hand_slot not in hands_needed] + for hand in hands_needed: + self.held_items.append(HeldItem(hand, item_def_id)) + return True + + def unwield(self, hand_slot: str) -> None: + self.held_items = [h for h in self.held_items if h.hand_slot != hand_slot] + + def wielded_shield(self, registry): + """The first held item that acts as a shield (has a block bonus or blockable types), if any.""" + for held in self.held_items: + item_def = registry.get_item_def(held.item_def_id) + if item_def.shield_block_bonus > 0 or item_def.blockable_projectile_types: + return item_def + return None + + def attach_mod(self, hand_slot: str, mod_item_def_id: str, registry) -> bool: + """Attaches a mod (scope/stock/magazine/...) to the item held in `hand_slot`.""" + held = self.get_held_item(hand_slot) + if held is None: + return False + weapon_def = registry.get_item_def(held.item_def_id) + mod_def = registry.get_item_def(mod_item_def_id) + if mod_def.attachment_kind is None: + return False + if weapon_def.magazine_size <= 0 and weapon_def.aim_cone_degrees <= 0: + return False # only ranged/ammo weapons take mods + held.attachments.append(mod_item_def_id) + return True + + def magazine_capacity(self, hand_slot: str, registry) -> int: + """The held weapon's ammo capacity: its base magazine_size plus any attached mod bonuses.""" + held = self.get_held_item(hand_slot) + if held is None: + return 0 + weapon_def = registry.get_item_def(held.item_def_id) + bonus = sum(registry.get_item_def(mod_id).magazine_capacity_bonus for mod_id in held.attachments) + return weapon_def.magazine_size + bonus + + def reload(self, hand_slot: str, registry) -> bool: + """Manually reloads the held weapon from the character's own inventory. + + Tops up from whichever compatible ammo type is already loaded (if any and still + available), otherwise the first compatible type found. Fails as a no-op if there's no + ammo-fed weapon held, no inventory, no matching ammo, or the magazine is already full. + """ + held = self.get_held_item(hand_slot) + if held is None or self.inventory is None: + return False + weapon_def = registry.get_item_def(held.item_def_id) + if weapon_def.magazine_size <= 0: + return False + capacity = self.magazine_capacity(hand_slot, registry) + if held.loaded_ammo_count >= capacity: + return False + + ammo_type = held.loaded_ammo_type if held.loaded_ammo_count > 0 else None + placed = next( + ( + p + for p in self.inventory.items() + if registry.get_item_def(p.item.def_id).ammo_type + in ((ammo_type,) if ammo_type else weapon_def.compatible_ammo_types) + ), + None, + ) + if placed is None: + return False + + ammo_def = registry.get_item_def(placed.item.def_id) + take = min(capacity - held.loaded_ammo_count, placed.item.quantity) + if take <= 0: + return False + placed.item.quantity -= take + if placed.item.quantity <= 0: + self.inventory.remove(placed.item.item_id, ammo_def) + held.loaded_ammo_type = ammo_def.ammo_type + held.loaded_ammo_count += take + return True + + def effective_speed(self, base_speed: float, registry) -> float: + """Movement speed = base_speed * sum(each present body part's own speed_factor, + scaled by its current integrity), further reduced by organ damage (e.g. a weakened + heart or lungs). A missing leg contributes nothing; an installed cybernetic replacement + (e.g. a bionic leg) contributes its own (possibly higher) factor instead of the default + part's - no separate mechanism needed, it's just a slot override. + """ + total_factor = 0.0 + for part in self.resolved_body_parts().values(): + if part is None: + continue + part_def = registry.get_body_part_def(part.part_def_id) + if part_def.speed_factor <= 0.0: + continue + total_factor += part_def.speed_factor * (part.integrity / 100.0) + return base_speed * total_factor * (1.0 - self.organ_penalty("speed", registry)) + + def effective_strength(self, registry) -> float: + """Raw strength reduced by organ damage (e.g. a weakened heart saps physical power).""" + return self.strength * (1.0 - self.organ_penalty("strength", registry)) + + def can_perform_ability(self, ability_id: str, registry) -> bool: + """Whether this character's species defines `ability_id` and its required body parts are all present.""" + if self.species_id is None: + return False + species = registry.get_species_def(self.species_id) + ability = next((a for a in species.abilities if a.id == ability_id), None) + if ability is None: + return False + return all(self.get_body_part(slot) is not None for slot in ability.required_slots) + + def blood_status(self, species_def) -> str: + if self.blood_percentage <= species_def.blood_critical_threshold: + return "critical" + if self.blood_percentage <= species_def.blood_danger_threshold: + return "danger" + return "normal" + + def skill_penalty(self, skill_id: str, registry) -> float: + """Fraction (0..1) of `skill_id` lost to missing/damaged body parts, weighted per the species' default parts.""" + total_weight = 0.0 + lost_weight = 0.0 + for default_part in self.default_body_parts: + part_def = registry.get_body_part_def(default_part.part_def_id) + weight = part_def.skill_weights.get(skill_id, 0.0) + if weight == 0.0: + continue + total_weight += weight + current = self.get_body_part(default_part.slot) + integrity_fraction = 0.0 if current is None else current.integrity / 100.0 + lost_weight += weight * (1.0 - integrity_fraction) + if total_weight == 0.0: + return 0.0 + return lost_weight / total_weight + + def organ_penalty(self, check_id: str, registry) -> float: + """Fraction (0..1) of `check_id` lost to damaged/missing organs, weighted per organ - + mirrors skill_penalty exactly, but over organs instead of body parts, and over whatever + named check an organ's check_weights lists (not just D&D skills - e.g. "strength", + "speed", or a not-yet-implemented "constitution"/"poison_resistance"). + """ + total_weight = 0.0 + lost_weight = 0.0 + for part in self.resolved_body_parts().values(): + if part is None: + continue + for organ in part.organs: + organ_def = registry.get_organ_def(organ.organ_def_id) + weight = organ_def.check_weights.get(check_id, 0.0) + if weight == 0.0: + continue + total_weight += weight + integrity_fraction = 0.0 if organ.removed else organ.integrity / 100.0 + lost_weight += weight * (1.0 - integrity_fraction) + if total_weight == 0.0: + return 0.0 + return lost_weight / total_weight + + def total_check_penalty(self, check_id: str, registry) -> float: + """Combined penalty from both damaged body parts and damaged organs for a named check, + applied multiplicatively (as independent sources of lost effectiveness) so it can never + exceed 1.0 lost. Reduces to skill_penalty alone when no organ affects this check or all + relevant organs are healthy - existing skill-only checks are unaffected either way. + """ + remaining = (1.0 - self.skill_penalty(check_id, registry)) * (1.0 - self.organ_penalty(check_id, registry)) + return 1.0 - remaining diff --git a/engine/character_creator.py b/engine/character_creator.py new file mode 100644 index 0000000..ba2bc01 --- /dev/null +++ b/engine/character_creator.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from engine.character import BodyPart, Character +from engine.character_library import CharacterLibrary +from engine.defs import DefRegistry +from engine.skills import default_bio + +DEFAULT_SKILL_POINTS = 10 +DEFAULT_MAX_SKILL_LEVEL = 5 + + +@dataclass +class CharacterCreator: + """Pure state/logic for building a new persistent character: pick a species, a gender + valid for that species (species define their own valid genders - see SpeciesDef.genders), + a name, and spend a fixed point budget across the classic D&D-style bio skills + (engine/skills.py) - then confirm() builds an actual Character and saves it into the + CharacterLibrary under a given account, ready to appear on the StartScreen. + + No rendering here - same boundary as StartScreen/CharacterMenu/LayerPickerMenu/AbilityBar + (engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, and skill + +/- steppers is a follow-up UI-layer task. This is the state/logic side: valid choices, + point-budget bookkeeping, and producing the finished Character. + """ + + registry: DefRegistry + skill_points_budget: int = DEFAULT_SKILL_POINTS + max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL + + name: str = "" + species_id: str | None = None + gender: str | None = None + bio: dict[str, int] = field(default_factory=default_bio) + + def set_name(self, name: str) -> None: + self.name = name.strip() + + def set_species(self, species_id: str) -> bool: + """Picks a species, resetting gender to that species' first valid option if the + current gender (from a previous species pick) isn't valid for it. + """ + if species_id not in self.registry.species_defs: + return False + self.species_id = species_id + species = self.registry.get_species_def(species_id) + if self.gender not in species.genders: + self.gender = species.genders[0] if species.genders else None + return True + + def set_gender(self, gender: str) -> bool: + if self.species_id is None: + return False + species = self.registry.get_species_def(self.species_id) + if gender not in species.genders: + return False + self.gender = gender + return True + + def spent_skill_points(self) -> int: + return sum(self.bio.values()) + + def remaining_skill_points(self) -> int: + return self.skill_points_budget - self.spent_skill_points() + + def increase_skill(self, skill_id: str) -> bool: + if skill_id not in self.bio: + return False + if self.remaining_skill_points() <= 0 or self.bio[skill_id] >= self.max_skill_level: + return False + self.bio[skill_id] += 1 + return True + + def decrease_skill(self, skill_id: str) -> bool: + if skill_id not in self.bio or self.bio[skill_id] <= 0: + return False + self.bio[skill_id] -= 1 + return True + + def preview_body_parts(self) -> list[BodyPart]: + """Read-only preview of the anatomy this character will start with, for a UI to render + without needing to build a whole Character first. + """ + if self.species_id is None: + return [] + return self.registry.new_default_body_parts(self.species_id) + + def is_ready(self) -> bool: + return bool(self.name) and self.species_id is not None and self.gender is not None + + def build_character(self) -> Character | None: + if not self.is_ready(): + return None + assert self.species_id is not None + return Character( + name=self.name, + species_id=self.species_id, + gender=self.gender, + default_body_parts=self.registry.new_default_body_parts(self.species_id), + bio=dict(self.bio), + ) + + def confirm(self, library: CharacterLibrary, account_id: str, character_id: str) -> Character | None: + """Builds the character and saves it into the library under (account_id, character_id) - + the "create" action once name/species/gender/skills are all set. No-op (returns None, + nothing saved) if the creator isn't ready yet. + """ + character = self.build_character() + if character is None: + return None + library.save_character(account_id, character_id, character) + return character diff --git a/engine/character_library.py b/engine/character_library.py new file mode 100644 index 0000000..e65c8fb --- /dev/null +++ b/engine/character_library.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from engine.character import Ailment, BodyPart, Character, ClothingPiece, HeldItem, Organ +from engine.defs import DefRegistry +from engine.inventory import Inventory +from engine.item import ItemInstance + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS library_characters ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + name TEXT NOT NULL, + species_id TEXT, + gender TEXT, + blood_percentage REAL NOT NULL DEFAULT 100.0, + strength INTEGER NOT NULL DEFAULT 10, + PRIMARY KEY (account_id, character_id) +); + +CREATE TABLE IF NOT EXISTS library_body_parts ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + part_def_id TEXT NOT NULL, + integrity REAL NOT NULL DEFAULT 100.0, + removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, character_id, is_default, slot) +); + +CREATE TABLE IF NOT EXISTS library_body_part_ailments ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + ailment_id TEXT NOT NULL, + name TEXT NOT NULL, + severity REAL NOT NULL DEFAULT 1.0, + progress REAL NOT NULL DEFAULT 0.0 +); + +CREATE TABLE IF NOT EXISTS library_organs ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + organ_def_id TEXT NOT NULL, + integrity REAL NOT NULL DEFAULT 100.0, + removed INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS library_clothing ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + item_def_id TEXT NOT NULL, + removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, character_id, is_default, slot) +); + +CREATE TABLE IF NOT EXISTS library_held_items ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + hand_slot TEXT NOT NULL, + item_def_id TEXT NOT NULL, + loaded_ammo_type TEXT, + loaded_ammo_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, character_id, hand_slot) +); + +CREATE TABLE IF NOT EXISTS library_held_item_attachments ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + hand_slot TEXT NOT NULL, + attachment_item_def_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS library_implants ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + item_def_id TEXT NOT NULL, + PRIMARY KEY (account_id, character_id, item_def_id) +); + +CREATE TABLE IF NOT EXISTS library_mental_stats ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + stat TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY (account_id, character_id, stat) +); + +CREATE TABLE IF NOT EXISTS library_bio ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + skill TEXT NOT NULL, + level INTEGER NOT NULL, + PRIMARY KEY (account_id, character_id, skill) +); + +CREATE TABLE IF NOT EXISTS library_inventories ( + account_id TEXT NOT NULL, + character_id TEXT NOT NULL, + inventory_id TEXT PRIMARY KEY, + owner_clothing_is_default INTEGER, + owner_clothing_slot TEXT, + width INTEGER NOT NULL, + height INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS library_inventory_items ( + inventory_id TEXT NOT NULL REFERENCES library_inventories(inventory_id), + item_id TEXT NOT NULL, + def_id TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + PRIMARY KEY (inventory_id, item_id) +); +""" + +_CLEAR_TABLES = ( + # library_inventory_items has no account_id/character_id of its own (see _clear_existing) - it's + # keyed only by inventory_id, so it's cleared via a join through library_inventories instead. + "library_inventories", + "library_bio", + "library_mental_stats", + "library_implants", + "library_held_item_attachments", + "library_held_items", + "library_clothing", + "library_organs", + "library_body_part_ailments", + "library_body_parts", + "library_characters", +) + + +class CharacterLibrary: + """Cross-session character persistence, keyed by (account_id, character_id) - separate + from WorldRepository's per-world save file. A character saved here has no position or + map; it's a portable template a user can spawn into any world (single-player continuation + or joining someone else's multiplayer session with a character built up over time). + """ + + def __init__(self, conn: sqlite3.Connection): + self.conn = conn + self.conn.executescript(SCHEMA) + self.conn.commit() + + @classmethod + def open(cls, path: str | Path) -> "CharacterLibrary": + conn = sqlite3.connect(str(path)) + return cls(conn) + + def close(self) -> None: + self.conn.close() + + def list_characters(self, account_id: str) -> list[tuple[str, str, str | None]]: + """(character_id, name, species_id) for every character saved under this account.""" + cur = self.conn.cursor() + return cur.execute( + "SELECT character_id, name, species_id FROM library_characters WHERE account_id = ? ORDER BY name", + (account_id,), + ).fetchall() + + def _clear_existing(self, cur: sqlite3.Cursor, account_id: str, character_id: str) -> None: + cur.execute( + "DELETE FROM library_inventory_items WHERE inventory_id IN" + " (SELECT inventory_id FROM library_inventories WHERE account_id = ? AND character_id = ?)", + (account_id, character_id), + ) + for table in _CLEAR_TABLES: + cur.execute(f"DELETE FROM {table} WHERE account_id = ? AND character_id = ?", (account_id, character_id)) + + def delete_character(self, account_id: str, character_id: str) -> None: + cur = self.conn.cursor() + self._clear_existing(cur, account_id, character_id) + self.conn.commit() + + def save_character(self, account_id: str, character_id: str, character: Character) -> None: + cur = self.conn.cursor() + self._clear_existing(cur, account_id, character_id) + + cur.execute( + "INSERT INTO library_characters (account_id, character_id, name, species_id, gender," + " blood_percentage, strength) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + account_id, character_id, character.name, character.species_id, character.gender, + character.blood_percentage, character.strength, + ), + ) + + for is_default, parts in ((0, character.body_parts), (1, character.default_body_parts)): + for part in parts: + cur.execute( + "INSERT INTO library_body_parts (account_id, character_id, is_default, slot, part_def_id," + " integrity, removed) VALUES (?, ?, ?, ?, ?, ?, ?)", + (account_id, character_id, is_default, part.slot, part.part_def_id, part.integrity, int(part.removed)), + ) + for ailment in part.ailments: + cur.execute( + "INSERT INTO library_body_part_ailments (account_id, character_id, is_default, slot," + " ailment_id, name, severity, progress) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + account_id, character_id, is_default, part.slot, ailment.id, ailment.name, + ailment.severity, ailment.progress, + ), + ) + for organ in part.organs: + cur.execute( + "INSERT INTO library_organs (account_id, character_id, is_default, slot, organ_def_id," + " integrity, removed) VALUES (?, ?, ?, ?, ?, ?, ?)", + (account_id, character_id, is_default, part.slot, organ.organ_def_id, organ.integrity, int(organ.removed)), + ) + + for is_default, pieces in ((0, character.clothing), (1, character.default_clothing)): + for piece in pieces: + cur.execute( + "INSERT INTO library_clothing (account_id, character_id, is_default, slot, item_def_id, removed)" + " VALUES (?, ?, ?, ?, ?, ?)", + (account_id, character_id, is_default, piece.slot, piece.item_def_id, int(piece.removed)), + ) + if piece.inventory is not None: + self._save_inventory( + cur, f"lib-{account_id}-{character_id}-clothing-{is_default}-{piece.slot}", + account_id, character_id, piece.inventory, owner_clothing=(is_default, piece.slot), + ) + + for held in character.held_items: + cur.execute( + "INSERT INTO library_held_items (account_id, character_id, hand_slot, item_def_id," + " loaded_ammo_type, loaded_ammo_count) VALUES (?, ?, ?, ?, ?, ?)", + (account_id, character_id, held.hand_slot, held.item_def_id, held.loaded_ammo_type, held.loaded_ammo_count), + ) + for attachment_id in held.attachments: + cur.execute( + "INSERT INTO library_held_item_attachments (account_id, character_id, hand_slot," + " attachment_item_def_id) VALUES (?, ?, ?, ?)", + (account_id, character_id, held.hand_slot, attachment_id), + ) + + for implant_item_def_id in character.implants: + cur.execute( + "INSERT INTO library_implants (account_id, character_id, item_def_id) VALUES (?, ?, ?)", + (account_id, character_id, implant_item_def_id), + ) + + if character.mental_stats is not None: + for stat, value in character.mental_stats.items(): + cur.execute( + "INSERT INTO library_mental_stats (account_id, character_id, stat, value) VALUES (?, ?, ?, ?)", + (account_id, character_id, stat, value), + ) + + for skill, level in character.bio.items(): + cur.execute( + "INSERT INTO library_bio (account_id, character_id, skill, level) VALUES (?, ?, ?, ?)", + (account_id, character_id, skill, level), + ) + + if character.inventory is not None: + self._save_inventory(cur, f"lib-{account_id}-{character_id}", account_id, character_id, character.inventory) + + self.conn.commit() + + def _save_inventory( + self, + cur: sqlite3.Cursor, + inventory_id: str, + account_id: str, + character_id: str, + inventory: Inventory, + owner_clothing: tuple[int, str] | None = None, + ) -> None: + owner_clothing_is_default, owner_clothing_slot = owner_clothing if owner_clothing else (None, None) + cur.execute( + "INSERT INTO library_inventories (account_id, character_id, inventory_id, owner_clothing_is_default," + " owner_clothing_slot, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)", + (account_id, character_id, inventory_id, owner_clothing_is_default, owner_clothing_slot, inventory.width, inventory.height), + ) + for placed in inventory.items(): + cur.execute( + "INSERT INTO library_inventory_items (inventory_id, item_id, def_id, quantity, x, y)" + " VALUES (?, ?, ?, ?, ?, ?)", + (inventory_id, placed.item.item_id, placed.item.def_id, placed.item.quantity, placed.x, placed.y), + ) + + def load_character(self, account_id: str, character_id: str, registry: DefRegistry) -> Character | None: + cur = self.conn.cursor() + row = cur.execute( + "SELECT name, species_id, gender, blood_percentage, strength FROM library_characters" + " WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ).fetchone() + if row is None: + return None + name, species_id, gender, blood_percentage, strength = row + + body_parts: list[BodyPart] = [] + default_body_parts: list[BodyPart] = [] + part_rows = cur.execute( + "SELECT is_default, slot, part_def_id, integrity, removed FROM library_body_parts" + " WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ).fetchall() + for is_default, slot, part_def_id, integrity, removed in part_rows: + part = BodyPart(slot=slot, part_def_id=part_def_id, integrity=integrity, removed=bool(removed)) + ailment_rows = cur.execute( + "SELECT ailment_id, name, severity, progress FROM library_body_part_ailments" + " WHERE account_id = ? AND character_id = ? AND is_default = ? AND slot = ?", + (account_id, character_id, is_default, slot), + ).fetchall() + for ailment_id, ailment_name, severity, progress in ailment_rows: + part.ailments.append(Ailment(id=ailment_id, name=ailment_name, severity=severity, progress=progress)) + organ_rows = cur.execute( + "SELECT organ_def_id, integrity, removed FROM library_organs" + " WHERE account_id = ? AND character_id = ? AND is_default = ? AND slot = ?", + (account_id, character_id, is_default, slot), + ).fetchall() + for organ_def_id, organ_integrity, organ_removed in organ_rows: + part.organs.append(Organ(organ_def_id=organ_def_id, integrity=organ_integrity, removed=bool(organ_removed))) + (default_body_parts if is_default else body_parts).append(part) + + clothing: list[ClothingPiece] = [] + default_clothing: list[ClothingPiece] = [] + clothing_rows = cur.execute( + "SELECT is_default, slot, item_def_id, removed FROM library_clothing" + " WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ).fetchall() + for is_default, slot, item_def_id, removed in clothing_rows: + piece = ClothingPiece(slot=slot, item_def_id=item_def_id, removed=bool(removed)) + inv_row = cur.execute( + "SELECT inventory_id, width, height FROM library_inventories" + " WHERE account_id = ? AND character_id = ? AND owner_clothing_is_default = ? AND owner_clothing_slot = ?", + (account_id, character_id, is_default, slot), + ).fetchone() + if inv_row is not None: + inventory_id, width, height = inv_row + piece.inventory = self._load_inventory(cur, inventory_id, width, height, registry) + (default_clothing if is_default else clothing).append(piece) + + held_items: list[HeldItem] = [] + held_rows = cur.execute( + "SELECT hand_slot, item_def_id, loaded_ammo_type, loaded_ammo_count FROM library_held_items" + " WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ).fetchall() + for hand_slot, item_def_id, loaded_ammo_type, loaded_ammo_count in held_rows: + attachment_rows = cur.execute( + "SELECT attachment_item_def_id FROM library_held_item_attachments" + " WHERE account_id = ? AND character_id = ? AND hand_slot = ?", + (account_id, character_id, hand_slot), + ).fetchall() + held_items.append( + HeldItem( + hand_slot=hand_slot, + item_def_id=item_def_id, + attachments=[a for (a,) in attachment_rows], + loaded_ammo_type=loaded_ammo_type, + loaded_ammo_count=loaded_ammo_count, + ) + ) + + implants = [ + item_def_id + for (item_def_id,) in cur.execute( + "SELECT item_def_id FROM library_implants WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ) + ] + + mental_stats = { + stat: value + for stat, value in cur.execute( + "SELECT stat, value FROM library_mental_stats WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ) + } or None + + bio = { + skill: level + for skill, level in cur.execute( + "SELECT skill, level FROM library_bio WHERE account_id = ? AND character_id = ?", + (account_id, character_id), + ) + } + + character = Character( + entity_id=character_id, + name=name, + def_id=None, + position=None, + species_id=species_id, + gender=gender, + blood_percentage=blood_percentage, + strength=strength, + body_parts=body_parts, + default_body_parts=default_body_parts, + clothing=clothing, + default_clothing=default_clothing, + held_items=held_items, + implants=implants, + mental_stats=mental_stats, + bio=bio, + ) + + inv_row = cur.execute( + "SELECT inventory_id, width, height FROM library_inventories" + " WHERE account_id = ? AND character_id = ? AND owner_clothing_slot IS NULL", + (account_id, character_id), + ).fetchone() + if inv_row is not None: + inventory_id, width, height = inv_row + character.inventory = self._load_inventory(cur, inventory_id, width, height, registry) + + return character + + def _load_inventory( + self, cur: sqlite3.Cursor, inventory_id: str, width: int, height: int, registry: DefRegistry + ) -> Inventory: + inventory = Inventory(width, height) + rows = cur.execute( + "SELECT item_id, def_id, quantity, x, y FROM library_inventory_items WHERE inventory_id = ?", + (inventory_id,), + ).fetchall() + for item_id, def_id, quantity, ix, iy in rows: + item_def = registry.get_item_def(def_id) + inventory.place(ItemInstance(item_id, def_id, quantity), item_def, ix, iy) + return inventory diff --git a/engine/config.py b/engine/config.py new file mode 100644 index 0000000..3ce514e --- /dev/null +++ b/engine/config.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import configparser +from pathlib import Path + +DEFAULT_KEYBINDINGS: dict[str, list[str]] = { + "move_up": ["w", "ArrowUp"], + "move_down": ["s", "ArrowDown"], + "move_left": ["a", "ArrowLeft"], + "move_right": ["d", "ArrowRight"], + "leap": ["space"], + "block": ["b"], + "activate_right_hand": ["mouse_left"], + "activate_left_hand": ["mouse_right"], + "activate_right_hand_2": ["shift+mouse_left"], + "activate_left_hand_2": ["shift+mouse_right"], + "select_ability_1": ["1"], + "select_ability_2": ["2"], + "select_ability_3": ["3"], + "select_ability_4": ["4"], + "select_ability_5": ["5"], + "select_ability_6": ["6"], + "select_ability_7": ["7"], + "select_ability_8": ["8"], + "select_ability_9": ["9"], + "select_ability_0": ["0"], +} + +# Human-friendly config names for keys that don't have a printable representation. +_KEY_ALIASES = {"space": " "} + + +def _normalize(key: str) -> str: + return _KEY_ALIASES.get(key, key) + + +class KeyBindings: + """Action -> list of keys, loaded from an editable .conf file (arrays, so several keys + can share one action). Falls back to DEFAULT_KEYBINDINGS for anything the file omits. + """ + + def __init__(self, bindings: dict[str, list[str]]): + self.bindings = bindings + self._action_by_key: dict[str, str] = {} + for action, keys in bindings.items(): + for key in keys: + self._action_by_key[_normalize(key)] = action + + @classmethod + def load(cls, path: Path) -> "KeyBindings": + bindings = {action: list(keys) for action, keys in DEFAULT_KEYBINDINGS.items()} + if path.exists(): + parser = configparser.ConfigParser() + parser.read(path) + for section in parser.sections(): + for action, raw in parser.items(section): + keys = [k.strip() for k in raw.split(",") if k.strip()] + if keys: + bindings[action] = keys + return cls(bindings) + + def action_for_key(self, key: str) -> str | None: + return self._action_by_key.get(key) + + def keys_for_action(self, action: str) -> list[str]: + return self.bindings.get(action, []) diff --git a/engine/db.py b/engine/db.py new file mode 100644 index 0000000..030c116 --- /dev/null +++ b/engine/db.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from engine.character import Ailment, BodyPart, Character, ClothingPiece +from engine.defs import DefRegistry +from engine.entity import Entity, EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map import Map +from engine.map_loader import MapLoader +from engine.tile import TILE_LAYERS, Tile, TileLayerInstance +from engine.world import World + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS maps ( + map_id TEXT PRIMARY KEY, + source_def TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS map_embeddings ( + parent_map_id TEXT NOT NULL REFERENCES maps(map_id), + child_map_id TEXT NOT NULL REFERENCES maps(map_id), + anchor_x INTEGER NOT NULL, + anchor_y INTEGER NOT NULL, + anchor_z INTEGER NOT NULL, + PRIMARY KEY (parent_map_id, child_map_id) +); + +CREATE TABLE IF NOT EXISTS tile_overrides ( + map_id TEXT NOT NULL REFERENCES maps(map_id), + x INTEGER NOT NULL, + y INTEGER NOT NULL, + z INTEGER NOT NULL, + subfloor TEXT, + subfloor_integrity REAL, + flooring TEXT, + flooring_integrity REAL, + room TEXT, + room_integrity REAL, + roof TEXT, + roof_integrity REAL, + PRIMARY KEY (map_id, x, y, z) +); + +CREATE TABLE IF NOT EXISTS entities ( + entity_id TEXT PRIMARY KEY, + def_id TEXT, + kind TEXT NOT NULL, + name TEXT, + map_id TEXT NOT NULL REFERENCES maps(map_id), + x INTEGER NOT NULL, + y INTEGER NOT NULL, + z INTEGER NOT NULL, + species_id TEXT, + gender TEXT, + blood_percentage REAL +); + +CREATE TABLE IF NOT EXISTS character_body_parts ( + entity_id TEXT NOT NULL REFERENCES entities(entity_id), + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + part_def_id TEXT NOT NULL, + integrity REAL NOT NULL DEFAULT 100.0, + removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (entity_id, is_default, slot) +); + +CREATE TABLE IF NOT EXISTS character_body_part_ailments ( + entity_id TEXT NOT NULL, + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + ailment_id TEXT NOT NULL, + name TEXT NOT NULL, + severity REAL NOT NULL DEFAULT 1.0, + FOREIGN KEY (entity_id, is_default, slot) REFERENCES character_body_parts(entity_id, is_default, slot) +); + +CREATE TABLE IF NOT EXISTS character_clothing ( + entity_id TEXT NOT NULL REFERENCES entities(entity_id), + is_default INTEGER NOT NULL, + slot TEXT NOT NULL, + item_def_id TEXT NOT NULL, + removed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (entity_id, is_default, slot) +); + +CREATE TABLE IF NOT EXISTS character_mental_stats ( + entity_id TEXT NOT NULL REFERENCES entities(entity_id), + stat TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY (entity_id, stat) +); + +CREATE TABLE IF NOT EXISTS character_bio ( + entity_id TEXT NOT NULL REFERENCES entities(entity_id), + skill TEXT NOT NULL, + level INTEGER NOT NULL, + PRIMARY KEY (entity_id, skill) +); + +CREATE TABLE IF NOT EXISTS inventories ( + inventory_id TEXT PRIMARY KEY, + owner_entity_id TEXT NOT NULL REFERENCES entities(entity_id), + owner_clothing_is_default INTEGER, + owner_clothing_slot TEXT, + width INTEGER NOT NULL, + height INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS inventory_items ( + item_id TEXT PRIMARY KEY, + inventory_id TEXT NOT NULL REFERENCES inventories(inventory_id), + def_id TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + x INTEGER NOT NULL, + y INTEGER NOT NULL +); +""" + +_SAVE_CLEAR_TABLES = ( + "inventory_items", + "inventories", + "character_bio", + "character_mental_stats", + "character_body_part_ailments", + "character_body_parts", + "character_clothing", + "entities", + "tile_overrides", + "map_embeddings", + "maps", + "meta", +) + + +class WorldRepository: + def __init__(self, conn: sqlite3.Connection): + self.conn = conn + self.conn.executescript(SCHEMA) + self.conn.commit() + + @classmethod + def open(cls, path: str | Path) -> "WorldRepository": + conn = sqlite3.connect(str(path)) + return cls(conn) + + def close(self) -> None: + self.conn.close() + + def save_world(self, world: World) -> None: + cur = self.conn.cursor() + for table in _SAVE_CLEAR_TABLES: + cur.execute(f"DELETE FROM {table}") + + for map_ in world.maps.values(): + cur.execute( + "INSERT INTO maps (map_id, source_def) VALUES (?, ?)", + (map_.map_id, map_.source_def), + ) + for map_ in world.maps.values(): + for embedding in map_.embeddings: + cur.execute( + "INSERT INTO map_embeddings (parent_map_id, child_map_id, anchor_x, anchor_y, anchor_z)" + " VALUES (?, ?, ?, ?, ?)", + (map_.map_id, embedding.child_map.map_id, *embedding.anchor), + ) + + for map_ in world.maps.values(): + self._save_tiles(cur, map_) + + for entity in world.entities.values(): + self._save_entity(cur, entity) + + if world.player is not None: + cur.execute("INSERT INTO meta (key, value) VALUES ('player_entity_id', ?)", (world.player.entity_id,)) + + self.conn.commit() + + def _save_tiles(self, cur: sqlite3.Cursor, map_: Map) -> None: + """Full snapshot of every tile (not a sparse diff) - simple and correct for base-building + style runtime edits: any change to any layer of any tile survives a save/reload. + """ + for z in range(map_.depth): + for y in range(map_.height): + for x in range(map_.width): + tile = map_.get_tile(x, y, z) + values: list = [map_.map_id, x, y, z] + for layer in TILE_LAYERS: + instance = tile.get_layer(layer) + values.append(instance.def_id if instance else None) + values.append(instance.integrity if instance else None) + cur.execute( + "INSERT INTO tile_overrides" + " (map_id, x, y, z, subfloor, subfloor_integrity, flooring, flooring_integrity," + " room, room_integrity, roof, roof_integrity)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + values, + ) + + def _save_entity(self, cur: sqlite3.Cursor, entity: Entity) -> None: + assert entity.position is not None + is_character = isinstance(entity, Character) + cur.execute( + "INSERT INTO entities (entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + entity.entity_id, + entity.def_id, + "character" if is_character else "entity", + entity.name, + entity.position.map_id, + entity.position.x, + entity.position.y, + entity.position.z, + entity.species_id if is_character else None, + entity.gender if is_character else None, + entity.blood_percentage if is_character else None, + ), + ) + if isinstance(entity, Character): + for is_default, parts in ((0, entity.body_parts), (1, entity.default_body_parts)): + for part in parts: + cur.execute( + "INSERT INTO character_body_parts (entity_id, is_default, slot, part_def_id, integrity, removed)" + " VALUES (?, ?, ?, ?, ?, ?)", + (entity.entity_id, is_default, part.slot, part.part_def_id, part.integrity, int(part.removed)), + ) + for ailment in part.ailments: + cur.execute( + "INSERT INTO character_body_part_ailments" + " (entity_id, is_default, slot, ailment_id, name, severity) VALUES (?, ?, ?, ?, ?, ?)", + (entity.entity_id, is_default, part.slot, ailment.id, ailment.name, ailment.severity), + ) + for is_default, pieces in ((0, entity.clothing), (1, entity.default_clothing)): + for piece in pieces: + cur.execute( + "INSERT INTO character_clothing (entity_id, is_default, slot, item_def_id, removed)" + " VALUES (?, ?, ?, ?, ?)", + (entity.entity_id, is_default, piece.slot, piece.item_def_id, int(piece.removed)), + ) + if piece.inventory is not None: + self._save_inventory( + cur, + f"inv-{entity.entity_id}-clothing-{is_default}-{piece.slot}", + entity.entity_id, + piece.inventory, + owner_clothing=(is_default, piece.slot), + ) + if entity.mental_stats is not None: + for stat, value in entity.mental_stats.items(): + cur.execute( + "INSERT INTO character_mental_stats (entity_id, stat, value) VALUES (?, ?, ?)", + (entity.entity_id, stat, value), + ) + for skill, level in entity.bio.items(): + cur.execute( + "INSERT INTO character_bio (entity_id, skill, level) VALUES (?, ?, ?)", + (entity.entity_id, skill, level), + ) + + if entity.inventory is not None: + self._save_inventory(cur, f"inv-{entity.entity_id}", entity.entity_id, entity.inventory) + + def _save_inventory( + self, + cur: sqlite3.Cursor, + inventory_id: str, + owner_entity_id: str, + inventory: Inventory, + owner_clothing: tuple[int, str] | None = None, + ) -> None: + owner_clothing_is_default, owner_clothing_slot = owner_clothing if owner_clothing else (None, None) + cur.execute( + "INSERT INTO inventories (inventory_id, owner_entity_id, owner_clothing_is_default, owner_clothing_slot, width, height)" + " VALUES (?, ?, ?, ?, ?, ?)", + (inventory_id, owner_entity_id, owner_clothing_is_default, owner_clothing_slot, inventory.width, inventory.height), + ) + for placed in inventory.items(): + cur.execute( + "INSERT INTO inventory_items (item_id, inventory_id, def_id, quantity, x, y)" + " VALUES (?, ?, ?, ?, ?, ?)", + (placed.item.item_id, inventory_id, placed.item.def_id, placed.item.quantity, placed.x, placed.y), + ) + + def load_world(self, registry: DefRegistry, map_loader: MapLoader) -> World | None: + cur = self.conn.cursor() + map_rows = cur.execute("SELECT map_id, source_def FROM maps").fetchall() + if not map_rows: + return None + + world = World(registry=registry) + for map_id, source_def in map_rows: + m = map_loader.load(source_def) + assert m.map_id == map_id + world.add_map(m) + + for map_id, x, y, z, subfloor, subfloor_i, flooring, flooring_i, room, room_i, roof, roof_i in cur.execute( + "SELECT map_id, x, y, z, subfloor, subfloor_integrity, flooring, flooring_integrity," + " room, room_integrity, roof, roof_integrity FROM tile_overrides" + ): + m = world.maps[map_id] + m.set_tile( + x, + y, + z, + Tile( + subfloor=TileLayerInstance(subfloor, subfloor_i) if subfloor else None, + flooring=TileLayerInstance(flooring, flooring_i) if flooring else None, + room=TileLayerInstance(room, room_i) if room else None, + roof=TileLayerInstance(roof, roof_i) if roof else None, + ), + ) + + entity_rows = cur.execute( + "SELECT entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage FROM entities" + ).fetchall() + for entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage in entity_rows: + position = EntityPosition(map_id, x, y, z) + if kind == "character": + entity: Entity = self._load_character( + cur, registry, entity_id, def_id, name, position, species_id, gender, blood_percentage + ) + else: + entity = Entity(entity_id=entity_id, name=name, def_id=def_id, position=position) + + inv_row = cur.execute( + "SELECT inventory_id, width, height FROM inventories" + " WHERE owner_entity_id = ? AND owner_clothing_slot IS NULL", + (entity_id,), + ).fetchone() + if inv_row is not None: + inventory_id, width, height = inv_row + entity.inventory = self._load_inventory(cur, inventory_id, width, height, registry) + + world.add_entity(entity) + + player_row = cur.execute("SELECT value FROM meta WHERE key = 'player_entity_id'").fetchone() + if player_row is not None: + world.player = world.entities.get(player_row[0]) + + return world + + def _load_inventory(self, cur: sqlite3.Cursor, inventory_id: str, width: int, height: int, registry: DefRegistry) -> Inventory: + inventory = Inventory(width, height) + rows = cur.execute( + "SELECT item_id, def_id, quantity, x, y FROM inventory_items WHERE inventory_id = ?", + (inventory_id,), + ).fetchall() + for item_id, def_id, quantity, ix, iy in rows: + item_def = registry.get_item_def(def_id) + inventory.place(ItemInstance(item_id, def_id, quantity), item_def, ix, iy) + return inventory + + def _load_character( + self, cur, registry: DefRegistry, entity_id, def_id, name, position, species_id, gender, blood_percentage + ) -> Character: + body_parts: list[BodyPart] = [] + default_body_parts: list[BodyPart] = [] + part_rows = cur.execute( + "SELECT is_default, slot, part_def_id, integrity, removed FROM character_body_parts WHERE entity_id = ?", + (entity_id,), + ).fetchall() + for is_default, slot, part_def_id, integrity, removed in part_rows: + part = BodyPart(slot=slot, part_def_id=part_def_id, integrity=integrity, removed=bool(removed)) + ailment_rows = cur.execute( + "SELECT ailment_id, name, severity FROM character_body_part_ailments" + " WHERE entity_id = ? AND is_default = ? AND slot = ?", + (entity_id, is_default, slot), + ).fetchall() + for ailment_id, ailment_name, severity in ailment_rows: + part.ailments.append(Ailment(id=ailment_id, name=ailment_name, severity=severity)) + (default_body_parts if is_default else body_parts).append(part) + + clothing: list[ClothingPiece] = [] + default_clothing: list[ClothingPiece] = [] + clothing_rows = cur.execute( + "SELECT is_default, slot, item_def_id, removed FROM character_clothing WHERE entity_id = ?", + (entity_id,), + ).fetchall() + for is_default, slot, item_def_id, removed in clothing_rows: + piece = ClothingPiece(slot=slot, item_def_id=item_def_id, removed=bool(removed)) + inv_row = cur.execute( + "SELECT inventory_id, width, height FROM inventories" + " WHERE owner_entity_id = ? AND owner_clothing_is_default = ? AND owner_clothing_slot = ?", + (entity_id, is_default, slot), + ).fetchone() + if inv_row is not None: + inventory_id, width, height = inv_row + piece.inventory = self._load_inventory(cur, inventory_id, width, height, registry) + (default_clothing if is_default else clothing).append(piece) + + mental_stats = { + stat: value + for stat, value in cur.execute( + "SELECT stat, value FROM character_mental_stats WHERE entity_id = ?", (entity_id,) + ) + } or None + + bio = { + skill: level + for skill, level in cur.execute( + "SELECT skill, level FROM character_bio WHERE entity_id = ?", (entity_id,) + ) + } + + return Character( + entity_id=entity_id, + name=name, + def_id=def_id, + position=position, + species_id=species_id, + gender=gender, + blood_percentage=blood_percentage if blood_percentage is not None else 100.0, + body_parts=body_parts, + default_body_parts=default_body_parts, + clothing=clothing, + default_clothing=default_clothing, + mental_stats=mental_stats, + bio=bio, + ) diff --git a/engine/defs.py b/engine/defs.py new file mode 100644 index 0000000..bddc0d3 --- /dev/null +++ b/engine/defs.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from engine.character import BodyPart, BodyPartDef, ClothingPiece, Organ, OrganDef +from engine.item import ItemDef +from engine.species import AbilityDef, SpeciesDef +from engine.tile import TILE_LAYERS, TileLayerDef + + +@dataclass(frozen=True) +class EntityDef: + """A spawn template: who to make (species, for characters), starting clothing, and starting inventory size.""" + + id: str + name: str + species_id: str | None = None + default_clothing: list[ClothingPiece] = field(default_factory=list) + inventory_size: tuple[int, int] | None = None + + +class DefRegistry: + def __init__(self): + self.tile_layer_defs: dict[tuple[str, str], TileLayerDef] = {} + self.item_defs: dict[str, ItemDef] = {} + self.body_part_defs: dict[str, BodyPartDef] = {} + self.organ_defs: dict[str, OrganDef] = {} + self.species_defs: dict[str, SpeciesDef] = {} + self.entity_defs: dict[str, EntityDef] = {} + + @classmethod + def load(cls, defs_dir: Path) -> "DefRegistry": + registry = cls() + registry._load_tiles(defs_dir / "tiles.json") + registry._load_items(defs_dir / "items.json") + registry._load_body_parts(defs_dir / "body_parts.json") + registry._load_organs(defs_dir / "organs.json") + registry._load_species(defs_dir / "species.json") + registry._load_entities(defs_dir / "entities.json") + return registry + + def _load_tiles(self, path: Path) -> None: + data = json.loads(path.read_text()) + for layer in TILE_LAYERS: + for def_id, fields in data.get(layer, {}).items(): + self.tile_layer_defs[(layer, def_id)] = TileLayerDef( + id=def_id, + layer=layer, + texture=fields.get("texture"), + color=tuple(fields.get("color", (255, 0, 255))), + walkable=fields.get("walkable", True), + blocks_los=fields.get("blocks_los", False), + deconstruct_item_id=fields.get("deconstruct_item_id"), + is_helm=fields.get("is_helm", False), + staircase_direction=fields.get("staircase_direction"), + ) + + def _load_items(self, path: Path) -> None: + data = json.loads(path.read_text()) + for def_id, fields in data.items(): + self.item_defs[def_id] = ItemDef( + id=def_id, + name=fields["name"], + width=fields["width"], + height=fields["height"], + texture=fields.get("texture"), + color=tuple(fields.get("color", (255, 0, 255))), + stackable=fields.get("stackable", False), + max_stack=fields.get("max_stack", 1), + clothing_slot=fields.get("clothing_slot"), + grants_inventory_size=( + tuple(fields["grants_inventory_size"]) if "grants_inventory_size" in fields else None + ), + armor_reduction=fields.get("armor_reduction", 0.0), + arm_slots_required=fields.get("arm_slots_required", 0), + compatible_body_types=tuple(fields.get("compatible_body_types", ())), + weapon_skill=fields.get("weapon_skill"), + weapon_damage=fields.get("weapon_damage", 0.0), + weapon_range=fields.get("weapon_range", 1), + projectile_type=fields.get("projectile_type"), + hands_required=fields.get("hands_required", 1), + shield_block_bonus=fields.get("shield_block_bonus", 0.0), + blockable_projectile_types=tuple(fields.get("blockable_projectile_types", ())), + tool_kind=fields.get("tool_kind"), + builds_tile_layer=fields.get("builds_tile_layer"), + builds_tile_layer_def_id=fields.get("builds_tile_layer_def_id"), + ammo_type=fields.get("ammo_type"), + ranged_knockback=fields.get("ranged_knockback", 0.0), + compatible_ammo_types=tuple(fields.get("compatible_ammo_types", ())), + magazine_size=fields.get("magazine_size", 0), + aim_cone_degrees=fields.get("aim_cone_degrees", 0.0), + pellet_count=fields.get("pellet_count", 1), + blast_radius=fields.get("blast_radius", 0), + attachment_kind=fields.get("attachment_kind"), + aimcone_reduction_degrees=fields.get("aimcone_reduction_degrees", 0.0), + magazine_capacity_bonus=fields.get("magazine_capacity_bonus", 0), + melee_knockback=fields.get("melee_knockback", 0.0), + grants_ability=fields.get("grants_ability"), + is_implant=fields.get("is_implant", False), + ) + + def _load_body_parts(self, path: Path) -> None: + data = json.loads(path.read_text()) + for def_id, fields in data.items(): + self.body_part_defs[def_id] = BodyPartDef( + id=def_id, + slot=fields["slot"], + texture=fields.get("texture"), + color=tuple(fields.get("color", (255, 0, 255))), + bleeding_weight=fields.get("bleeding_weight", 0.0), + bleeding_speed=fields.get("bleeding_speed", 0.0), + skill_weights=dict(fields.get("skill_weights", {})), + speed_factor=fields.get("speed_factor", 0.0), + ) + + def _load_organs(self, path: Path) -> None: + data = json.loads(path.read_text()) + for def_id, fields in data.items(): + self.organ_defs[def_id] = OrganDef( + id=def_id, + name=fields.get("name", def_id), + host_slot=fields["host_slot"], + check_weights=dict(fields.get("check_weights", {})), + affects_organs=dict(fields.get("affects_organs", {})), + ) + + def _load_species(self, path: Path) -> None: + data = json.loads(path.read_text()) + for def_id, fields in data.items(): + default_parts = [ + BodyPart(slot=self.body_part_defs[part_id].slot, part_def_id=part_id) + for part_id in fields.get("default_body_parts", []) + ] + abilities = [ + AbilityDef( + id=ability["id"], + name=ability.get("name", ability["id"]), + required_slots=tuple(ability.get("required_slots", ())), + ) + for ability in fields.get("abilities", []) + ] + self.species_defs[def_id] = SpeciesDef( + id=def_id, + name=fields.get("name", def_id), + genders=list(fields.get("genders", [])), + default_body_parts=default_parts, + default_organs=list(fields.get("default_organs", [])), + body_type=fields.get("body_type", "biped"), + abilities=abilities, + blood_danger_threshold=fields.get("blood_danger_threshold", 50.0), + blood_critical_threshold=fields.get("blood_critical_threshold", 25.0), + health_item_compatibility=list(fields.get("health_item_compatibility", [])), + ) + + def _load_entities(self, path: Path) -> None: + data = json.loads(path.read_text()) + for def_id, fields in data.items(): + inventory_size = fields.get("inventory_size") + default_clothing = [ + ClothingPiece(slot=self.item_defs[item_id].clothing_slot, item_def_id=item_id) + for item_id in fields.get("default_clothing", []) + ] + self.entity_defs[def_id] = EntityDef( + id=def_id, + name=fields.get("name", def_id), + species_id=fields.get("species_id"), + default_clothing=default_clothing, + inventory_size=tuple(inventory_size) if inventory_size else None, + ) + + def get_tile_layer_def(self, layer: str, def_id: str | None) -> TileLayerDef | None: + if def_id is None: + return None + return self.tile_layer_defs.get((layer, def_id)) + + def get_item_def(self, def_id: str) -> ItemDef: + return self.item_defs[def_id] + + def get_body_part_def(self, def_id: str) -> BodyPartDef: + return self.body_part_defs[def_id] + + def get_organ_def(self, def_id: str) -> OrganDef: + return self.organ_defs[def_id] + + def get_species_def(self, def_id: str) -> SpeciesDef: + return self.species_defs[def_id] + + def get_entity_def(self, def_id: str) -> EntityDef: + return self.entity_defs[def_id] + + def new_default_body_parts(self, species_id: str) -> list[BodyPart]: + """Fresh BodyPart instances copied from a species' defaults, safe to mutate per-character. + + Organs are placed per the species' own explicit default_organs list (not by scanning + every loaded organ def for a host_slot match) - a species declares exactly which organs + it has, same as it declares exactly which body parts it has. This matters once species + stop sharing anatomy: e.g. a robotic species with a "torso" slot must NOT automatically + inherit an organic heart/lungs/liver/kidneys just because they happen to host there. + Each declared organ is still placed into whichever body part its own OrganDef.host_slot + names. + """ + species = self.get_species_def(species_id) + organs_by_slot: dict[str, list[Organ]] = {} + for organ_def_id in species.default_organs: + organ_def = self.organ_defs[organ_def_id] + organs_by_slot.setdefault(organ_def.host_slot, []).append(Organ(organ_def_id=organ_def_id)) + return [ + BodyPart( + slot=p.slot, + part_def_id=p.part_def_id, + organs=organs_by_slot.get(p.slot, []), + ) + for p in species.default_body_parts + ] + + def new_default_clothing(self, entity_def_id: str) -> list[ClothingPiece]: + """Fresh ClothingPiece instances copied from an entity template's starting outfit, safe to mutate per-character.""" + return [ + ClothingPiece(slot=p.slot, item_def_id=p.item_def_id) + for p in self.get_entity_def(entity_def_id).default_clothing + ] diff --git a/engine/entity.py b/engine/entity.py new file mode 100644 index 0000000..0cd2e20 --- /dev/null +++ b/engine/entity.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import itertools +from dataclasses import dataclass + +_id_counter = itertools.count(1) + + +def generate_entity_id(prefix: str = "entity") -> str: + return f"{prefix}-{next(_id_counter)}" + + +@dataclass +class EntityPosition: + """x/y/z are continuous (not tile-locked); z stays layer-like (whole numbers by convention). + + Which tile a continuous position falls in is always floor(x), floor(y) - see Map.get_tile. + """ + + map_id: str + x: float + y: float + z: float + + +class Entity: + """Base primitive for all non-tile things in the world.""" + + def __init__( + self, + entity_id: str | None = None, + name: str = "", + def_id: str | None = None, + position: EntityPosition | None = None, + ): + self.entity_id = entity_id or generate_entity_id() + self.name = name + self.def_id = def_id + self.position = position + self.inventory = None # Inventory | None — assignable to any Entity + + def update(self, dt: float) -> None: + pass diff --git a/engine/gamepad.py b/engine/gamepad.py new file mode 100644 index 0000000..23eea8b --- /dev/null +++ b/engine/gamepad.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import configparser +from dataclasses import dataclass, field +from pathlib import Path + +# Standard GLFW gamepad button indices (glfwGetGamepadState order) mapped to config-friendly +# names, so the .conf format matches KeyBindings' "action = token, token" style exactly. +GAMEPAD_BUTTON_NAMES: dict[int, str] = { + 0: "gp_a", + 1: "gp_b", + 2: "gp_x", + 3: "gp_y", + 4: "gp_left_bumper", + 5: "gp_right_bumper", + 6: "gp_back", + 7: "gp_start", + 8: "gp_guide", + 9: "gp_left_thumb", + 10: "gp_right_thumb", + 11: "gp_dpad_up", + 12: "gp_dpad_right", + 13: "gp_dpad_down", + 14: "gp_dpad_left", +} + +# Standard GLFW gamepad axis indices. The triggers are analog axes (not buttons) in GLFW's +# standard mapping - GamepadState.digital_buttons() turns them into gp_left_trigger/ +# gp_right_trigger pseudo-buttons via TRIGGER_PRESS_THRESHOLD, so bindings only ever deal with +# named buttons, never raw axis indices. +AXIS_LEFT_X = 0 +AXIS_LEFT_Y = 1 +AXIS_RIGHT_X = 2 +AXIS_RIGHT_Y = 3 +AXIS_LEFT_TRIGGER = 4 +AXIS_RIGHT_TRIGGER = 5 + +TRIGGER_PRESS_THRESHOLD = 0.5 +DEFAULT_STICK_DEADZONE = 0.2 + +DEFAULT_GAMEPAD_BINDINGS: dict[str, list[str]] = { + "leap": ["gp_a"], + "block": ["gp_left_bumper"], + "activate_right_hand": ["gp_right_bumper"], + "activate_left_hand": ["gp_x"], + "activate_right_hand_2": ["gp_right_trigger"], + "activate_left_hand_2": ["gp_left_trigger"], +} + + +def apply_deadzone(value: float, deadzone: float = DEFAULT_STICK_DEADZONE) -> float: + return 0.0 if abs(value) < deadzone else value + + +@dataclass +class GamepadState: + """One frame's snapshot of a gamepad, decoupled from any actual hardware/glfw call so the + binding/mapping logic is fully unit-testable. main.py is responsible for the one-line glue + that turns a real glfw.get_gamepad_state(...) result into this shape each frame - see + GamepadState.from_glfw. + """ + + buttons: dict[str, bool] = field(default_factory=dict) # gp_* name -> pressed + axes: list[float] = field(default_factory=lambda: [0.0] * 6) + + def axis(self, index: int) -> float: + return self.axes[index] if index < len(self.axes) else 0.0 + + def digital_buttons(self) -> set[str]: + """Every currently-pressed gp_* name, including the triggers thresholded into + pseudo-buttons (gp_left_trigger/gp_right_trigger) alongside the real digital buttons. + """ + pressed = {name for name, is_down in self.buttons.items() if is_down} + if self.axis(AXIS_LEFT_TRIGGER) > TRIGGER_PRESS_THRESHOLD: + pressed.add("gp_left_trigger") + if self.axis(AXIS_RIGHT_TRIGGER) > TRIGGER_PRESS_THRESHOLD: + pressed.add("gp_right_trigger") + return pressed + + @classmethod + def from_glfw(cls, raw) -> "GamepadState": + """Wraps a glfw.get_gamepad_state(...) result (a _GLFWgamepadstate with .buttons/.axes + sequences indexed exactly like GAMEPAD_BUTTON_NAMES/AXIS_*) into this hardware-agnostic + shape. Never exercised by tests (no hardware in CI) - kept intentionally thin so the + only untested code is "read the raw struct", not any actual logic. + """ + buttons = {name: bool(raw.buttons[index]) for index, name in GAMEPAD_BUTTON_NAMES.items()} + axes = list(raw.axes) + return cls(buttons=buttons, axes=axes) + + +class GamepadBindings: + """Action -> list of gp_* button names, loaded from an editable .conf file - same array- + per-action format and fallback behavior as engine.config.KeyBindings, just for gamepad + buttons instead of keys/mouse buttons. + """ + + def __init__(self, bindings: dict[str, list[str]]): + self.bindings = bindings + self._action_by_button: dict[str, str] = {} + for action, buttons in bindings.items(): + for button in buttons: + self._action_by_button[button] = action + + @classmethod + def load(cls, path: Path) -> "GamepadBindings": + bindings = {action: list(buttons) for action, buttons in DEFAULT_GAMEPAD_BINDINGS.items()} + if path.exists(): + parser = configparser.ConfigParser() + parser.read(path) + for section in parser.sections(): + for action, raw in parser.items(section): + buttons = [b.strip() for b in raw.split(",") if b.strip()] + if buttons: + bindings[action] = buttons + return cls(bindings) + + def action_for_button(self, button: str) -> str | None: + return self._action_by_button.get(button) + + def buttons_for_action(self, action: str) -> list[str]: + return self.bindings.get(action, []) diff --git a/engine/input.py b/engine/input.py new file mode 100644 index 0000000..5e93d1a --- /dev/null +++ b/engine/input.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from engine.config import DEFAULT_KEYBINDINGS, KeyBindings +from engine.gamepad import AXIS_LEFT_X, AXIS_LEFT_Y, GamepadBindings, GamepadState, apply_deadzone + +MOVE_ACTION_VECTORS: dict[str, tuple[int, int, int]] = { + "move_up": (0, -1, 0), + "move_down": (0, 1, 0), + "move_left": (-1, 0, 0), + "move_right": (1, 0, 0), +} + +# Mouse-button-driven "which hand acts" scheme: left/right click for the primary pair of +# hands, shift+left/right for a second pair (only meaningful for a 4-armed species; wield_item +# already refuses to use a hand slot with no corresponding arm, so this degrades safely). +HAND_ACTIVATION_ACTIONS: dict[str, str] = { + "activate_right_hand": "right_hand", + "activate_left_hand": "left_hand", + "activate_right_hand_2": "right_hand_2", + "activate_left_hand_2": "left_hand_2", +} + +# jupyter_rfb/rendercanvas pointer button numbering (DOM-style): 0 = left, 2 = right. +MOUSE_BUTTON_KEYS: dict[int, str] = {0: "mouse_left", 2: "mouse_right"} + +# Numkey hotkeys for the ability bar (engine/ui.py::AbilityBar) - "0" conventionally maps to +# the 10th slot (index 9), matching the usual 1-9,0 hotbar ordering. +SELECT_ABILITY_ACTIONS: dict[str, int] = { + "select_ability_1": 0, + "select_ability_2": 1, + "select_ability_3": 2, + "select_ability_4": 3, + "select_ability_5": 4, + "select_ability_6": 5, + "select_ability_7": 6, + "select_ability_8": 7, + "select_ability_9": 8, + "select_ability_0": 9, +} + +DEFAULT_FACING: tuple[int, int, int] = (0, 1, 0) + + +class InputState: + """Discrete key/mouse-driven movement and actions (one press = one step or state toggle), + resolved through a KeyBindings config so any action can be bound to multiple inputs. Also + tracks raw pointer position for continuous mouse aiming (see engine/aim.py). + """ + + def __init__(self, keybindings: KeyBindings | None = None): + self.keybindings = keybindings or KeyBindings(dict(DEFAULT_KEYBINDINGS)) + self.pending_move: tuple[int, int, int] | None = None + self.held_move_actions: set[str] = set() + self.pending_leap: tuple[int, int, int] | None = None + self.pending_hand_activation: str | None = None + self.pending_ability_select: int | None = None + self.is_blocking = False + self.facing: tuple[int, int, int] = DEFAULT_FACING + self.pointer_pos: tuple[float, float] | None = None + self.gamepad_move_vector: tuple[float, float] = (0.0, 0.0) + self._prev_gamepad_buttons: set[str] = set() + + def handle_event(self, event: dict) -> None: + event_type = event.get("event_type") + + if event_type == "pointer_move": + self.pointer_pos = (event.get("x", 0.0), event.get("y", 0.0)) + return + + if event_type == "pointer_down": + self._dispatch_down(MOUSE_BUTTON_KEYS.get(event.get("button", -1)), event.get("modifiers", ())) + return + + if event_type not in ("key_down", "key_up"): + return + + key = event.get("key", "") + action = self._resolve_action(key, event.get("modifiers", ())) + if action is None: + return + if event_type == "key_down": + self._apply_down(action) + else: + self._apply_up(action) + + def _resolve_action(self, key: str, modifiers) -> str | None: + if key and "Shift" in modifiers: + action = self.keybindings.action_for_key(f"shift+{key}") + if action is not None: + return action + return self.keybindings.action_for_key(key) + + def _dispatch_down(self, mouse_key: str | None, modifiers) -> None: + if mouse_key is None: + return + action = self._resolve_action(mouse_key, modifiers) + if action is not None: + self._apply_down(action) + + def _apply_down(self, action: str) -> None: + if action in MOVE_ACTION_VECTORS: + move = MOVE_ACTION_VECTORS[action] + self.pending_move = move # one-shot compat, e.g. for a discrete-step caller + self.held_move_actions.add(action) + self.facing = move + elif action == "leap": + self.pending_leap = self.facing + elif action == "block": + self.is_blocking = True + elif action in HAND_ACTIVATION_ACTIONS: + self.pending_hand_activation = HAND_ACTIVATION_ACTIONS[action] + elif action in SELECT_ABILITY_ACTIONS: + self.pending_ability_select = SELECT_ABILITY_ACTIONS[action] + + def _apply_up(self, action: str) -> None: + if action in MOVE_ACTION_VECTORS: + self.held_move_actions.discard(action) + elif action == "block": + self.is_blocking = False + + def apply_gamepad_state(self, state: GamepadState, bindings: GamepadBindings) -> None: + """Feeds one frame's polled gamepad snapshot through the same action dispatch as + keyboard/mouse events. Unlike glfw's edge-triggered key_down/key_up callbacks, a + gamepad is polled every frame, so button transitions have to be detected here by + diffing against the previous frame's pressed set. + """ + pressed = state.digital_buttons() + for button in pressed - self._prev_gamepad_buttons: + action = bindings.action_for_button(button) + if action is not None: + self._apply_down(action) + for button in self._prev_gamepad_buttons - pressed: + action = bindings.action_for_button(button) + if action is not None: + self._apply_up(action) + self._prev_gamepad_buttons = pressed + + self.gamepad_move_vector = ( + apply_deadzone(state.axis(AXIS_LEFT_X)), + apply_deadzone(state.axis(AXIS_LEFT_Y)), + ) + + def consume_move(self) -> tuple[int, int, int] | None: + move, self.pending_move = self.pending_move, None + return move + + def current_move_vector(self) -> tuple[float, float, float]: + """The combined direction of all currently-held movement keys plus the left stick's + deadzoned analog offset (for continuous movement) - the two sources simply add, so + keyboard and gamepad work either standalone or together. + + Holding both an up/down and left/right key gives a diagonal vector - not normalized + here (World.try_move_continuous normalizes it), so callers get the raw combination. + """ + dx = dy = 0.0 + for action in self.held_move_actions: + vx, vy, _ = MOVE_ACTION_VECTORS[action] + dx += vx + dy += vy + dx += self.gamepad_move_vector[0] + dy += self.gamepad_move_vector[1] + return (dx, dy, 0.0) + + def consume_leap(self) -> tuple[int, int, int] | None: + leap, self.pending_leap = self.pending_leap, None + return leap + + def consume_hand_activation(self) -> str | None: + hand, self.pending_hand_activation = self.pending_hand_activation, None + return hand + + def consume_ability_select(self) -> int | None: + index, self.pending_ability_select = self.pending_ability_select, None + return index diff --git a/engine/inventory.py b/engine/inventory.py new file mode 100644 index 0000000..e8129b8 --- /dev/null +++ b/engine/inventory.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from engine.item import ItemDef, ItemInstance + + +@dataclass +class PlacedItem: + item: ItemInstance + x: int + y: int + + +class Inventory: + """A grid of cells that items occupy according to their w x h footprint.""" + + def __init__(self, width: int, height: int): + self.width = width + self.height = height + self._cells: list[str | None] = [None] * (width * height) + self._items: dict[str, PlacedItem] = {} + + def _footprint(self, item_def: ItemDef, x: int, y: int) -> list[tuple[int, int]]: + return [(x + dx, y + dy) for dy in range(item_def.height) for dx in range(item_def.width)] + + def can_place(self, item_def: ItemDef, x: int, y: int) -> bool: + for cx, cy in self._footprint(item_def, x, y): + if not (0 <= cx < self.width and 0 <= cy < self.height): + return False + if self._cells[cy * self.width + cx] is not None: + return False + return True + + def place(self, item: ItemInstance, item_def: ItemDef, x: int, y: int) -> bool: + if not self.can_place(item_def, x, y): + return False + for cx, cy in self._footprint(item_def, x, y): + self._cells[cy * self.width + cx] = item.item_id + self._items[item.item_id] = PlacedItem(item, x, y) + return True + + def remove(self, item_id: str, item_def: ItemDef) -> ItemInstance | None: + placed = self._items.pop(item_id, None) + if placed is None: + return None + for cx, cy in self._footprint(item_def, placed.x, placed.y): + self._cells[cy * self.width + cx] = None + return placed.item + + def find_first_fit(self, item_def: ItemDef) -> tuple[int, int] | None: + for y in range(self.height - item_def.height + 1): + for x in range(self.width - item_def.width + 1): + if self.can_place(item_def, x, y): + return (x, y) + return None + + def items(self) -> list[PlacedItem]: + return list(self._items.values()) diff --git a/engine/item.py b/engine/item.py new file mode 100644 index 0000000..37ad003 --- /dev/null +++ b/engine/item.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ItemDef: + id: str + name: str + width: int + height: int + texture: str | None = None + color: tuple[int, int, int] = (255, 0, 255) + stackable: bool = False + max_stack: int = 1 + clothing_slot: str | None = None # set if this item can be worn (e.g. "torso", "back") + grants_inventory_size: tuple[int, int] | None = None # e.g. a backpack, when worn + armor_reduction: float = 0.0 # flat damage reduction while worn (clothing) + arm_slots_required: int = 0 # sleeved garments only (e.g. a jacket): 0 = no arm-count + # constraint (hats, backpacks, pants, ...); a garment with more sleeves than the wearer + # has arms still fits (extras go unused), fewer does not - see Character.wear_item + compatible_body_types: tuple[str, ...] = () # e.g. ("biped",), ("quadruped",) - which + # SpeciesDef.body_type values this item can be worn by at all; empty = fits any body type + weapon_skill: str | None = None # bio skill this weapon's attacks are checked against + weapon_damage: float = 0.0 # flat bonus added on a successful skill check + weapon_range: int = 1 # tiles; 1 = melee (adjacent), >1 = ranged (used by shooting) + projectile_type: str | None = None # e.g. "bullet", "arrow" - what a shield needs to block it + hands_required: int = 1 # 1 or 2; two-handed items occupy a hand pair when wielded + shield_block_bonus: float = 0.0 # bonus added to the defender's blocking skill check + blockable_projectile_types: tuple[str, ...] = () # projectile_types this shield stops + tool_kind: str | None = None # e.g. "deconstructor", "constructor" + builds_tile_layer: str | None = None # which layer this material can construct (used with a constructor tool) + builds_tile_layer_def_id: str | None = None # which def gets placed there + ammo_type: str | None = None # set on ammo items: "small_caliber", "heavy_caliber", "shotgun_shell", ... + ranged_knockback: float = 0.0 # ammo items: tiles of knockback dealt on impact + ammo_damage_bonus: float = 0.0 # ammo items: extra flat damage added to the weapon's own bonus + compatible_ammo_types: tuple[str, ...] = () # ammo-based weapons: which ammo_types they accept + magazine_size: int = 0 # ammo-based weapons: base capacity (0 = not ammo-fed, e.g. bow/pistol_basic) + aim_cone_degrees: float = 0.0 # ranged weapons: base spread; 0 = perfectly accurate + pellet_count: int = 1 # shotgun-style weapons: independent projectiles fired per shot + blast_radius: int = 0 # rocket-style weapons: AoE radius in tiles (0 = no AoE) + attachment_kind: str | None = None # e.g. "scope", "stock", "magazine" + aimcone_reduction_degrees: float = 0.0 # attachment effect + magazine_capacity_bonus: int = 0 # attachment effect (e.g. an extended mag) + melee_knockback: float = 0.0 # melee weapons: base knockback tiles, scaled by attacker strength + grants_ability: str | None = None # e.g. "time_warp_sphere" - triggered on activation, see resources/logic/abilities.py + is_implant: bool = False # slotless: installed directly on the character, not held/worn + + +class ItemInstance: + def __init__(self, item_id: str, def_id: str, quantity: int = 1): + self.item_id = item_id + self.def_id = def_id + self.quantity = quantity diff --git a/engine/map.py b/engine/map.py new file mode 100644 index 0000000..eb9b794 --- /dev/null +++ b/engine/map.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import math + +from engine.tile import Tile + + +def rotate_point(x: int, y: int, width: int, height: int, rotation: int) -> tuple[int, int]: + """Rotates (x, y) within a width x height grid by `rotation` quarter turns (clockwise). + + The result is expressed in the rotated bounding box's own coordinate space (still + starting at (0, 0)). Use rotated_size() for that box's new width/height. + """ + rotation %= 4 + if rotation == 0: + return x, y + if rotation == 1: + return height - 1 - y, x + if rotation == 2: + return width - 1 - x, height - 1 - y + return y, width - 1 - x + + +def rotate_vector(dx: int, dy: int, quarter_turns: int) -> tuple[int, int]: + """Rotates a free direction vector (not bound to a grid) by quarter turns (clockwise). + + Unlike rotate_point, there's no bounding box to stay within - this is for things like a + projectile's ricochet direction, not repositioning a point inside a map's own grid. + """ + quarter_turns %= 4 + if quarter_turns == 0: + return dx, dy + if quarter_turns == 1: + return -dy, dx + if quarter_turns == 2: + return -dx, -dy + return dy, -dx + + +def rotate_position(x: float, y: float, width: float, height: float, rotation: int) -> tuple[float, float]: + """Continuous analogue of rotate_point, for entity positions (not tile indices) crossing a + rotated embedding boundary. Drops the "-1" rotate_point uses: that offset reflects a + *discrete* grid of {0, ..., width-1} cells, whereas a continuous position spans the + continuum [0, width], so the reflection point is width/height themselves, not width-1/height-1. + """ + rotation %= 4 + if rotation == 0: + return x, y + if rotation == 1: + return height - y, x + if rotation == 2: + return width - x, height - y + return y, width - x + + +def rotated_size(width: int, height: int, rotation: int) -> tuple[int, int]: + return (height, width) if rotation % 2 == 1 else (width, height) + + +class MapEmbedding: + """Joins a child Map into a parent Map via an anchor + quarter-turn rotation. + + Both anchor and rotation are plain mutable attributes (see move_to/rotate_to) so an + embedded map (e.g. a ship) can move and turn live. Entities already aboard keep their + position expressed in the child map's own local coordinates, so moving/turning the ship + never needs to touch them - only the transform used to cross its boundary changes. + Translating an out-of-range local coordinate correctly yields the parent-space cell just + outside the child's (rotated) footprint, which is what lets World.try_move cross the + boundary in both directions with one code path, at any anchor/rotation. + """ + + def __init__(self, parent_map: "Map", child_map: "Map", anchor: tuple[int, int, int], rotation: int = 0): + self.parent_map = parent_map + self.child_map = child_map + self.anchor = anchor + self.rotation = rotation % 4 + + def footprint_size(self) -> tuple[int, int]: + return rotated_size(self.child_map.width, self.child_map.height, self.rotation) + + def local_to_parent(self, x: int, y: int, z: int) -> tuple[int, int, int]: + rx, ry = rotate_point(x, y, self.child_map.width, self.child_map.height, self.rotation) + ax, ay, az = self.anchor + return (ax + rx, ay + ry, az + z) + + def parent_to_local(self, px: int, py: int, pz: int) -> tuple[int, int, int] | None: + ax, ay, az = self.anchor + rx, ry, rz = px - ax, py - ay, pz - az + fw, fh = self.footprint_size() + if not (0 <= rx < fw and 0 <= ry < fh and 0 <= rz < self.child_map.depth): + return None + x, y = rotate_point(rx, ry, fw, fh, (4 - self.rotation) % 4) + return (x, y, rz) + + def local_to_parent_pos(self, x: float, y: float, z: float) -> tuple[float, float, float]: + """Continuous-position counterpart of local_to_parent, for entities (not tile lookups).""" + rx, ry = rotate_position(x, y, self.child_map.width, self.child_map.height, self.rotation) + ax, ay, az = self.anchor + return (ax + rx, ay + ry, az + z) + + def parent_to_local_pos(self, px: float, py: float, pz: float) -> tuple[float, float, float]: + """Continuous-position counterpart of parent_to_local. Unlike parent_to_local, this + doesn't bounds-check (the caller already knows the tile is inside the footprint via + embedding_at) - it just carries the fractional offset through the rotation correctly. + """ + ax, ay, az = self.anchor + rx, ry, rz = px - ax, py - ay, pz - az + fw, fh = self.footprint_size() + x, y = rotate_position(rx, ry, fw, fh, (4 - self.rotation) % 4) + return (x, y, rz) + + def move_to(self, anchor: tuple[int, int, int]) -> None: + """Live-relocate the embedded map (e.g. a ship sailing). Aboard entities need no update.""" + self.anchor = anchor + + def rotate_to(self, rotation: int) -> None: + """Turn the embedded map to a new quarter-turn orientation (0-3, clockwise).""" + self.rotation = rotation % 4 + + +class Map: + def __init__(self, map_id: str, width: int, height: int, depth: int, source_def: str | None = None): + self.map_id = map_id + self.width = width + self.height = height + self.depth = depth + self.source_def = source_def or f"{map_id}.json" + self._tiles: list[Tile] = [Tile() for _ in range(width * height * depth)] + self.embeddings: list[MapEmbedding] = [] + self.parent_embedding: MapEmbedding | None = None + + def _index(self, x: int, y: int, z: int) -> int: + return x + y * self.width + z * self.width * self.height + + def in_bounds(self, x: float, y: float, z: float) -> bool: + """Accepts continuous coordinates too - which tile a position falls in is floor(x), floor(y).""" + x, y, z = math.floor(x), math.floor(y), math.floor(z) + return 0 <= x < self.width and 0 <= y < self.height and 0 <= z < self.depth + + def get_tile(self, x: float, y: float, z: float) -> Tile: + fx, fy, fz = math.floor(x), math.floor(y), math.floor(z) + if not self.in_bounds(fx, fy, fz): + raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}") + return self._tiles[self._index(fx, fy, fz)] + + def set_tile(self, x: float, y: float, z: float, tile: Tile) -> None: + fx, fy, fz = math.floor(x), math.floor(y), math.floor(z) + if not self.in_bounds(fx, fy, fz): + raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}") + self._tiles[self._index(fx, fy, fz)] = tile + + def embed(self, child: "Map", anchor: tuple[int, int, int], rotation: int = 0) -> MapEmbedding: + embedding = MapEmbedding(self, child, anchor, rotation) + self.embeddings.append(embedding) + child.parent_embedding = embedding + return embedding + + def embedding_at(self, x: float, y: float, z: float) -> MapEmbedding | None: + fx, fy, fz = math.floor(x), math.floor(y), math.floor(z) + for embedding in self.embeddings: + if embedding.parent_to_local(fx, fy, fz) is not None: + return embedding + return None + + def embeddings_at_z(self, z: int) -> list[MapEmbedding]: + return [e for e in self.embeddings if e.anchor[2] <= z < e.anchor[2] + e.child_map.depth] diff --git a/engine/map_loader.py b/engine/map_loader.py new file mode 100644 index 0000000..29022e9 --- /dev/null +++ b/engine/map_loader.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from engine.map import Map +from engine.tile import TILE_LAYERS, Tile, TileLayerInstance + + +def _layer_instances(fields: dict) -> dict[str, TileLayerInstance | None]: + return {layer: (TileLayerInstance(def_id) if def_id else None) for layer, def_id in fields.items() if layer in TILE_LAYERS} + + +class MapLoader: + """Builds Map graphs (including embedded sub-maps) from resources/defs/maps/*.json.""" + + def __init__(self, maps_dir: Path): + self.maps_dir = maps_dir + self._cache: dict[str, Map] = {} + + def load(self, filename: str) -> Map: + data = json.loads((self.maps_dir / filename).read_text()) + map_id = data["map_id"] + if map_id in self._cache: + return self._cache[map_id] + + m = Map(map_id, data["width"], data["height"], data["depth"], source_def=filename) + self._cache[map_id] = m + + default_tile = data.get("default_tile", {}) + for z in range(m.depth): + for y in range(m.height): + for x in range(m.width): + m.set_tile(x, y, z, Tile(**_layer_instances(default_tile))) + + for override in data.get("tiles", []): + x, y, z = override["x"], override["y"], override["z"] + fields = {k: v for k, v in override.items() if k not in ("x", "y", "z")} + tile = m.get_tile(x, y, z) + for layer, instance in _layer_instances(fields).items(): + tile.set_layer(layer, instance) + + for embedding in data.get("embeddings", []): + child = self.load(embedding["map_file"]) + m.embed(child, tuple(embedding["anchor"]), embedding.get("rotation", 0)) + + return m diff --git a/engine/network/__init__.py b/engine/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/network/client.py b/engine/network/client.py new file mode 100644 index 0000000..04fe525 --- /dev/null +++ b/engine/network/client.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import asyncio +import json + +from engine.network.protocol import DEFAULT_PORT, encode_message + + +class GameClient: + """Minimal TCP client for GameServer: sends hello/move, receives broadcasts from other players.""" + + def __init__(self, entity_id: str, name: str, host: str = "127.0.0.1", port: int = DEFAULT_PORT): + self.entity_id = entity_id + self.name = name + self.host = host + self.port = port + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + + async def connect(self) -> None: + self._reader, self._writer = await asyncio.open_connection(self.host, self.port) + await self.send({"type": "hello", "entity_id": self.entity_id, "name": self.name}) + + async def send(self, message: dict) -> None: + assert self._writer is not None + self._writer.write(encode_message(message)) + await self._writer.drain() + + async def send_move(self, map_id: str, x: int, y: int, z: int) -> None: + await self.send({"type": "move", "entity_id": self.entity_id, "map_id": map_id, "x": x, "y": y, "z": z}) + + async def receive(self) -> dict | None: + assert self._reader is not None + line = await self._reader.readline() + if not line: + return None + return json.loads(line) + + async def close(self) -> None: + if self._writer is not None: + self._writer.close() + await self._writer.wait_closed() + self._writer = None diff --git a/engine/network/protocol.py b/engine/network/protocol.py new file mode 100644 index 0000000..568df46 --- /dev/null +++ b/engine/network/protocol.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import json + +DEFAULT_PORT = 42069 + + +def encode_message(message: dict) -> bytes: + return (json.dumps(message) + "\n").encode("utf-8") + + +def decode_message(line: bytes) -> dict: + return json.loads(line.decode("utf-8")) diff --git a/engine/network/server.py b/engine/network/server.py new file mode 100644 index 0000000..d5e353f --- /dev/null +++ b/engine/network/server.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass + +from engine.network.protocol import DEFAULT_PORT, encode_message + + +@dataclass +class ConnectedClient: + entity_id: str + writer: asyncio.StreamWriter + + +class GameServer: + """Minimal authoritative-relay TCP server: broadcasts join/move/leave to every other client. + + Wire format is JSON-lines (see engine/network/protocol.py). This is a foundation, not a + full sync engine - it relays messages verbatim rather than validating/simulating world + state server-side. + """ + + def __init__(self, host: str = "0.0.0.0", port: int = DEFAULT_PORT): + self.host = host + self.port = port + self.clients: dict[str, ConnectedClient] = {} + self._server: asyncio.AbstractServer | None = None + + async def start(self) -> None: + self._server = await asyncio.start_server(self._handle_client, self.host, self.port) + self.port = self._server.sockets[0].getsockname()[1] + + async def serve_forever(self) -> None: + assert self._server is not None + async with self._server: + await self._server.serve_forever() + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + entity_id: str | None = None + try: + while True: + line = await reader.readline() + if not line: + break + message = json.loads(line) + if message.get("type") == "hello": + entity_id = str(message["entity_id"]) + self.clients[entity_id] = ConnectedClient(entity_id, writer) + await self._broadcast( + {"type": "join", "entity_id": entity_id, "name": message.get("name", "")}, exclude=entity_id + ) + else: + await self._broadcast(message, exclude=entity_id) + finally: + if entity_id is not None: + self.clients.pop(entity_id, None) + await self._broadcast({"type": "leave", "entity_id": entity_id}) + writer.close() + + async def _broadcast(self, message: dict, exclude: str | None = None) -> None: + data = encode_message(message) + for client_id, client in list(self.clients.items()): + if client_id == exclude: + continue + try: + client.writer.write(data) + await client.writer.drain() + except ConnectionError: + self.clients.pop(client_id, None) diff --git a/engine/placeholder_textures.py b/engine/placeholder_textures.py new file mode 100644 index 0000000..f9ab83e --- /dev/null +++ b/engine/placeholder_textures.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +from PIL import Image + +from engine.defs import DefRegistry + +TEXTURE_SIZE = 32 +BORDER_SHADE = 0.75 + + +def _checker_image(color: tuple[int, int, int], size: int = TEXTURE_SIZE) -> Image.Image: + img = Image.new("RGBA", (size, size), (*color, 255)) + pixels = img.load() + border = max(1, size // 16) + shaded = tuple(int(c * BORDER_SHADE) for c in color) + for y in range(size): + for x in range(size): + if x < border or y < border or x >= size - border or y >= size - border: + pixels[x, y] = (*shaded, 255) + return img + + +def _ensure_texture(textures_dir: Path, relative_path: str, color: tuple[int, int, int]) -> None: + path = textures_dir / relative_path + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + _checker_image(color).save(path) + + +def generate_placeholder_textures(registry: DefRegistry, textures_dir: Path) -> None: + """Writes a flat-color/checker PNG for every def with a texture path, if missing.""" + for layer_def in registry.tile_layer_defs.values(): + if layer_def.texture: + _ensure_texture(textures_dir, layer_def.texture, layer_def.color) + for item_def in registry.item_defs.values(): + if item_def.texture: + _ensure_texture(textures_dir, item_def.texture, item_def.color) + for part_def in registry.body_part_defs.values(): + if part_def.texture: + _ensure_texture(textures_dir, part_def.texture, part_def.color) diff --git a/engine/render/__init__.py b/engine/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/render/device.py b/engine/render/device.py new file mode 100644 index 0000000..26638cb --- /dev/null +++ b/engine/render/device.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import wgpu + + +def request_device(canvas) -> tuple["wgpu.GPUAdapter", "wgpu.GPUDevice"]: + adapter = wgpu.gpu.request_adapter_sync(canvas=canvas, power_preference="high-performance") + device = adapter.request_device_sync() + return adapter, device + + +def configure_context(canvas, adapter, device): + context = canvas.get_context("wgpu") + texture_format = context.get_preferred_format(adapter) + context.configure(device=device, format=texture_format) + return context, texture_format diff --git a/engine/render/pipeline.py b/engine/render/pipeline.py new file mode 100644 index 0000000..addc123 --- /dev/null +++ b/engine/render/pipeline.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import wgpu + +SHADER_PATH = Path(__file__).parent / "shaders" / "textured_quad.wgsl" + +# position(vec2) + uv(vec2), one unit quad shared by every instance +QUAD_VERTICES = np.array( + [ + [0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 1.0], + [1.0, 1.0, 1.0, 1.0], + ], + dtype=np.float32, +) +QUAD_INDICES = np.array([0, 1, 2, 2, 1, 3], dtype=np.uint16) + +CAMERA_UNIFORM_SIZE = 16 # scale(vec2) + offset(vec2) + + +class QuadPipeline: + def __init__(self, device, texture_format: str): + self.device = device + + shader = device.create_shader_module(code=SHADER_PATH.read_text()) + + self.camera_buffer = device.create_buffer( + size=CAMERA_UNIFORM_SIZE, + usage=wgpu.BufferUsage.UNIFORM | wgpu.BufferUsage.COPY_DST, + ) + camera_bind_group_layout = device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.VERTEX, + "buffer": {"type": wgpu.BufferBindingType.uniform}, + }, + ] + ) + self.camera_bind_group = device.create_bind_group( + layout=camera_bind_group_layout, + entries=[{"binding": 0, "resource": {"buffer": self.camera_buffer, "offset": 0, "size": CAMERA_UNIFORM_SIZE}}], + ) + + self.texture_bind_group_layout = device.create_bind_group_layout( + entries=[ + { + "binding": 0, + "visibility": wgpu.ShaderStage.FRAGMENT, + "texture": { + "sample_type": wgpu.TextureSampleType.float, + "view_dimension": wgpu.TextureViewDimension.d2, + }, + }, + { + "binding": 1, + "visibility": wgpu.ShaderStage.FRAGMENT, + "sampler": {"type": wgpu.SamplerBindingType.filtering}, + }, + ] + ) + + pipeline_layout = device.create_pipeline_layout( + bind_group_layouts=[camera_bind_group_layout, self.texture_bind_group_layout] + ) + + self.pipeline = device.create_render_pipeline( + layout=pipeline_layout, + vertex={ + "module": shader, + "entry_point": "vs_main", + "buffers": [ + { + "array_stride": 4 * 4, + "step_mode": wgpu.VertexStepMode.vertex, + "attributes": [ + {"format": wgpu.VertexFormat.float32x2, "offset": 0, "shader_location": 0}, + {"format": wgpu.VertexFormat.float32x2, "offset": 8, "shader_location": 1}, + ], + }, + { + "array_stride": 4 * 4, + "step_mode": wgpu.VertexStepMode.instance, + "attributes": [ + {"format": wgpu.VertexFormat.float32x2, "offset": 0, "shader_location": 2}, + {"format": wgpu.VertexFormat.float32x2, "offset": 8, "shader_location": 3}, + ], + }, + ], + }, + primitive={"topology": wgpu.PrimitiveTopology.triangle_list, "cull_mode": wgpu.CullMode.none}, + fragment={ + "module": shader, + "entry_point": "fs_main", + "targets": [ + { + "format": texture_format, + "blend": { + "color": { + "src_factor": wgpu.BlendFactor.src_alpha, + "dst_factor": wgpu.BlendFactor.one_minus_src_alpha, + "operation": wgpu.BlendOperation.add, + }, + "alpha": { + "src_factor": wgpu.BlendFactor.one, + "dst_factor": wgpu.BlendFactor.one_minus_src_alpha, + "operation": wgpu.BlendOperation.add, + }, + }, + } + ], + }, + ) + + self.quad_vertex_buffer = device.create_buffer( + size=QUAD_VERTICES.nbytes, usage=wgpu.BufferUsage.VERTEX | wgpu.BufferUsage.COPY_DST + ) + device.queue.write_buffer(self.quad_vertex_buffer, 0, QUAD_VERTICES.tobytes()) + + self.quad_index_buffer = device.create_buffer( + size=QUAD_INDICES.nbytes, usage=wgpu.BufferUsage.INDEX | wgpu.BufferUsage.COPY_DST + ) + device.queue.write_buffer(self.quad_index_buffer, 0, QUAD_INDICES.tobytes()) + self.quad_index_count = len(QUAD_INDICES) + + self.sampler = device.create_sampler(mag_filter=wgpu.FilterMode.nearest, min_filter=wgpu.FilterMode.nearest) + + def make_texture_bind_group(self, texture_view): + return self.device.create_bind_group( + layout=self.texture_bind_group_layout, + entries=[ + {"binding": 0, "resource": texture_view}, + {"binding": 1, "resource": self.sampler}, + ], + ) + + def update_camera(self, scale: tuple[float, float], offset: tuple[float, float]) -> None: + data = np.array([scale[0], scale[1], offset[0], offset[1]], dtype=np.float32) + self.device.queue.write_buffer(self.camera_buffer, 0, data.tobytes()) + + def make_instance_buffer(self, instances: np.ndarray): + buffer = self.device.create_buffer( + size=instances.nbytes, usage=wgpu.BufferUsage.VERTEX | wgpu.BufferUsage.COPY_DST + ) + self.device.queue.write_buffer(buffer, 0, instances.tobytes()) + return buffer diff --git a/engine/render/renderer.py b/engine/render/renderer.py new file mode 100644 index 0000000..53abe5d --- /dev/null +++ b/engine/render/renderer.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections import defaultdict + +import numpy as np +import wgpu + +from engine.camera import OrthoCamera +from engine.character import Character +from engine.defs import DefRegistry +from engine.map import Map, rotate_point +from engine.render.pipeline import QuadPipeline +from engine.render.texture import TextureCache +from engine.tile import TILE_LAYERS +from engine.world import World + +CLEAR_COLOR = {"r": 0.05, "g": 0.05, "b": 0.08, "a": 1.0} +CHARACTER_SLOT_ORDER = ("head", "torso", "left_arm", "right_arm", "left_leg", "right_leg") + + +class Renderer: + def __init__(self, device, pipeline: QuadPipeline, texture_cache: TextureCache, registry: DefRegistry): + self.device = device + self.pipeline = pipeline + self.texture_cache = texture_cache + self.registry = registry + self._texture_bind_groups: dict[str, object] = {} + + def _bind_group_for(self, relative_path: str): + if relative_path not in self._texture_bind_groups: + view = self.texture_cache.get_view(relative_path) + self._texture_bind_groups[relative_path] = self.pipeline.make_texture_bind_group(view) + return self._texture_bind_groups[relative_path] + + def render_frame(self, context, world: World, camera: OrthoCamera) -> None: + scale, offset = camera.scale_offset() + self.pipeline.update_camera(scale, offset) + + buckets: dict[str, list[float]] = defaultdict(list) + if world.player is not None and world.player.position is not None: + active_map = world.maps[world.player.position.map_id] + self._draw_map(active_map, (0, 0, 0), 0, world, buckets) + + texture = context.get_current_texture() + view = texture.create_view() + encoder = self.device.create_command_encoder() + pass_encoder = encoder.begin_render_pass( + color_attachments=[ + { + "view": view, + "clear_value": CLEAR_COLOR, + "load_op": wgpu.LoadOp.clear, + "store_op": wgpu.StoreOp.store, + } + ] + ) + pass_encoder.set_pipeline(self.pipeline.pipeline) + pass_encoder.set_bind_group(0, self.pipeline.camera_bind_group) + pass_encoder.set_vertex_buffer(0, self.pipeline.quad_vertex_buffer) + pass_encoder.set_index_buffer(self.pipeline.quad_index_buffer, wgpu.IndexFormat.uint16) + + for relative_path, flat_instances in buckets.items(): + instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4) + instance_buffer = self.pipeline.make_instance_buffer(instances) + pass_encoder.set_bind_group(1, self._bind_group_for(relative_path)) + pass_encoder.set_vertex_buffer(1, instance_buffer) + pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances)) + + pass_encoder.end() + self.device.queue.submit([encoder.finish()]) + + def _draw_map( + self, m: Map, offset: tuple[int, int, int], rotation: int, world: World, buckets: dict[str, list[float]] + ) -> None: + for z in range(m.depth): + for layer in TILE_LAYERS: + for y in range(m.height): + for x in range(m.width): + tile = m.get_tile(x, y, z) + def_id = tile.layer_id(layer) + if def_id is None: + continue + layer_def = self.registry.get_tile_layer_def(layer, def_id) + if layer_def is None or not layer_def.texture: + continue + rx, ry = rotate_point(x, y, m.width, m.height, rotation) + buckets[layer_def.texture].extend( + (float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0) + ) + + for entity in world.entities_on_map(m.map_id): + if isinstance(entity, Character): + self._draw_character(entity, offset, rotation, m.width, m.height, buckets) + + for embedding in m.embeddings: + arx, ary = rotate_point(embedding.anchor[0], embedding.anchor[1], m.width, m.height, rotation) + child_offset = (offset[0] + arx, offset[1] + ary, offset[2] + embedding.anchor[2]) + child_rotation = (rotation + embedding.rotation) % 4 + self._draw_map(embedding.child_map, child_offset, child_rotation, world, buckets) + + def _draw_character( + self, + character: Character, + offset: tuple[int, int, int], + rotation: int, + map_width: int, + map_height: int, + buckets: dict[str, list[float]], + ) -> None: + assert character.position is not None + rx, ry = rotate_point(character.position.x, character.position.y, map_width, map_height, rotation) + base_x = offset[0] + rx + base_y = offset[1] + ry + resolved = character.resolved_body_parts() + row_h = 1.0 / len(CHARACTER_SLOT_ORDER) + for i, slot in enumerate(CHARACTER_SLOT_ORDER): + part = resolved.get(slot) + if part is None: + continue + part_def = self.registry.get_body_part_def(part.part_def_id) + if not part_def.texture: + continue + buckets[part_def.texture].extend( + (base_x + 0.1, base_y + i * row_h + row_h * 0.05, 0.8, row_h * 0.9) + ) diff --git a/engine/render/shaders/textured_quad.wgsl b/engine/render/shaders/textured_quad.wgsl new file mode 100644 index 0000000..b8a2521 --- /dev/null +++ b/engine/render/shaders/textured_quad.wgsl @@ -0,0 +1,42 @@ +struct CameraUniform { + scale: vec2, + offset: vec2, +}; + +@group(0) @binding(0) var camera: CameraUniform; +@group(1) @binding(0) var quad_texture: texture_2d; +@group(1) @binding(1) var quad_sampler: sampler; + +struct VertexIn { + @location(0) position: vec2, + @location(1) uv: vec2, +}; + +struct InstanceIn { + @location(2) world_pos: vec2, + @location(3) size: vec2, +}; + +struct VertexOut { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(vin: VertexIn, iin: InstanceIn) -> VertexOut { + var out: VertexOut; + let world = iin.world_pos + vin.position * iin.size; + let ndc = world * camera.scale + camera.offset; + out.clip_position = vec4(ndc, 0.0, 1.0); + out.uv = vin.uv; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let color = textureSample(quad_texture, quad_sampler, in.uv); + if (color.a < 0.01) { + discard; + } + return color; +} diff --git a/engine/render/texture.py b/engine/render/texture.py new file mode 100644 index 0000000..ef3ffdb --- /dev/null +++ b/engine/render/texture.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import wgpu +from PIL import Image + + +class TextureCache: + def __init__(self, device, textures_dir: Path): + self.device = device + self.textures_dir = textures_dir + self._views: dict[str, "wgpu.GPUTextureView"] = {} + + def get_view(self, relative_path: str) -> "wgpu.GPUTextureView": + if relative_path not in self._views: + self._views[relative_path] = self._load(relative_path) + return self._views[relative_path] + + def _load(self, relative_path: str) -> "wgpu.GPUTextureView": + image = Image.open(self.textures_dir / relative_path).convert("RGBA") + data = np.asarray(image, dtype=np.uint8) + width, height = image.width, image.height + + texture = self.device.create_texture( + size=(width, height, 1), + format=wgpu.TextureFormat.rgba8unorm, + usage=wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + ) + self.device.queue.write_texture( + {"texture": texture}, + data.tobytes(), + {"bytes_per_row": width * 4, "rows_per_image": height}, + (width, height, 1), + ) + return texture.create_view() diff --git a/engine/render/window.py b/engine/render/window.py new file mode 100644 index 0000000..9d6bed5 --- /dev/null +++ b/engine/render/window.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import glfw +from rendercanvas.glfw import GlfwRenderCanvas, loop + + +class Window: + def __init__(self, width: int = 960, height: int = 720, title: str = "Mi2d RPG Engine"): + if hasattr(glfw, "PLATFORM") and hasattr(glfw, "PLATFORM_X11"): + glfw.init_hint(glfw.PLATFORM, glfw.PLATFORM_X11) + self.canvas = GlfwRenderCanvas(size=(width, height), title=title, update_mode="continuous") + + def run(self) -> None: + loop.run() diff --git a/engine/skills.py b/engine/skills.py new file mode 100644 index 0000000..c29dd49 --- /dev/null +++ b/engine/skills.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +# Classic D&D-style skill placeholders. Values are skill levels/modifiers, meaning TBD by game logic. +DND_SKILLS = ( + "acrobatics", + "animal_handling", + "arcana", + "athletics", + "deception", + "history", + "insight", + "intimidation", + "investigation", + "medicine", + "nature", + "perception", + "performance", + "persuasion", + "religion", + "sleight_of_hand", + "stealth", + "survival", +) + + +def default_bio() -> dict[str, int]: + return {skill: 0 for skill in DND_SKILLS} diff --git a/engine/species.py b/engine/species.py new file mode 100644 index 0000000..29d258e --- /dev/null +++ b/engine/species.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from engine.character import BodyPart + + +@dataclass(frozen=True) +class AbilityDef: + """An ability a species can perform, gated on the body parts it needs to work.""" + + id: str + name: str + required_slots: tuple[str, ...] = () + + +@dataclass(frozen=True) +class SpeciesDef: + """A species/preset a Character is based off: default anatomy + blood-loss thresholds.""" + + id: str + name: str + genders: list[str] = field(default_factory=list) # open marker: species defines its own valid gender values + default_body_parts: list[BodyPart] = field(default_factory=list) + default_organs: list[str] = field(default_factory=list) # organ_def ids; placed by each organ's own host_slot + body_type: str = "biped" # coarse anatomical shape (e.g. "biped", "quadruped") - gates which + # clothing fits at all, regardless of arm count; see ItemDef.compatible_body_types + abilities: list[AbilityDef] = field(default_factory=list) + blood_danger_threshold: float = 50.0 # % blood remaining + blood_critical_threshold: float = 25.0 + health_item_compatibility: list[str] = field(default_factory=list) # placeholder for future health-item tagging diff --git a/engine/start_screen.py b/engine/start_screen.py new file mode 100644 index 0000000..af47c3f --- /dev/null +++ b/engine/start_screen.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from engine.character import Character +from engine.character_library import CharacterLibrary +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.world import World + + +@dataclass +class StartScreen: + """Pure interaction state for the pre-game start screen: pick one of the current account's + persistent characters (see engine/character_library.py::CharacterLibrary) to join a session + with - single-player continuation or joining someone else's multiplayer world with a + character built up over time. + + No rendering here - same boundary as LayerPickerMenu/AbilityBar/CharacterMenu in + engine/ui.py: listing characters as clickable rows and drawing the screen itself is a + follow-up UI-layer task. This is the state/logic side: which characters exist for this + account, which one is currently selected, and handing off to join_selected_character() + once confirmed. Creating a brand new character is a separate flow - see + engine/character_creator.py. + """ + + account_id: str + characters: list[tuple[str, str, str | None]] = field(default_factory=list) # (character_id, name, species_id) + selected_index: int | None = None + + @classmethod + def load_for_account(cls, account_id: str, library: CharacterLibrary) -> "StartScreen": + return cls(account_id=account_id, characters=library.list_characters(account_id)) + + def refresh(self, library: CharacterLibrary) -> None: + """Re-reads the character list (e.g. after creating or deleting one) and clears selection.""" + self.characters = library.list_characters(self.account_id) + self.selected_index = None + + def select(self, index: int) -> None: + if 0 <= index < len(self.characters): + self.selected_index = index + + def selected_character_id(self) -> str | None: + if self.selected_index is None: + return None + return self.characters[self.selected_index][0] + + +def join_world_with_character( + world: World, + library: CharacterLibrary, + account_id: str, + character_id: str, + registry: DefRegistry, + spawn_position: EntityPosition, +) -> Character | None: + """Loads a persistent character from the library and drops it into `world` at + `spawn_position` - the "confirm join" action once a StartScreen selection is made. Returns + None (no-op on `world`) if no such character exists for this account. + """ + character = library.load_character(account_id, character_id, registry) + if character is None: + return None + character.position = spawn_position + world.add_entity(character) + return character diff --git a/engine/tile.py b/engine/tile.py new file mode 100644 index 0000000..c185019 --- /dev/null +++ b/engine/tile.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +TILE_LAYERS = ("subfloor", "flooring", "room", "roof") + + +@dataclass(frozen=True) +class TileLayerDef: + id: str + layer: str + texture: str | None = None + color: tuple[int, int, int] = (255, 0, 255) + walkable: bool = True + blocks_los: bool = False + deconstruct_item_id: str | None = None # item yielded by a careful (right-click) deconstruction + is_helm: bool = False # standing here lets a character steer the embedded map it's part of + staircase_direction: str | None = None # "up" or "down" - see Tile.staircase_direction + extra: dict = field(default_factory=dict) + + +@dataclass +class TileLayerInstance: + """A layer's actual per-cell state (as opposed to its static def).""" + + def_id: str + integrity: float = 100.0 + + +@dataclass +class Tile: + subfloor: TileLayerInstance | None = None + flooring: TileLayerInstance | None = None + room: TileLayerInstance | None = None + roof: TileLayerInstance | None = None + + def get_layer(self, layer: str) -> TileLayerInstance | None: + return getattr(self, layer) + + def set_layer(self, layer: str, instance: TileLayerInstance | None) -> None: + setattr(self, layer, instance) + + def layer_id(self, layer: str) -> str | None: + instance = self.get_layer(layer) + return instance.def_id if instance is not None else None + + def is_walkable(self, registry) -> bool: + for layer in ("room", "flooring"): + layer_def = registry.get_tile_layer_def(layer, self.layer_id(layer)) + if layer_def is not None and not layer_def.walkable: + return False + return True + + def staircase_direction(self, registry) -> str | None: + """'up'/'down' if either layer here is a staircase/ladder, else None.""" + for layer in ("room", "flooring"): + layer_def = registry.get_tile_layer_def(layer, self.layer_id(layer)) + if layer_def is not None and layer_def.staircase_direction is not None: + return layer_def.staircase_direction + return None diff --git a/engine/timing.py b/engine/timing.py new file mode 100644 index 0000000..de8d05a --- /dev/null +++ b/engine/timing.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import heapq +import itertools +from dataclasses import dataclass, field +from typing import Callable + + +class GameClock: + """Converts real elapsed time into discrete game ticks at a fixed rate.""" + + def __init__(self, tick_interval: float = 1.0): + self.tick_interval = tick_interval + self.total_time = 0.0 + self.tick_count = 0 + self._accumulator = 0.0 + + def advance(self, dt: float) -> int: + """Advance by dt real seconds; returns how many whole ticks just elapsed.""" + self.total_time += dt + self._accumulator += dt + ticks = int(self._accumulator // self.tick_interval) + if ticks: + self._accumulator -= ticks * self.tick_interval + self.tick_count += ticks + return ticks + + +@dataclass(order=True) +class _ScheduledEvent: + run_at: float + seq: int + event_id: str = field(compare=False) + callback: Callable[[], None] = field(compare=False) + interval: float | None = field(default=None, compare=False) + + +class Scheduler: + """Min-heap of one-off or repeating callbacks, run against a game-time value you drive (e.g. GameClock.total_time).""" + + def __init__(self): + self._heap: list[_ScheduledEvent] = [] + self._seq = itertools.count() + + def schedule_at(self, run_at: float, event_id: str, callback: Callable[[], None], interval: float | None = None) -> None: + heapq.heappush(self._heap, _ScheduledEvent(run_at, next(self._seq), event_id, callback, interval)) + + def schedule_in(self, now: float, delay: float, event_id: str, callback: Callable[[], None], interval: float | None = None) -> None: + self.schedule_at(now + delay, event_id, callback, interval) + + def run_due(self, now: float) -> list[str]: + """Runs (and pops) every event due at or before `now`; repeating events are rescheduled. Returns the ids run.""" + ran: list[str] = [] + while self._heap and self._heap[0].run_at <= now: + event = heapq.heappop(self._heap) + event.callback() + ran.append(event.event_id) + if event.interval is not None: + self.schedule_at(event.run_at + event.interval, event.event_id, event.callback, event.interval) + return ran + + def pending_count(self) -> int: + return len(self._heap) diff --git a/engine/ui.py b/engine/ui.py new file mode 100644 index 0000000..e065b53 --- /dev/null +++ b/engine/ui.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class LayerPickerMenu: + """Pure interaction state for choosing which of a tile's occupied layers a tool targets. + + No rendering here - this project has no text-rendering system yet, so drawing an actual + on-screen popup box is a follow-up UI-layer task. This is the game-logic side of it: which + option is highlighted, and what layer name comes back once the player confirms. + """ + + map_id: str + x: int + y: int + z: int + options: list[str] = field(default_factory=list) + selected_index: int = 0 + + def cycle(self, delta: int = 1) -> None: + if self.options: + self.selected_index = (self.selected_index + delta) % len(self.options) + + def confirm(self) -> str | None: + if not self.options: + return None + return self.options[self.selected_index] + + +@dataclass +class AbilityBarSlot: + """One assigned slot on the ability bar: where to find the ability-granting thing (a + wielded item in a hand, or an installed implant) when this slot gets triggered - see + resources/logic/actions.py::activate_ability_bar_slot for the dispatch on `kind`. + """ + + kind: str # "hand" or "implant" + source_id: str # hand_slot name (e.g. "right_hand") or implant item_def_id + ability_id: str # the ability granted, for display/bookkeeping + + +@dataclass +class AbilityBar: + """Pure interaction state for the bottom-left ability bar: up to 10 assignable slots + (numkeys 1-9 and 0), selectable by mouse click or hotkey - both just call select(). + + No rendering here - same boundary as LayerPickerMenu above: this project has no + text/icon-rendering system yet, so drawing the actual bar and highlighting the + selected slot on-screen is a follow-up UI-layer task. + """ + + slots: list[AbilityBarSlot | None] = field(default_factory=lambda: [None] * 10) + selected_index: int | None = None + + def assign(self, index: int, slot: AbilityBarSlot | None) -> None: + self.slots[index] = slot + + def select(self, index: int) -> None: + if 0 <= index < len(self.slots): + self.selected_index = index + + def selected_slot(self) -> AbilityBarSlot | None: + if self.selected_index is None: + return None + return self.slots[self.selected_index] + + +@dataclass +class CharacterMenu: + """Pure interaction state for the bottom-left tabbed character menu: a "bio" tab (skills/ + mental stats/health - all already on the Character model) and an "equipment" tab (worn + clothing + held items). Right-clicking an equipped slot that grants an inventory (e.g. a + worn backpack) opens a popup submenu above the menu showing that slot's contents. + + No rendering here - same boundary as LayerPickerMenu/AbilityBar above: the actual bio and + equipment data already lives on the Character/DefRegistry models this engine built over the + whole session, so this class only tracks which tab is active and which backpack popup (if + any) is open - drawing the tabs, labels, and popup box is a follow-up UI-layer task. + """ + + TABS = ("bio", "equipment") + + active_tab: str = "bio" + open_backpack_slot: str | None = None # clothing slot whose granted-inventory popup is open + + def select_tab(self, tab: str) -> None: + if tab in self.TABS: + self.active_tab = tab + self.open_backpack_slot = None # leaving equipment closes any open popup + + def right_click_equipment_slot(self, slot: str, character, registry) -> bool: + """Toggles the backpack popup for a worn item that grants an inventory (e.g. a + backpack). No-op (returns False, nothing opens) for an empty slot or a worn item with + no granted inventory - e.g. a plain jacket has nothing to pop open. + """ + piece = character.get_clothing(slot) + if piece is None: + return False + item_def = registry.get_item_def(piece.item_def_id) + if item_def.grants_inventory_size is None: + return False + if self.open_backpack_slot == slot: + self.open_backpack_slot = None + return False + self.open_backpack_slot = slot + return True + + def close_backpack_popup(self) -> None: + self.open_backpack_slot = None diff --git a/engine/world.py b/engine/world.py new file mode 100644 index 0000000..c565996 --- /dev/null +++ b/engine/world.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass, field + +from engine.character import Character +from engine.entity import Entity, EntityPosition +from engine.map import Map + +LEAP_DISTANCE = 2 + + +@dataclass +class TimeWarpZone: + """A timed spherical zone that slows everything inside it except a whitelist of entities. + + Generic on purpose: size (radius), speed_factor, whitelist (immune_entity_ids), and time + (expires_at, an absolute GameClock.total_time) are all just constructor args - nothing here + assumes it was cast by a character ability vs. triggered by an item (see + resources/logic/abilities.py's cast_time_warp_sphere for both attachment points). + """ + + map_id: str + center: tuple[float, float, float] + radius: float + speed_factor: float + expires_at: float + immune_entity_ids: set[str] = field(default_factory=set) + + def contains(self, map_id: str, x: float, y: float, z: float) -> bool: + if map_id != self.map_id or math.floor(z) != math.floor(self.center[2]): + return False + dx, dy = x - self.center[0], y - self.center[1] + return math.hypot(dx, dy) <= self.radius + + def affects(self, entity_id: str) -> bool: + return entity_id not in self.immune_entity_ids + + +class World: + """Owns the map graph, all entities, and the def registry; drives movement.""" + + def __init__(self, registry=None): + self.registry = registry + self.maps: dict[str, Map] = {} + self.entities: dict[str, Entity] = {} + self.player: Entity | None = None + self.time_warp_zones: list[TimeWarpZone] = [] + + def speed_multiplier_at(self, entity_id: str, map_id: str, x: float, y: float, z: float, now: float) -> float: + """Combined slow from every active, non-expired zone that affects this entity here.""" + multiplier = 1.0 + for zone in self.time_warp_zones: + if zone.expires_at > now and zone.affects(entity_id) and zone.contains(map_id, x, y, z): + multiplier *= zone.speed_factor + return multiplier + + def expire_time_warp_zones(self, now: float) -> None: + self.time_warp_zones = [z for z in self.time_warp_zones if z.expires_at > now] + + def add_map(self, map_: Map) -> None: + self.maps[map_.map_id] = map_ + + def add_entity(self, entity: Entity) -> None: + self.entities[entity.entity_id] = entity + + def entities_on_map(self, map_id: str) -> list[Entity]: + return [e for e in self.entities.values() if e.position is not None and e.position.map_id == map_id] + + def entity_at(self, map_id: str, x: int, y: int, z: int) -> Entity | None: + """Tile-membership lookup: matches any entity whose (possibly continuous) position + falls in tile (x, y, z), i.e. floor(pos.x) == x etc. Floor of an int is itself, so this + is unchanged for entities that happen to sit exactly on a tile. + """ + for entity in self.entities.values(): + pos = entity.position + if pos is None or pos.map_id != map_id: + continue + if (math.floor(pos.x), math.floor(pos.y), math.floor(pos.z)) == (x, y, z): + return entity + return None + + def try_move(self, entity: Entity, dx: int, dy: int, dz: int) -> bool: + assert entity.position is not None + cur_map = self.maps[entity.position.map_id] + tx = entity.position.x + dx + ty = entity.position.y + dy + tz = entity.position.z + dz + moved = self._move_within(entity, cur_map, tx, ty, tz) + if moved: + self._apply_staircase(entity) + return moved + + def try_leap(self, entity: Entity, dx: int, dy: int, dz: int, distance: int = LEAP_DISTANCE) -> bool: + """A leap in the given unit direction, e.g. to clear a gap and land aboard a ship. + + Reuses try_move's own semantics unchanged: it only ever validates the landing tile, + never anything in between, so scaling the delta is all a "jump over a gap" needs. + + If the entity's species defines a "leap" ability (e.g. requiring both legs), it's + gated on that; species/entities with no such ability defined are left unrestricted. + """ + if isinstance(entity, Character) and entity.species_id is not None and self.registry is not None: + species = self.registry.get_species_def(entity.species_id) + if any(a.id == "leap" for a in species.abilities) and not entity.can_perform_ability("leap", self.registry): + return False + return self.try_move(entity, dx * distance, dy * distance, dz * distance) + + def _move_within(self, entity: Entity, map_: Map, x: int, y: int, z: int) -> bool: + if map_.in_bounds(x, y, z): + embedding = map_.embedding_at(x, y, z) + if embedding is not None: + lx, ly, lz = embedding.parent_to_local(x, y, z) + if not embedding.child_map.get_tile(lx, ly, lz).is_walkable(self.registry): + return False + entity.position = EntityPosition(embedding.child_map.map_id, lx, ly, lz) + return True + if not map_.get_tile(x, y, z).is_walkable(self.registry): + return False + entity.position = EntityPosition(map_.map_id, x, y, z) + return True + + if map_.parent_embedding is not None: + embedding = map_.parent_embedding + px, py, pz = embedding.local_to_parent(x, y, z) + return self._move_within(entity, embedding.parent_map, px, py, pz) + + return False + + def try_move_continuous(self, entity: Entity, dx: float, dy: float, dz: float, distance: float) -> bool: + """True stepless movement: moves `entity` by `distance` along the (dx, dy, dz) direction + (need not be a unit vector - it's normalized here), landing at a fractional position + rather than snapping to a tile. Collision is checked against whichever tile the + destination falls in (floor(x), floor(y)) - same walkability/embedding rules as + try_move, just point-based rather than a full sweep, and the path between old and new + position isn't swept for obstacles (a fast-enough mover could tunnel a thin wall in one + frame - a known simplification, not a concern at ordinary per-frame speeds/dt). + """ + assert entity.position is not None + length = math.hypot(dx, dy) + if length == 0: + return False + ux, uy = dx / length, dy / length + nx = entity.position.x + ux * distance + ny = entity.position.y + uy * distance + nz = entity.position.z + moved = self._move_within_continuous(entity, self.maps[entity.position.map_id], nx, ny, nz) + if moved: + self._apply_staircase(entity) + return moved + + def _apply_staircase(self, entity: Entity) -> None: + """A single z-shift if the entity just landed on a staircase/ladder tile, provided the + target floor is in bounds and walkable at the same (x, y). Not recursive - a stack of + staircases each carries you up/down exactly one level per step onto them. + """ + assert entity.position is not None + map_ = self.maps[entity.position.map_id] + x, y, z = entity.position.x, entity.position.y, entity.position.z + direction = map_.get_tile(x, y, z).staircase_direction(self.registry) + if direction is None: + return + nz = math.floor(z) + (1 if direction == "up" else -1) + if not map_.in_bounds(x, y, nz): + return + if not map_.get_tile(x, y, nz).is_walkable(self.registry): + return + entity.position = EntityPosition(map_.map_id, x, y, nz) + + def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool: + tile_x, tile_y, tile_z = math.floor(x), math.floor(y), math.floor(z) + if map_.in_bounds(tile_x, tile_y, tile_z): + embedding = map_.embedding_at(tile_x, tile_y, tile_z) + if embedding is not None: + local_tile = embedding.parent_to_local(tile_x, tile_y, tile_z) + assert local_tile is not None + if not embedding.child_map.get_tile(*local_tile).is_walkable(self.registry): + return False + lx, ly, lz = embedding.parent_to_local_pos(x, y, z) + entity.position = EntityPosition(embedding.child_map.map_id, lx, ly, lz) + return True + if not map_.get_tile(tile_x, tile_y, tile_z).is_walkable(self.registry): + return False + entity.position = EntityPosition(map_.map_id, x, y, z) + return True + + if map_.parent_embedding is not None: + embedding = map_.parent_embedding + px, py, pz = embedding.local_to_parent_pos(x, y, z) + return self._move_within_continuous(entity, embedding.parent_map, px, py, pz) + + return False diff --git a/main.py b/main.py new file mode 100644 index 0000000..4561ae8 --- /dev/null +++ b/main.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import random +import time +from pathlib import Path + +import glfw + +from engine.aim import snap_to_8way +from engine.camera import OrthoCamera +from engine.character import Character +from engine.config import KeyBindings +from engine.db import WorldRepository +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.gamepad import GamepadBindings, GamepadState +from engine.input import InputState +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map_loader import MapLoader +from engine.placeholder_textures import generate_placeholder_textures +from engine.render.device import configure_context, request_device +from engine.render.pipeline import QuadPipeline +from engine.render.renderer import Renderer +from engine.render.texture import TextureCache +from engine.render.window import Window +from engine.timing import GameClock +from engine.world import World +from resources.logic.actions import activate_hand +from resources.logic.health_ticks import apply_bleeding_tick +from resources.logic.organs import apply_organ_interactions_tick +from resources.logic.steering import at_helm, try_steer_ship + +ROOT_DIR = Path(__file__).resolve().parent +DEFS_DIR = ROOT_DIR / "resources" / "defs" +TEXTURES_DIR = ROOT_DIR / "resources" / "textures" +DB_PATH = ROOT_DIR / "data" / "world.sqlite3" +KEYBINDINGS_PATH = ROOT_DIR / "config" / "keybindings.conf" +CONTROLLER_BINDINGS_PATH = ROOT_DIR / "config" / "controller_bindings.conf" +BASE_MOVE_SPEED = 4.0 # tiles/second at full (two healthy legs') speed factor + + +def spawn_player(registry: DefRegistry) -> Character: + entity_def = registry.get_entity_def("player") + assert entity_def.species_id is not None + + player = Character( + entity_id="player-1", + name=entity_def.name, + def_id=entity_def.id, + position=EntityPosition("demo_world", 4, 4, 0), + species_id=entity_def.species_id, + gender="nonbinary", + default_body_parts=registry.new_default_body_parts(entity_def.species_id), + default_clothing=registry.new_default_clothing(entity_def.id), + ) + player.wield_item("right_hand", "staff_oak", registry) + + if entity_def.inventory_size is not None: + player.inventory = Inventory(*entity_def.inventory_size) + player.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0) + player.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0) + + return player + + +def build_fresh_world(registry: DefRegistry, map_loader: MapLoader) -> World: + world = World(registry=registry) + demo_world = map_loader.load("demo_world.json") + world.add_map(demo_world) + for embedding in demo_world.embeddings: + world.add_map(embedding.child_map) + + player = spawn_player(registry) + world.add_entity(player) + world.player = player + return world + + +def main() -> None: + registry = DefRegistry.load(DEFS_DIR) + generate_placeholder_textures(registry, TEXTURES_DIR) + + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + repo = WorldRepository.open(DB_PATH) + map_loader = MapLoader(DEFS_DIR / "maps") + + world = repo.load_world(registry, map_loader) + if world is None: + world = build_fresh_world(registry, map_loader) + print("No save found; started a fresh world.") + else: + print(f"Loaded save: player at {world.player.position}.") + + window = Window() + adapter, device = request_device(window.canvas) + context, texture_format = configure_context(window.canvas, adapter, device) + + pipeline = QuadPipeline(device, texture_format) + texture_cache = TextureCache(device, TEXTURES_DIR) + renderer = Renderer(device, pipeline, texture_cache, registry) + camera = OrthoCamera(window.canvas.get_physical_size()[0], window.canvas.get_physical_size()[1]) + keybindings = KeyBindings.load(KEYBINDINGS_PATH) + gamepad_bindings = GamepadBindings.load(CONTROLLER_BINDINGS_PATH) + input_state = InputState(keybindings) + clock = GameClock(tick_interval=1.0) + last_frame_time = time.perf_counter() + rng = random.Random() + + def on_event(event: dict) -> None: + if event.get("event_type") == "close": + repo.save_world(world) + print("Saved world on close.") + else: + input_state.handle_event(event) + + window.canvas.add_event_handler(on_event, "key_down", "key_up", "pointer_down", "pointer_move", "close") + + def current_aim_direction(player: Character) -> tuple[int, int, int]: + assert player.position is not None + if input_state.pointer_pos is None: + return input_state.facing + wx, wy = camera.screen_to_world(*input_state.pointer_pos) + dx, dy = snap_to_8way(wx - (player.position.x + 0.5), wy - (player.position.y + 0.5)) + return dx, dy, 0 + + def poll_gamepad() -> None: + # A gamepad is polled (not event-driven like key_down/key_up), so this just feeds + # whatever's plugged into joystick slot 1 into the same action dispatch as keyboard/ + # mouse each frame - see InputState.apply_gamepad_state for the edge detection. + if glfw.joystick_present(glfw.JOYSTICK_1) and glfw.joystick_is_gamepad(glfw.JOYSTICK_1): + raw = glfw.get_gamepad_state(glfw.JOYSTICK_1) + if raw is not None: + input_state.apply_gamepad_state(GamepadState.from_glfw(raw), gamepad_bindings) + + def draw_frame() -> None: + nonlocal last_frame_time + wall_now = time.perf_counter() + dt = wall_now - last_frame_time + last_frame_time = wall_now + + poll_gamepad() + + ticks = clock.advance(dt) + world.expire_time_warp_zones(clock.total_time) + if ticks: + for entity in world.entities.values(): + if isinstance(entity, Character): + apply_bleeding_tick(entity, registry, ticks) + apply_organ_interactions_tick(entity, registry, ticks) + + player = world.player + if isinstance(player, Character) and player.position is not None: + player.is_blocking = input_state.is_blocking + + move_dx, move_dy, _ = input_state.current_move_vector() + if move_dx or move_dy: + if at_helm(player, world) is not None: + # steering a ship stays a discrete, one-press-per-step affair - the ship's + # own grid structure doesn't need continuous sub-tile positioning + move = input_state.consume_move() + if move is not None: + try_steer_ship(player, world, *move) + else: + speed = player.effective_speed(BASE_MOVE_SPEED, registry) + slow = world.speed_multiplier_at( + player.entity_id, player.position.map_id, player.position.x, player.position.y, + player.position.z, clock.total_time, + ) + if speed > 0: + world.try_move_continuous(player, move_dx, move_dy, 0, distance=speed * slow * dt) + + leap = input_state.consume_leap() + if leap is not None: + world.try_leap(player, *leap) + + hand = input_state.consume_hand_activation() + if hand is not None: + activate_hand(player, world, registry, hand, current_aim_direction(player), rng=rng, now=clock.total_time) + + if isinstance(player, Character) and player.position is not None: + camera.set_target(player.position.x + 0.5, player.position.y + 0.5) + renderer.render_frame(context, world, camera) + + window.canvas.request_draw(draw_frame) + window.run() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0e4c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..c58f2f2 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "venvPath": ".", + "venv": ".venv", + "include": ["engine", "resources/logic", "tests", "main.py"] +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..26b77f6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce9bbaa --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +wgpu +glfw +numpy +Pillow diff --git a/resources/__init__.py b/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/defs/body_parts.json b/resources/defs/body_parts.json new file mode 100644 index 0000000..857ff80 --- /dev/null +++ b/resources/defs/body_parts.json @@ -0,0 +1,291 @@ +{ + "human_head": { + "slot": "head", + "texture": "characters/human_head.png", + "color": [235, 200, 170], + "bleeding_weight": 0.10, + "bleeding_speed": 4.0, + "skill_weights": { "perception": 0.6, "insight": 0.4 } + }, + "human_head_hat": { + "slot": "head", + "texture": "characters/human_head_hat.png", + "color": [90, 50, 40], + "bleeding_weight": 0.10, + "bleeding_speed": 4.0, + "skill_weights": { "perception": 0.6, "insight": 0.4 } + }, + "human_torso": { + "slot": "torso", + "texture": "characters/human_torso.png", + "color": [70, 100, 160], + "bleeding_weight": 0.35, + "bleeding_speed": 6.0, + "skill_weights": { "athletics": 0.5 } + }, + "human_left_arm": { + "slot": "left_arm", + "texture": "characters/human_left_arm.png", + "color": [235, 200, 170], + "bleeding_weight": 0.10, + "bleeding_speed": 2.0, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "human_right_arm": { + "slot": "right_arm", + "texture": "characters/human_right_arm.png", + "color": [235, 200, 170], + "bleeding_weight": 0.10, + "bleeding_speed": 2.0, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "human_left_leg": { + "slot": "left_leg", + "texture": "characters/human_left_leg.png", + "color": [60, 60, 90], + "bleeding_weight": 0.125, + "bleeding_speed": 3.0, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.5 + }, + "human_right_leg": { + "slot": "right_leg", + "texture": "characters/human_right_leg.png", + "color": [60, 60, 90], + "bleeding_weight": 0.125, + "bleeding_speed": 3.0, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.5 + }, + "bionic_leg": { + "slot": "left_leg", + "texture": "characters/bionic_leg.png", + "color": [140, 150, 160], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 1.2 + }, + + "reptilian_head": { + "slot": "head", + "texture": "characters/reptilian_head.png", + "color": [70, 140, 70], + "bleeding_weight": 0.10, + "bleeding_speed": 3.0, + "skill_weights": { "perception": 0.6, "insight": 0.4 } + }, + "reptilian_torso": { + "slot": "torso", + "texture": "characters/reptilian_torso.png", + "color": [50, 110, 50], + "bleeding_weight": 0.35, + "bleeding_speed": 5.0, + "skill_weights": { "athletics": 0.5 } + }, + "reptilian_left_arm": { + "slot": "left_arm", + "texture": "characters/reptilian_left_arm.png", + "color": [70, 140, 70], + "bleeding_weight": 0.06, + "bleeding_speed": 1.5, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "reptilian_right_arm": { + "slot": "right_arm", + "texture": "characters/reptilian_right_arm.png", + "color": [70, 140, 70], + "bleeding_weight": 0.06, + "bleeding_speed": 1.5, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "reptilian_left_arm_2": { + "slot": "left_arm_2", + "texture": "characters/reptilian_left_arm_2.png", + "color": [70, 140, 70], + "bleeding_weight": 0.06, + "bleeding_speed": 1.5, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "reptilian_right_arm_2": { + "slot": "right_arm_2", + "texture": "characters/reptilian_right_arm_2.png", + "color": [70, 140, 70], + "bleeding_weight": 0.06, + "bleeding_speed": 1.5, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "reptilian_left_leg": { + "slot": "left_leg", + "texture": "characters/reptilian_left_leg.png", + "color": [40, 90, 40], + "bleeding_weight": 0.125, + "bleeding_speed": 2.5, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.5 + }, + "reptilian_right_leg": { + "slot": "right_leg", + "texture": "characters/reptilian_right_leg.png", + "color": [40, 90, 40], + "bleeding_weight": 0.125, + "bleeding_speed": 2.5, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.5 + }, + + "alien_head": { + "slot": "head", + "texture": "characters/alien_head.png", + "color": [180, 140, 220], + "bleeding_weight": 0.10, + "bleeding_speed": 3.5, + "skill_weights": { "perception": 0.6, "insight": 0.4 } + }, + "alien_torso": { + "slot": "torso", + "texture": "characters/alien_torso.png", + "color": [140, 100, 190], + "bleeding_weight": 0.30, + "bleeding_speed": 5.0, + "skill_weights": { "athletics": 0.5 } + }, + "alien_left_arm": { + "slot": "left_arm", + "texture": "characters/alien_left_arm.png", + "color": [180, 140, 220], + "bleeding_weight": 0.08, + "bleeding_speed": 1.5, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "alien_right_arm": { + "slot": "right_arm", + "texture": "characters/alien_right_arm.png", + "color": [180, 140, 220], + "bleeding_weight": 0.08, + "bleeding_speed": 1.5, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "alien_left_leg": { + "slot": "left_leg", + "texture": "characters/alien_left_leg.png", + "color": [110, 80, 150], + "bleeding_weight": 0.10, + "bleeding_speed": 2.5, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.55 + }, + "alien_right_leg": { + "slot": "right_leg", + "texture": "characters/alien_right_leg.png", + "color": [110, 80, 150], + "bleeding_weight": 0.10, + "bleeding_speed": 2.5, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.55 + }, + + "bot_head": { + "slot": "head", + "texture": "characters/bot_head.png", + "color": [150, 150, 160], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "perception": 0.6, "insight": 0.4 } + }, + "bot_torso": { + "slot": "torso", + "texture": "characters/bot_torso.png", + "color": [110, 110, 120], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "athletics": 0.5 } + }, + "bot_left_arm": { + "slot": "left_arm", + "texture": "characters/bot_left_arm.png", + "color": [150, 150, 160], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "bot_right_arm": { + "slot": "right_arm", + "texture": "characters/bot_right_arm.png", + "color": [150, 150, 160], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 } + }, + "bot_left_leg": { + "slot": "left_leg", + "texture": "characters/bot_left_leg.png", + "color": [90, 90, 100], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.5 + }, + "bot_right_leg": { + "slot": "right_leg", + "texture": "characters/bot_right_leg.png", + "color": [90, 90, 100], + "bleeding_weight": 0.0, + "bleeding_speed": 0.0, + "skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 }, + "speed_factor": 0.5 + }, + + "dog_head": { + "slot": "head", + "texture": "characters/dog_head.png", + "color": [150, 110, 70], + "bleeding_weight": 0.10, + "bleeding_speed": 3.0, + "skill_weights": { "perception": 0.7, "insight": 0.3 } + }, + "dog_torso": { + "slot": "torso", + "texture": "characters/dog_torso.png", + "color": [130, 95, 60], + "bleeding_weight": 0.30, + "bleeding_speed": 5.0, + "skill_weights": { "athletics": 0.5 } + }, + "dog_front_left_leg": { + "slot": "front_left_leg", + "texture": "characters/dog_front_left_leg.png", + "color": [110, 80, 50], + "bleeding_weight": 0.07, + "bleeding_speed": 1.5, + "skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 }, + "speed_factor": 0.25 + }, + "dog_front_right_leg": { + "slot": "front_right_leg", + "texture": "characters/dog_front_right_leg.png", + "color": [110, 80, 50], + "bleeding_weight": 0.07, + "bleeding_speed": 1.5, + "skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 }, + "speed_factor": 0.25 + }, + "dog_back_left_leg": { + "slot": "back_left_leg", + "texture": "characters/dog_back_left_leg.png", + "color": [110, 80, 50], + "bleeding_weight": 0.07, + "bleeding_speed": 1.5, + "skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 }, + "speed_factor": 0.25 + }, + "dog_back_right_leg": { + "slot": "back_right_leg", + "texture": "characters/dog_back_right_leg.png", + "color": [110, 80, 50], + "bleeding_weight": 0.07, + "bleeding_speed": 1.5, + "skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 }, + "speed_factor": 0.25 + } +} diff --git a/resources/defs/entities.json b/resources/defs/entities.json new file mode 100644 index 0000000..c34af0a --- /dev/null +++ b/resources/defs/entities.json @@ -0,0 +1,38 @@ +{ + "player": { + "name": "Player", + "species_id": "human", + "default_clothing": ["backpack_basic"], + "inventory_size": [6, 5] + }, + "npc_reptilian": { + "name": "Reptilian", + "species_id": "reptilian_quad", + "default_clothing": [], + "inventory_size": [6, 5] + }, + "npc_alien": { + "name": "Zenari", + "species_id": "alien_tri", + "default_clothing": [], + "inventory_size": [6, 5] + }, + "npc_bot": { + "name": "Bot", + "species_id": "bot", + "default_clothing": [], + "inventory_size": [6, 5] + }, + "npc_dog": { + "name": "Dog", + "species_id": "dog", + "default_clothing": [], + "inventory_size": [4, 4] + }, + "companion_dog": { + "name": "Companion Dog", + "species_id": "dog", + "default_clothing": ["dog_harness", "dog_saddlebag"], + "inventory_size": null + } +} diff --git a/resources/defs/items.json b/resources/defs/items.json new file mode 100644 index 0000000..2deb3a4 --- /dev/null +++ b/resources/defs/items.json @@ -0,0 +1,284 @@ +{ + "staff_oak": { + "name": "Oak Staff", + "width": 1, + "height": 5, + "texture": "items/staff_oak.png", + "color": [150, 100, 50], + "weapon_skill": "athletics", + "weapon_damage": 6.0, + "weapon_range": 1, + "hands_required": 1, + "melee_knockback": 1.5 + }, + "sandwich": { + "name": "Sandwich", + "width": 2, + "height": 2, + "texture": "items/sandwich.png", + "color": [230, 190, 100], + "stackable": true, + "max_stack": 4 + }, + "leather_jacket": { + "name": "Leather Jacket", + "width": 2, + "height": 2, + "texture": "items/leather_jacket.png", + "color": [90, 60, 40], + "clothing_slot": "torso", + "armor_reduction": 3.0, + "arm_slots_required": 2, + "compatible_body_types": ["biped"] + }, + "reptilian_jacket": { + "name": "Reptilian Jacket", + "width": 2, + "height": 2, + "texture": "items/reptilian_jacket.png", + "color": [60, 100, 60], + "clothing_slot": "torso", + "armor_reduction": 3.0, + "arm_slots_required": 4, + "compatible_body_types": ["biped"] + }, + "backpack_basic": { + "name": "Basic Backpack", + "width": 2, + "height": 2, + "texture": "items/backpack_basic.png", + "color": [110, 90, 60], + "clothing_slot": "back", + "grants_inventory_size": [4, 4], + "compatible_body_types": ["biped"] + }, + "dog_harness": { + "name": "Dog Harness", + "width": 2, + "height": 2, + "texture": "items/dog_harness.png", + "color": [150, 60, 50], + "clothing_slot": "torso", + "armor_reduction": 2.0, + "compatible_body_types": ["quadruped"] + }, + "dog_saddlebag": { + "name": "Dog Saddlebag", + "width": 2, + "height": 2, + "texture": "items/dog_saddlebag.png", + "color": [120, 90, 60], + "clothing_slot": "back", + "grants_inventory_size": [3, 3], + "compatible_body_types": ["quadruped"] + }, + "wood_planks": { + "name": "Wood Planks", + "width": 2, + "height": 1, + "texture": "items/wood_planks.png", + "color": [140, 100, 60], + "stackable": true, + "max_stack": 10, + "builds_tile_layer": "room", + "builds_tile_layer_def_id": "wood_wall" + }, + "multi_deconstructor": { + "name": "Multi-Deconstructor", + "width": 1, + "height": 2, + "texture": "items/multi_deconstructor.png", + "color": [200, 60, 60], + "tool_kind": "deconstructor", + "hands_required": 2 + }, + "constructor_tool": { + "name": "Constructor", + "width": 1, + "height": 2, + "texture": "items/constructor_tool.png", + "color": [60, 160, 60], + "tool_kind": "constructor", + "hands_required": 2 + }, + "pistol_basic": { + "name": "Basic Pistol", + "width": 1, + "height": 1, + "texture": "items/pistol_basic.png", + "color": [70, 70, 75], + "weapon_skill": "sleight_of_hand", + "weapon_damage": 8.0, + "weapon_range": 6, + "projectile_type": "bullet", + "hands_required": 1 + }, + "bow_basic": { + "name": "Basic Bow", + "width": 1, + "height": 3, + "texture": "items/bow_basic.png", + "color": [130, 90, 50], + "weapon_skill": "sleight_of_hand", + "weapon_damage": 5.0, + "weapon_range": 6, + "projectile_type": "arrow", + "hands_required": 2 + }, + "wooden_shield": { + "name": "Wooden Shield", + "width": 2, + "height": 2, + "texture": "items/wooden_shield.png", + "color": [120, 80, 40], + "hands_required": 1, + "shield_block_bonus": 4.0, + "blockable_projectile_types": ["arrow"] + }, + "heavy_shield": { + "name": "Heavy Shield", + "width": 2, + "height": 3, + "texture": "items/heavy_shield.png", + "color": [90, 90, 100], + "hands_required": 1, + "shield_block_bonus": 6.0, + "blockable_projectile_types": ["heavy_caliber"] + }, + + "ammo_heavy_caliber": { + "name": "Heavy Caliber Rounds", + "width": 1, + "height": 1, + "texture": "items/ammo_heavy_caliber.png", + "color": [80, 70, 40], + "stackable": true, + "max_stack": 20, + "ammo_type": "heavy_caliber", + "ranged_knockback": 3.0 + }, + "ammo_shotgun_shell": { + "name": "Shotgun Shells", + "width": 1, + "height": 1, + "texture": "items/ammo_shotgun_shell.png", + "color": [180, 40, 40], + "stackable": true, + "max_stack": 20, + "ammo_type": "shotgun_shell", + "ranged_knockback": 4.0 + }, + "ammo_shotgun_slug": { + "name": "Shotgun Slugs", + "width": 1, + "height": 1, + "texture": "items/ammo_shotgun_slug.png", + "color": [140, 30, 30], + "stackable": true, + "max_stack": 20, + "ammo_type": "shotgun_slug", + "ranged_knockback": 4.0 + }, + "ammo_rocket": { + "name": "Rockets", + "width": 1, + "height": 2, + "texture": "items/ammo_rocket.png", + "color": [60, 90, 60], + "stackable": true, + "max_stack": 5, + "ammo_type": "rocket", + "ranged_knockback": 5.0 + }, + + "scope_basic": { + "name": "Basic Scope", + "width": 1, + "height": 1, + "texture": "items/scope_basic.png", + "color": [40, 40, 40], + "attachment_kind": "scope", + "aimcone_reduction_degrees": 3.0 + }, + "stock_basic": { + "name": "Basic Stock", + "width": 1, + "height": 1, + "texture": "items/stock_basic.png", + "color": [90, 60, 40], + "attachment_kind": "stock", + "aimcone_reduction_degrees": 2.0 + }, + "extended_mag": { + "name": "Extended Magazine", + "width": 1, + "height": 1, + "texture": "items/extended_mag.png", + "color": [60, 60, 65], + "attachment_kind": "magazine", + "magazine_capacity_bonus": 5 + }, + + "rifle_heavy": { + "name": "Heavy Rifle", + "width": 1, + "height": 4, + "texture": "items/rifle_heavy.png", + "color": [50, 50, 55], + "weapon_skill": "sleight_of_hand", + "weapon_damage": 14.0, + "weapon_range": 8, + "hands_required": 2, + "compatible_ammo_types": ["heavy_caliber"], + "magazine_size": 5, + "aim_cone_degrees": 6.0 + }, + "shotgun_basic": { + "name": "Basic Shotgun", + "width": 1, + "height": 4, + "texture": "items/shotgun_basic.png", + "color": [80, 55, 35], + "weapon_skill": "sleight_of_hand", + "weapon_damage": 4.0, + "weapon_range": 5, + "hands_required": 2, + "compatible_ammo_types": ["shotgun_shell", "shotgun_slug"], + "magazine_size": 2, + "aim_cone_degrees": 45.0, + "pellet_count": 6 + }, + "rocket_launcher": { + "name": "Rocket Launcher", + "width": 2, + "height": 4, + "texture": "items/rocket_launcher.png", + "color": [50, 65, 50], + "weapon_skill": "sleight_of_hand", + "weapon_damage": 25.0, + "weapon_range": 8, + "hands_required": 2, + "compatible_ammo_types": ["rocket"], + "magazine_size": 1, + "aim_cone_degrees": 5.0, + "blast_radius": 2 + }, + "chronowand": { + "name": "Chronowand", + "width": 1, + "height": 3, + "texture": "items/chronowand.png", + "color": [120, 90, 200], + "hands_required": 1, + "grants_ability": "time_warp_sphere" + }, + "chronoimplant": { + "name": "Chronoimplant", + "width": 1, + "height": 1, + "texture": "items/chronoimplant.png", + "color": [150, 110, 220], + "is_implant": true, + "grants_ability": "time_warp_sphere" + } +} diff --git a/resources/defs/maps/demo_world.json b/resources/defs/maps/demo_world.json new file mode 100644 index 0000000..ec93840 --- /dev/null +++ b/resources/defs/maps/demo_world.json @@ -0,0 +1,11 @@ +{ + "map_id": "demo_world", + "width": 10, + "height": 10, + "depth": 1, + "default_tile": { "subfloor": "dirt", "flooring": "grass" }, + "tiles": [], + "embeddings": [ + { "map_file": "ship_small.json", "anchor": [6, 6, 0] } + ] +} diff --git a/resources/defs/maps/harbor_world.json b/resources/defs/maps/harbor_world.json new file mode 100644 index 0000000..d8812fa --- /dev/null +++ b/resources/defs/maps/harbor_world.json @@ -0,0 +1,11 @@ +{ + "map_id": "harbor_world", + "width": 14, + "height": 14, + "depth": 1, + "default_tile": { "subfloor": "dirt", "flooring": "grass" }, + "tiles": [], + "embeddings": [ + { "map_file": "ship_test.json", "anchor": [4, 4, 0], "rotation": 1 } + ] +} diff --git a/resources/defs/maps/ship_small.json b/resources/defs/maps/ship_small.json new file mode 100644 index 0000000..6f34c6d --- /dev/null +++ b/resources/defs/maps/ship_small.json @@ -0,0 +1,20 @@ +{ + "map_id": "ship_small", + "width": 4, + "height": 3, + "depth": 1, + "default_tile": { "subfloor": "dirt", "flooring": "wood_deck" }, + "tiles": [ + { "x": 0, "y": 0, "z": 0, "room": "wood_wall" }, + { "x": 1, "y": 0, "z": 0, "room": "wood_wall" }, + { "x": 3, "y": 0, "z": 0, "room": "wood_wall" }, + { "x": 0, "y": 1, "z": 0, "room": "wood_wall" }, + { "x": 3, "y": 1, "z": 0, "room": "wood_wall" }, + { "x": 0, "y": 2, "z": 0, "room": "wood_wall" }, + { "x": 1, "y": 2, "z": 0, "room": "wood_wall" }, + { "x": 2, "y": 2, "z": 0, "room": "wood_wall" }, + { "x": 3, "y": 2, "z": 0, "room": "wood_wall" }, + { "x": 2, "y": 1, "z": 0, "room": "ship_helm" } + ], + "embeddings": [] +} diff --git a/resources/defs/maps/ship_test.json b/resources/defs/maps/ship_test.json new file mode 100644 index 0000000..c529042 --- /dev/null +++ b/resources/defs/maps/ship_test.json @@ -0,0 +1,294 @@ +{ + "map_id": "ship_test", + "width": 5, + "height": 5, + "depth": 2, + "default_tile": { + "subfloor": "dirt", + "flooring": "wood_deck" + }, + "tiles": [ + { + "x": 0, + "y": 0, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 0, + "y": 4, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 1, + "y": 0, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 1, + "y": 4, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 2, + "y": 0, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 2, + "y": 4, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 3, + "y": 0, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 3, + "y": 4, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 4, + "y": 0, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 4, + "y": 4, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 0, + "y": 1, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 4, + "y": 1, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 0, + "y": 2, + "z": 0, + "roof": "shingles" + }, + { + "x": 4, + "y": 2, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 0, + "y": 3, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 4, + "y": 3, + "z": 0, + "roof": "shingles", + "room": "wood_wall" + }, + { + "x": 1, + "y": 1, + "z": 0, + "roof": "shingles" + }, + { + "x": 1, + "y": 2, + "z": 0, + "roof": "shingles" + }, + { + "x": 1, + "y": 3, + "z": 0, + "roof": "shingles" + }, + { + "x": 2, + "y": 1, + "z": 0, + "roof": "shingles" + }, + { + "x": 2, + "y": 2, + "z": 0, + "roof": "shingles" + }, + { + "x": 2, + "y": 3, + "z": 0, + "roof": "shingles" + }, + { + "x": 3, + "y": 1, + "z": 0, + "roof": "shingles" + }, + { + "x": 3, + "y": 2, + "z": 0, + "roof": "shingles" + }, + { + "x": 3, + "y": 3, + "z": 0, + "roof": "shingles", + "room": "stairs_up" + }, + { + "x": 0, + "y": 0, + "z": 1, + "room": "wood_wall" + }, + { + "x": 0, + "y": 4, + "z": 1, + "room": "wood_wall" + }, + { + "x": 1, + "y": 0, + "z": 1, + "room": "wood_wall" + }, + { + "x": 1, + "y": 4, + "z": 1, + "room": "wood_wall" + }, + { + "x": 2, + "y": 0, + "z": 1, + "room": "wood_wall" + }, + { + "x": 2, + "y": 4, + "z": 1, + "room": "wood_wall" + }, + { + "x": 3, + "y": 0, + "z": 1, + "room": "wood_wall" + }, + { + "x": 3, + "y": 4, + "z": 1, + "room": "wood_wall" + }, + { + "x": 4, + "y": 0, + "z": 1, + "room": "wood_wall" + }, + { + "x": 4, + "y": 4, + "z": 1, + "room": "wood_wall" + }, + { + "x": 0, + "y": 1, + "z": 1, + "room": "wood_wall" + }, + { + "x": 4, + "y": 1, + "z": 1, + "room": "wood_wall" + }, + { + "x": 0, + "y": 2, + "z": 1, + "room": "wood_wall" + }, + { + "x": 4, + "y": 2, + "z": 1, + "room": "wood_wall" + }, + { + "x": 0, + "y": 3, + "z": 1, + "room": "wood_wall" + }, + { + "x": 4, + "y": 3, + "z": 1, + "room": "wood_wall" + }, + { + "x": 1, + "y": 1, + "z": 1, + "room": "ship_helm", + "roof": "shingles" + }, + { + "x": 2, + "y": 1, + "z": 1, + "room": "wood_wall" + }, + { + "x": 3, + "y": 3, + "z": 1, + "room": "stairs_down" + } + ], + "embeddings": [] +} diff --git a/resources/defs/maps/storm_field.json b/resources/defs/maps/storm_field.json new file mode 100644 index 0000000..8d71161 --- /dev/null +++ b/resources/defs/maps/storm_field.json @@ -0,0 +1,19 @@ +{ + "map_id": "storm_field", + "width": 10, + "height": 10, + "depth": 1, + "default_tile": { "subfloor": "dirt", "flooring": "grass" }, + "tiles": [ + { "x": 7, "y": 7, "z": 0, "roof": "shingles" }, + { "x": 8, "y": 7, "z": 0, "roof": "shingles" }, + { "x": 9, "y": 7, "z": 0, "roof": "shingles" }, + { "x": 7, "y": 8, "z": 0, "roof": "shingles" }, + { "x": 8, "y": 8, "z": 0, "roof": "shingles" }, + { "x": 9, "y": 8, "z": 0, "roof": "shingles" }, + { "x": 7, "y": 9, "z": 0, "roof": "shingles" }, + { "x": 8, "y": 9, "z": 0, "roof": "shingles" }, + { "x": 9, "y": 9, "z": 0, "roof": "shingles" } + ], + "embeddings": [] +} diff --git a/resources/defs/organs.json b/resources/defs/organs.json new file mode 100644 index 0000000..c5b2278 --- /dev/null +++ b/resources/defs/organs.json @@ -0,0 +1,136 @@ +{ + "human_heart": { + "name": "Heart", + "host_slot": "torso", + "check_weights": { "strength": 0.6, "speed": 0.5 }, + "affects_organs": { "human_kidneys": 0.05 } + }, + "human_lungs": { + "name": "Lungs", + "host_slot": "torso", + "check_weights": { "speed": 0.3, "athletics": 0.3 } + }, + "human_liver": { + "name": "Liver", + "host_slot": "torso", + "check_weights": { "constitution": 0.7, "poison_resistance": 0.7 } + }, + "human_kidneys": { + "name": "Kidneys", + "host_slot": "torso", + "check_weights": { "constitution": 0.3, "poison_resistance": 0.3 } + }, + "human_brain": { + "name": "Brain", + "host_slot": "head", + "check_weights": { "insight": 0.5, "perception": 0.3 } + }, + + "reptilian_heart": { + "name": "Heart", + "host_slot": "torso", + "check_weights": { "strength": 0.6, "speed": 0.5 }, + "affects_organs": { "reptilian_venom_gland": 0.05 } + }, + "reptilian_lung": { + "name": "Lung", + "host_slot": "torso", + "check_weights": { "speed": 0.3, "athletics": 0.3 } + }, + "reptilian_liver": { + "name": "Liver", + "host_slot": "torso", + "check_weights": { "constitution": 0.5, "poison_resistance": 0.4 } + }, + "reptilian_venom_gland": { + "name": "Venom Gland", + "host_slot": "torso", + "check_weights": { "poison_resistance": 0.3, "intimidation": 0.2 } + }, + "reptilian_brain": { + "name": "Brain", + "host_slot": "head", + "check_weights": { "insight": 0.5, "perception": 0.3 } + }, + + "zenari_cardial_node": { + "name": "Cardial Node", + "host_slot": "torso", + "check_weights": { "strength": 0.6, "speed": 0.5 }, + "affects_organs": { "zenari_filtration_organ": 0.05 } + }, + "zenari_respirator": { + "name": "Respirator", + "host_slot": "torso", + "check_weights": { "speed": 0.3, "athletics": 0.3 } + }, + "zenari_metabolizer": { + "name": "Metabolizer", + "host_slot": "torso", + "check_weights": { "constitution": 0.7, "poison_resistance": 0.7 } + }, + "zenari_filtration_organ": { + "name": "Filtration Organ", + "host_slot": "torso", + "check_weights": { "constitution": 0.3, "poison_resistance": 0.3 } + }, + "zenari_cortex": { + "name": "Cortex", + "host_slot": "head", + "check_weights": { "insight": 0.5, "perception": 0.3 } + }, + + "bot_cpu": { + "name": "CPU", + "host_slot": "head", + "check_weights": { "insight": 0.5, "perception": 0.3 } + }, + "bot_npu": { + "name": "NPU", + "host_slot": "head", + "check_weights": { "sleight_of_hand": 0.4, "perception": 0.2 } + }, + "bot_power_cell": { + "name": "Power Cell", + "host_slot": "torso", + "check_weights": { "strength": 0.6, "speed": 0.5 }, + "affects_organs": { "bot_cpu": 0.05, "bot_npu": 0.05, "bot_hydraulic_fluid_bladder": 0.03 } + }, + "bot_hydraulic_fluid_bladder": { + "name": "Hydraulic Fluid Bladder", + "host_slot": "torso", + "check_weights": { "strength": 0.3, "athletics": 0.3 } + }, + "bot_cooling_fan": { + "name": "Cooling Fan", + "host_slot": "torso", + "check_weights": { "speed": 0.3, "athletics": 0.3 } + }, + + "dog_heart": { + "name": "Heart", + "host_slot": "torso", + "check_weights": { "strength": 0.6, "speed": 0.5 }, + "affects_organs": { "dog_kidneys": 0.05 } + }, + "dog_lungs": { + "name": "Lungs", + "host_slot": "torso", + "check_weights": { "speed": 0.3, "athletics": 0.3 } + }, + "dog_liver": { + "name": "Liver", + "host_slot": "torso", + "check_weights": { "constitution": 0.7, "poison_resistance": 0.7 } + }, + "dog_kidneys": { + "name": "Kidneys", + "host_slot": "torso", + "check_weights": { "constitution": 0.3, "poison_resistance": 0.3 } + }, + "dog_brain": { + "name": "Brain", + "host_slot": "head", + "check_weights": { "insight": 0.5, "perception": 0.3 } + } +} diff --git a/resources/defs/species.json b/resources/defs/species.json new file mode 100644 index 0000000..c9f97b7 --- /dev/null +++ b/resources/defs/species.json @@ -0,0 +1,137 @@ +{ + "human": { + "name": "Human", + "genders": ["male", "female", "nonbinary"], + "default_body_parts": [ + "human_head", + "human_torso", + "human_left_arm", + "human_right_arm", + "human_left_leg", + "human_right_leg" + ], + "default_organs": ["human_heart", "human_lungs", "human_liver", "human_kidneys", "human_brain"], + "body_type": "biped", + "abilities": [ + { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, + { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, + { "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] }, + { "id": "time_warp_sphere", "name": "Time Warp Sphere", "required_slots": ["head"] } + ], + "blood_danger_threshold": 50.0, + "blood_critical_threshold": 25.0, + "health_item_compatibility": [] + }, + "reptilian_quad": { + "name": "Reptilian", + "genders": ["none"], + "default_body_parts": [ + "reptilian_head", + "reptilian_torso", + "reptilian_left_arm", + "reptilian_right_arm", + "reptilian_left_arm_2", + "reptilian_right_arm_2", + "reptilian_left_leg", + "reptilian_right_leg" + ], + "default_organs": [ + "reptilian_heart", + "reptilian_lung", + "reptilian_liver", + "reptilian_venom_gland", + "reptilian_brain" + ], + "body_type": "biped", + "abilities": [ + { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, + { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, + { "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] } + ], + "blood_danger_threshold": 45.0, + "blood_critical_threshold": 20.0, + "health_item_compatibility": [] + }, + "alien_tri": { + "name": "Zenari", + "genders": ["alpha", "beta", "gamma"], + "default_body_parts": [ + "alien_head", + "alien_torso", + "alien_left_arm", + "alien_right_arm", + "alien_left_leg", + "alien_right_leg" + ], + "default_organs": [ + "zenari_cardial_node", + "zenari_respirator", + "zenari_metabolizer", + "zenari_filtration_organ", + "zenari_cortex" + ], + "body_type": "biped", + "abilities": [ + { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, + { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, + { "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] }, + { "id": "time_warp_sphere", "name": "Time Warp Sphere", "required_slots": ["head"] } + ], + "blood_danger_threshold": 55.0, + "blood_critical_threshold": 30.0, + "health_item_compatibility": [] + }, + "bot": { + "name": "Bot", + "genders": ["none"], + "default_body_parts": [ + "bot_head", + "bot_torso", + "bot_left_arm", + "bot_right_arm", + "bot_left_leg", + "bot_right_leg" + ], + "default_organs": [ + "bot_cpu", + "bot_npu", + "bot_power_cell", + "bot_hydraulic_fluid_bladder", + "bot_cooling_fan" + ], + "body_type": "biped", + "abilities": [ + { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, + { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, + { "id": "ranged_attack", "name": "Ranged Attack", "required_slots": ["right_arm"] } + ], + "blood_danger_threshold": 0.0, + "blood_critical_threshold": 0.0, + "health_item_compatibility": [] + }, + "dog": { + "name": "Dog", + "genders": ["male", "female"], + "default_body_parts": [ + "dog_head", + "dog_torso", + "dog_front_left_leg", + "dog_front_right_leg", + "dog_back_left_leg", + "dog_back_right_leg" + ], + "default_organs": ["dog_heart", "dog_lungs", "dog_liver", "dog_kidneys", "dog_brain"], + "body_type": "quadruped", + "abilities": [ + { + "id": "leap", + "name": "Leap", + "required_slots": ["front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"] + }, + { "id": "melee_attack", "name": "Bite", "required_slots": ["head"] } + ], + "blood_danger_threshold": 50.0, + "blood_critical_threshold": 25.0, + "health_item_compatibility": [] + } +} diff --git a/resources/defs/tiles.json b/resources/defs/tiles.json new file mode 100644 index 0000000..9559048 --- /dev/null +++ b/resources/defs/tiles.json @@ -0,0 +1,20 @@ +{ + "subfloor": { + "dirt": { "color": [92, 64, 51], "walkable": true } + }, + "flooring": { + "grass": { "color": [86, 156, 70], "walkable": true }, + "wood_deck": { "color": [166, 124, 82], "walkable": true } + }, + "room": { + "wood_wall": { "color": [110, 78, 48], "walkable": false, "blocks_los": true, "deconstruct_item_id": "wood_planks" }, + "stone_wall": { "color": [120, 120, 120], "walkable": false, "blocks_los": true }, + "ship_helm": { "color": [200, 170, 60], "walkable": true, "is_helm": true }, + "stairs_up": { "color": [160, 140, 100], "walkable": true, "staircase_direction": "up" }, + "stairs_down": { "color": [100, 90, 80], "walkable": true, "staircase_direction": "down" }, + "ladder": { "color": [130, 110, 70], "walkable": true, "staircase_direction": "up" } + }, + "roof": { + "shingles": { "color": [140, 60, 50], "walkable": true } + } +} diff --git a/resources/logic/__init__.py b/resources/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/resources/logic/abilities.py b/resources/logic/abilities.py new file mode 100644 index 0000000..65d880b --- /dev/null +++ b/resources/logic/abilities.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from engine.character import Character +from engine.world import TimeWarpZone, World + +DEFAULT_RADIUS = 3.0 +DEFAULT_SPEED_FACTOR = 0.3 # 30% normal speed inside the sphere +DEFAULT_DURATION = 5.0 # seconds + + +def cast_time_warp_sphere( + caster: Character, + world: World, + now: float, + center: tuple[float, float, float] | None = None, + radius: float = DEFAULT_RADIUS, + speed_factor: float = DEFAULT_SPEED_FACTOR, + duration: float = DEFAULT_DURATION, + whitelist: set[str] | None = None, +) -> TimeWarpZone: + """Sample ability: everything inside the sphere (except the caster and `whitelist`, e.g. + allies) moves at `speed_factor` of normal for `duration` seconds. Size, speed factor, + whitelist, and time are all just arguments - this is deliberately not tied to being cast + from a character ability vs. an item; see grants_ability on ItemDef and resources/logic/ + actions.py::activate_hand for the item attachment point, and Character.can_perform_ability + for gating a direct character-ability trigger the same way leap/melee/ranged already are. + Note: only entity *movement* speed is actually slowed (see World.speed_multiplier_at) - + this engine resolves shots as instant hit-scans with no travel-time to slow. + """ + assert caster.position is not None + if center is None: + center = (caster.position.x, caster.position.y, caster.position.z) + immune = set(whitelist) if whitelist is not None else set() + immune.add(caster.entity_id) + zone = TimeWarpZone( + map_id=caster.position.map_id, + center=center, + radius=radius, + speed_factor=speed_factor, + expires_at=now + duration, + immune_entity_ids=immune, + ) + world.time_warp_zones.append(zone) + return zone diff --git a/resources/logic/actions.py b/resources/logic/actions.py new file mode 100644 index 0000000..5d96678 --- /dev/null +++ b/resources/logic/actions.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from engine.character import Character +from engine.defs import DefRegistry +from engine.ui import AbilityBarSlot +from engine.world import World +from resources.logic.abilities import cast_time_warp_sphere +from resources.logic.combat import DiceRoller, perform_attack, perform_shoot +from resources.logic.construction import interact_with_tile +from resources.logic.knockback import resolve_melee_knockback +from resources.logic.ranged_combat import fire_held_weapon + +ABILITY_CASTERS = { + "time_warp_sphere": cast_time_warp_sphere, +} + +ABILITY_COOLDOWNS = { + "time_warp_sphere": 8.0, # seconds; longer than the zone's own default 5s duration +} + + +def _cast_ability(character: Character, world: World, ability_id: str, now: float): + """Shared by the item-in-hand and implant ability paths: casts `ability_id` if it's off + cooldown, then starts its cooldown (see ABILITY_COOLDOWNS) - a no-op returning None while + still on cooldown, or if no caster is registered for it. + """ + caster = ABILITY_CASTERS.get(ability_id) + if caster is None or not character.is_ability_ready(ability_id, now): + return None + result = caster(character, world, now) + character.start_ability_cooldown(ability_id, now, ABILITY_COOLDOWNS.get(ability_id, 0.0)) + return result + + +def _first_buildable_layer(character: Character, registry: DefRegistry) -> str | None: + if character.inventory is None: + return None + for placed in character.inventory.items(): + material_def = registry.get_item_def(placed.item.def_id) + if material_def.builds_tile_layer is not None: + return material_def.builds_tile_layer + return None + + +def activate_hand( + character: Character, + world: World, + registry: DefRegistry, + hand_slot: str, + aim_direction: tuple[int, int, int], + rng: DiceRoller | None = None, + now: float = 0.0, +): + """Resolves whatever's held in `hand_slot` against `aim_direction`: a tool interacts with + the tile immediately ahead, an ability-granting item casts its ability (`now` is the game + clock time, needed for timed effects like time_warp_sphere), an ammo-fed ranged weapon + fires (see fire_held_weapon), a simple non-ammo ranged weapon shoots directly, and melee + (weapon or bare hand) attacks whoever's standing there - with knockback on a successful + hit. Returns whatever the underlying action returned, or None if the hand is empty or + nothing applicable is in range. + """ + assert character.position is not None + held = character.get_held_item(hand_slot) + dx, dy, dz = aim_direction + ahead = (character.position.x + dx, character.position.y + dy, character.position.z + dz) + + if held is None: + target = world.entity_at(character.position.map_id, *ahead) + if isinstance(target, Character): + result = perform_attack(character, target, registry, weapon_def=None, rng=rng) + resolve_melee_knockback(world, character, target, None, result.hit, registry) + return result + return None + + item_def = registry.get_item_def(held.item_def_id) + + if item_def.grants_ability is not None: + return _cast_ability(character, world, item_def.grants_ability, now) + + if item_def.tool_kind is not None: + map_ = world.maps[character.position.map_id] + if not map_.in_bounds(*ahead): + return None + button = "left" if hand_slot in ("right_hand", "right_hand_2") else "right" + + if item_def.tool_kind == "constructor": + layer = _first_buildable_layer(character, registry) + else: + tile = map_.get_tile(*ahead) + layer = next((layer for layer in ("room", "flooring", "subfloor", "roof") if tile.get_layer(layer)), None) + if layer is None: + return None + return interact_with_tile(character, world, character.position.map_id, ahead[0], ahead[1], ahead[2], layer, button) + + if item_def.magazine_size > 0: + return fire_held_weapon(character, world, registry, hand_slot, aim_direction, rng=rng) + + if item_def.weapon_range > 1: + return perform_shoot(character, world, registry, item_def, aim_direction, rng=rng) + + target = world.entity_at(character.position.map_id, *ahead) + if isinstance(target, Character): + result = perform_attack(character, target, registry, weapon_def=item_def, rng=rng) + resolve_melee_knockback(world, character, target, item_def, result.hit, registry) + return result + return None + + +def activate_implant( + character: Character, + world: World, + registry: DefRegistry, + implant_item_def_id: str, + now: float = 0.0, +): + """Casts the ability granted by an installed slotless implant (e.g. a chronoimplant + granting time_warp_sphere) - mirrors the item-in-hand ability path in activate_hand, + but implants have no hand slot and are checked against character.implants instead. + """ + if implant_item_def_id not in character.implants: + return None + item_def = registry.get_item_def(implant_item_def_id) + if item_def.grants_ability is None: + return None + return _cast_ability(character, world, item_def.grants_ability, now) + + +def activate_ability_bar_slot( + character: Character, + world: World, + registry: DefRegistry, + slot: AbilityBarSlot, + aim_direction: tuple[int, int, int], + rng: DiceRoller | None = None, + now: float = 0.0, +): + """Triggers whatever an ability-bar slot points at, whether that's a wielded hand + (dispatches through activate_hand, so a bar slot pointed at a hand behaves identically to + clicking that hand directly) or an installed implant (dispatches through activate_implant). + """ + if slot.kind == "hand": + return activate_hand(character, world, registry, slot.source_id, aim_direction, rng=rng, now=now) + if slot.kind == "implant": + return activate_implant(character, world, registry, slot.source_id, now=now) + return None diff --git a/resources/logic/combat.py b/resources/logic/combat.py new file mode 100644 index 0000000..0f76ccf --- /dev/null +++ b/resources/logic/combat.py @@ -0,0 +1,269 @@ +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, + ) diff --git a/resources/logic/companion.py b/resources/logic/companion.py new file mode 100644 index 0000000..d05ff25 --- /dev/null +++ b/resources/logic/companion.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from engine.character import Character +from engine.defs import DefRegistry +from engine.inventory import Inventory +from engine.world import World + + +def open_companion_inventory( + actor: Character, + world: World, + registry: DefRegistry, + aim_direction: tuple[int, int, int], + slot: str, +) -> Inventory | None: + """Opens the equipped inventory (e.g. a saddlebag) worn by an adjacent companion NPC - a + companion dog wearing a dog_saddlebag being the sample case. Returns None if there's no + Character adjacent in that direction, the target is the actor themself, or nothing worn at + `slot` grants an inventory (get_equipped_inventory already covers that last case). + + This is a targeted variant of Character.get_equipped_inventory - that method reads a + character's own gear, this one reads a companion's, gated on being next to them. + """ + assert actor.position is not None + dx, dy, dz = aim_direction + ahead = (actor.position.x + dx, actor.position.y + dy, actor.position.z + dz) + target = world.entity_at(actor.position.map_id, *ahead) + if not isinstance(target, Character) or target is actor: + return None + return target.get_equipped_inventory(slot, registry) diff --git a/resources/logic/construction.py b/resources/logic/construction.py new file mode 100644 index 0000000..d1694f4 --- /dev/null +++ b/resources/logic/construction.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from engine.character import Character +from engine.defs import DefRegistry +from engine.inventory import Inventory +from engine.item import ItemDef, ItemInstance +from engine.tile import TILE_LAYERS, Tile, TileLayerInstance +from engine.world import World + + +def present_layers(tile: Tile) -> list[str]: + """Which of the tile's 4 layers are currently occupied, in stack order - the candidate + list for a 'pick a layer' popup when a tool targets a tile with more than one. + """ + return [layer for layer in TILE_LAYERS if tile.get_layer(layer) is not None] + + +def damage_tile_layer(tile: Tile, layer: str, amount: float) -> bool: + """Reduces a layer's integrity; clears it once it hits zero. Returns whether it was destroyed.""" + instance = tile.get_layer(layer) + if instance is None: + return False + instance.integrity = max(0.0, instance.integrity - amount) + if instance.integrity <= 0.0: + tile.set_layer(layer, None) + return True + return False + + +def destroy_tile_layer(tile: Tile, layer: str) -> bool: + """The multi-deconstructor's left-click action: instantly clears a layer, no item gained.""" + if tile.get_layer(layer) is None: + return False + tile.set_layer(layer, None) + return True + + +def deconstruct_tile_layer(tile: Tile, layer: str, registry: DefRegistry) -> ItemInstance | None: + """The multi-deconstructor's right-click action: clears a layer and yields its item, if any.""" + instance = tile.get_layer(layer) + if instance is None: + return None + layer_def = registry.get_tile_layer_def(layer, instance.def_id) + tile.set_layer(layer, None) + if layer_def is None or layer_def.deconstruct_item_id is None: + return None + return ItemInstance(item_id=f"deconstructed-{layer}-{instance.def_id}", def_id=layer_def.deconstruct_item_id) + + +def construct_tile_layer( + tile: Tile, + layer: str, + item_def: ItemDef, + inventory: Inventory, + inventory_item_id: str, +) -> bool: + """The constructor tool's action: consumes a matching material item to place a new layer. + + Fails as a no-op if the item doesn't build this layer, or the layer is already occupied + (build on the bare slot first - deconstruct/destroy whatever's there, then construct). + """ + if item_def.builds_tile_layer != layer or item_def.builds_tile_layer_def_id is None: + return False + if tile.get_layer(layer) is not None: + return False + if inventory.remove(inventory_item_id, item_def) is None: + return False + tile.set_layer(layer, TileLayerInstance(item_def.builds_tile_layer_def_id)) + return True + + +def wielded_tool(character: Character, registry: DefRegistry) -> ItemDef | None: + for held in character.held_items: + item_def = registry.get_item_def(held.item_def_id) + if item_def.tool_kind is not None: + return item_def + return None + + +def interact_with_tile( + character: Character, world: World, map_id: str, x: int, y: int, z: int, layer: str, button: str +) -> ItemInstance | bool | None: + """Dispatches a tool interaction against one layer of a tile, based on what's wielded. + + A deconstructor: left-click destroys the layer outright, right-click deconstructs it and + (if there's room) drops the yielded item straight into the character's inventory. A + constructor: consumes the first matching material found in the character's inventory to + build that layer. Returns whatever the underlying action returned, or None if nothing + wieldable/applicable was found. + """ + tool = wielded_tool(character, registry := world.registry) + if tool is None: + return None + tile = world.maps[map_id].get_tile(x, y, z) + + if tool.tool_kind == "deconstructor": + if button == "left": + return destroy_tile_layer(tile, layer) + if button == "right": + item = deconstruct_tile_layer(tile, layer, registry) + if item is not None and character.inventory is not None: + item_def = registry.get_item_def(item.def_id) + slot = character.inventory.find_first_fit(item_def) + if slot is not None: + character.inventory.place(item, item_def, *slot) + return item + return None + + if tool.tool_kind == "constructor": + if character.inventory is None: + return False + for placed in character.inventory.items(): + material_def = registry.get_item_def(placed.item.def_id) + if material_def.builds_tile_layer == layer: + return construct_tile_layer(tile, layer, material_def, character.inventory, placed.item.item_id) + return False + + return None diff --git a/resources/logic/health_ticks.py b/resources/logic/health_ticks.py new file mode 100644 index 0000000..8dacd63 --- /dev/null +++ b/resources/logic/health_ticks.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from engine.character import Character +from engine.defs import DefRegistry + + +def apply_bleeding_tick(character: Character, registry: DefRegistry, ticks: int) -> None: + """Every body part that's been lost (removed=True) bleeds at its def's bleeding_speed per tick.""" + if ticks <= 0: + return + total_loss = 0.0 + for part in character.body_parts: + if not part.removed: + continue + part_def = registry.get_body_part_def(part.part_def_id) + total_loss += part_def.bleeding_speed * ticks + if total_loss: + character.blood_percentage = max(0.0, character.blood_percentage - total_loss) diff --git a/resources/logic/knockback.py b/resources/logic/knockback.py new file mode 100644 index 0000000..3c14562 --- /dev/null +++ b/resources/logic/knockback.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from engine.character import Character +from engine.entity import Entity, EntityPosition +from engine.item import ItemDef +from engine.world import World + +UNARMED_MELEE_KNOCKBACK = 0.3 # tiles, at baseline strength +RECOIL_FACTOR = 0.3 # fraction of dealt knockback the shooter/attacker feels in return +STRENGTH_BASELINE = 10.0 + + +def _sign(n: float) -> int: + return (n > 0) - (n < 0) + + +def direction_between(a: EntityPosition, b: EntityPosition) -> tuple[int, int, int]: + """Unit-ish step direction from a toward b (each axis independently signed).""" + return (_sign(b.x - a.x), _sign(b.y - a.y), _sign(b.z - a.z)) + + +def apply_knockback(world: World, entity: Entity, direction: tuple[int, int, int], distance: int) -> int: + """Pushes `entity` up to `distance` tiles via normal movement, stopping early at an obstacle. + + Returns how many tiles it actually moved (may be less than `distance` if blocked). + """ + dx, dy, dz = direction + moved = 0 + for _ in range(distance): + if not world.try_move(entity, dx, dy, dz): + break + moved += 1 + return moved + + +def melee_knockback_distance(attacker: Character, weapon_def: ItemDef | None, registry) -> int: + """Melee knockback scales with the attacker's effective strength (organ damage - e.g. a + weakened heart - reduces it), at baseline STRENGTH_BASELINE = 1x. + """ + base = weapon_def.melee_knockback if weapon_def is not None else UNARMED_MELEE_KNOCKBACK + return round(base * (attacker.effective_strength(registry) / STRENGTH_BASELINE)) + + +def apply_recoil( + world: World, shooter: Character, forward_direction: tuple[int, int, int], knockback_dealt: int, is_rocket: bool +) -> int: + """Pushes the attacker/shooter backward, proportional to the knockback they just dealt. + Skipped entirely for rocket launchers (their AoE knockback isn't a single directed impulse + the shooter would feel the same way). + """ + if is_rocket or knockback_dealt <= 0: + return 0 + recoil_distance = round(knockback_dealt * RECOIL_FACTOR) + if recoil_distance <= 0: + return 0 + backward = (-forward_direction[0], -forward_direction[1], -forward_direction[2]) + return apply_knockback(world, shooter, backward, recoil_distance) + + +def resolve_melee_knockback( + world: World, attacker: Character, defender: Character, weapon_def: ItemDef | None, hit: bool, registry +) -> int: + """Applies melee knockback (+ the attacker's recoil) on a successful hit only.""" + if not hit: + return 0 + assert attacker.position is not None and defender.position is not None + distance = melee_knockback_distance(attacker, weapon_def, registry) + if distance <= 0: + return 0 + direction = direction_between(attacker.position, defender.position) + moved = apply_knockback(world, defender, direction, distance) + apply_recoil(world, attacker, direction, moved, is_rocket=False) + return moved + + +def resolve_ranged_knockback( + world: World, + shooter: Character, + target: Character, + ammo_def: ItemDef, + shot_direction: tuple[int, int, int], + is_rocket: bool = False, +) -> int: + """Applies ranged knockback from the ammo's own stat, regardless of hit/miss/blocked-check + outcome: the impact's kinetic force lands as long as the shot physically reached the target + (a shield hard-blocking/ricocheting it away is the one case this is never called for). + """ + distance = round(ammo_def.ranged_knockback) + if distance <= 0: + return 0 + moved = apply_knockback(world, target, shot_direction, distance) + apply_recoil(world, shooter, shot_direction, moved, is_rocket=is_rocket) + return moved + + +def resolve_explosion_knockback(world: World, ammo_def: ItemDef, center: tuple[int, int, int], hit_entities) -> None: + """Radial knockback for each entity caught in a blast, pushed directly away from the + impact point rather than along the shot's original travel direction. + """ + distance = round(ammo_def.ranged_knockback) + if distance <= 0: + return + for entity, _damage in hit_entities: + assert entity.position is not None + direction = (_sign(entity.position.x - center[0]), _sign(entity.position.y - center[1]), 0) + if direction == (0, 0, 0): + continue + apply_knockback(world, entity, direction, distance) diff --git a/resources/logic/organs.py b/resources/logic/organs.py new file mode 100644 index 0000000..c4e47b4 --- /dev/null +++ b/resources/logic/organs.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from engine.character import Character +from engine.defs import DefRegistry + + +def apply_organ_interactions_tick(character: Character, registry: DefRegistry, ticks: int) -> None: + """Damaged organs stress the organs they affect (OrganDef.affects_organs), e.g. a weakened + heart gradually strains the kidneys. Damage per tick is proportional to both the source + organ's own damage fraction and the elapsed ticks. Sources are snapshotted before any + damage is applied, so effects don't cascade within the same tick (a heart hit doesn't also + immediately hit whatever the newly-damaged kidneys affect, this call). + """ + if ticks <= 0: + return + + sources: list[tuple[str, float]] = [] + for part in character.resolved_body_parts().values(): + if part is None: + continue + for organ in part.organs: + if organ.removed: + continue + organ_def = registry.get_organ_def(organ.organ_def_id) + if not organ_def.affects_organs: + continue + damage_fraction = 1.0 - (organ.integrity / 100.0) + if damage_fraction <= 0.0: + continue + sources.append((organ_def.id, damage_fraction)) + + for source_id, damage_fraction in sources: + source_def = registry.get_organ_def(source_id) + for target_organ_id, rate in source_def.affects_organs.items(): + _damage_organ(character, target_organ_id, rate * damage_fraction * ticks) + + +def _damage_organ(character: Character, organ_def_id: str, amount: float) -> None: + if amount <= 0: + return + for slot, part in character.resolved_body_parts().items(): + if part is None: + continue + if not any(o.organ_def_id == organ_def_id for o in part.organs): + continue + override = character.get_or_create_body_part_override(slot) + assert override is not None + organ = next(o for o in override.organs if o.organ_def_id == organ_def_id) + organ.integrity = max(0.0, organ.integrity - amount) + if organ.integrity <= 0.0: + organ.removed = True + return diff --git a/resources/logic/ranged_combat.py b/resources/logic/ranged_combat.py new file mode 100644 index 0000000..063b90b --- /dev/null +++ b/resources/logic/ranged_combat.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass, replace + +from engine.aim import apply_cone_deviation +from engine.character import Character +from engine.defs import DefRegistry +from engine.item import ItemDef +from engine.world import World +from resources.logic.combat import ( + DiceRoller, + ShotResult, + UNARMED_SKILL, + apply_body_part_damage, + choose_hit_slot, + find_ranged_target, + perform_shoot, +) +from resources.logic.construction import damage_tile_layer +from resources.logic.knockback import resolve_explosion_knockback, resolve_ranged_knockback + +DEGREES_PER_SKILL_POINT = 0.3 # how much a skill point narrows the aim cone +MIN_CONE_DEGREES = 1.0 +PELLET_SPREAD_FRACTION = 0.5 # "blast fills about half the cone" +SLUG_CONE_FRACTION = 0.8 # slugshot: "slightly thinner aimcone" than the weapon's base cone + + +def effective_aim_cone(character: Character, hand_slot: str, registry: DefRegistry) -> float: + """The actual cone a shot is randomized within: base spread minus skill and mod reductions.""" + held = character.get_held_item(hand_slot) + if held is None: + return 0.0 + weapon_def = registry.get_item_def(held.item_def_id) + if weapon_def.aim_cone_degrees <= 0: + return 0.0 + skill_id = weapon_def.weapon_skill or UNARMED_SKILL + effective_skill = character.bio.get(skill_id, 0) * (1.0 - character.skill_penalty(skill_id, registry)) + mod_reduction = sum(registry.get_item_def(mod_id).aimcone_reduction_degrees for mod_id in held.attachments) + cone = weapon_def.aim_cone_degrees - effective_skill * DEGREES_PER_SKILL_POINT - mod_reduction + return max(MIN_CONE_DEGREES, cone) + + +def _with_ammo_bonus(weapon_def: ItemDef, ammo_def: ItemDef) -> ItemDef: + if ammo_def.ammo_damage_bonus == 0.0: + return weapon_def + return replace(weapon_def, weapon_damage=weapon_def.weapon_damage + ammo_def.ammo_damage_bonus) + + +@dataclass +class ExplosionResult: + impact: tuple[int, int, int] + hit_entities: list[tuple[Character, float]] + + +@dataclass +class BlastResult: + """A shotgun's independent pellets, each its own ShotResult.""" + + pellets: list[ShotResult] + + +def perform_explosion( + world: World, + map_id: str, + center: tuple[int, int, int], + radius: int, + base_damage: float, +) -> ExplosionResult: + """AoE: damages every entity and every 'room' tile layer within `radius` (Chebyshev + distance), with linear falloff from the impact point. Ties into the same body-part-damage + and tile-integrity mechanisms combat/deconstruction already use. + """ + map_ = world.maps[map_id] + cx, cy, cz = center + hit_entities: list[tuple[Character, float]] = [] + for dx in range(-radius, radius + 1): + for dy in range(-radius, radius + 1): + distance = max(abs(dx), abs(dy)) + if distance > radius: + continue + x, y, z = cx + dx, cy + dy, cz + if not map_.in_bounds(x, y, z): + continue + falloff = 1.0 - (distance / (radius + 1)) + damage = base_damage * falloff + + tile = map_.get_tile(x, y, z) + if tile.room is not None: + damage_tile_layer(tile, "room", damage) + + target = world.entity_at(map_id, x, y, z) + if isinstance(target, Character): + slot = choose_hit_slot(target, precision_check=damage) + if slot is not None: + apply_body_part_damage(target, slot, damage) + hit_entities.append((target, damage)) + + return ExplosionResult(impact=center, hit_entities=hit_entities) + + +def perform_rocket_shot( + shooter: Character, + world: World, + registry: DefRegistry, + weapon_def: ItemDef, + ammo_def: ItemDef, + aim_direction: tuple[int, int, int], + cone_degrees: float, + rng: DiceRoller, +) -> ExplosionResult: + """Travels until it hits something (entity or wall) or runs out of range, then explodes. + Knockback radiates outward from the impact point (no recoil - see resolve_explosion_knockback). + """ + assert shooter.position is not None + dx, dy, dz = aim_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, + ) + effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def) + explosion = perform_explosion(world, shooter.position.map_id, impact, weapon_def.blast_radius, effective_weapon_def.weapon_damage) + resolve_explosion_knockback(world, ammo_def, impact, explosion.hit_entities) + return explosion + + +def _apply_shot_knockback( + world: World, shooter: Character, shot_result: ShotResult, ammo_def: ItemDef, aim_direction: tuple[int, int, int] +) -> None: + """Knockback lands on whoever the shot ultimately engages (direct hit, or a ricochet's + eventual target), regardless of the hit/miss/opposed-check outcome - see + resolve_ranged_knockback. Uses the original aim direction as an approximation of the + shot's travel direction (pellets/cone deviation aren't tracked precisely enough to recover + their exact path after the fact, which is a fine approximation for "basic" knockback feel). + """ + if shot_result.target_entity_id is None: + return + target = world.entities.get(shot_result.target_entity_id) + if isinstance(target, Character): + resolve_ranged_knockback(world, shooter, target, ammo_def, aim_direction) + + +def perform_shotgun_blast( + shooter: Character, + world: World, + registry: DefRegistry, + weapon_def: ItemDef, + ammo_def: ItemDef, + aim_direction: tuple[int, int, int], + cone_degrees: float, + rng: DiceRoller, +) -> BlastResult: + """Shell: pellet_count independent pellets spread across ~half the weapon's cone. Slug: + a single, higher-damage projectile in a slightly-thinner-than-base cone instead. + """ + effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def) + is_slug = ammo_def.ammo_type == "shotgun_slug" + + if is_slug: + slug_cone = cone_degrees * SLUG_CONE_FRACTION + pellets = [ + perform_shoot( + shooter, world, registry, effective_weapon_def, aim_direction, rng=rng, + cone_degrees=slug_cone, projectile_type=ammo_def.ammo_type, + ) + ] + else: + pellet_cone = cone_degrees * PELLET_SPREAD_FRACTION + pellets = [ + perform_shoot( + shooter, world, registry, effective_weapon_def, aim_direction, rng=rng, + cone_degrees=pellet_cone, projectile_type=ammo_def.ammo_type, + ) + for _ in range(weapon_def.pellet_count) + ] + + first_hit = next((p for p in pellets if p.target_entity_id is not None), None) + if first_hit is not None: + _apply_shot_knockback(world, shooter, first_hit, ammo_def, aim_direction) + + return BlastResult(pellets=pellets) + + +def fire_held_weapon( + character: Character, + world: World, + registry: DefRegistry, + hand_slot: str, + aim_direction: tuple[int, int, int], + rng: DiceRoller | None = None, +): + """The ammo-aware firing path: requires a loaded, compatible ammo-fed weapon (see + Character.reload). Routes to a rocket AoE, a shotgun blast, or a normal aimed shot, + consuming one round either way. Returns None if there's nothing to fire (unarmed, not + ammo-fed, or empty - use perform_shoot/perform_attack directly for simple weapons). + """ + rng = rng or random.Random() + held = character.get_held_item(hand_slot) + if held is None: + return None + weapon_def = registry.get_item_def(held.item_def_id) + if weapon_def.magazine_size <= 0 or held.loaded_ammo_count <= 0 or held.loaded_ammo_type is None: + return None + + # look up the actual ammo ItemDef by its ammo_type (the loaded type), not by item id - + # any item whose ammo_type matches is an equally valid stand-in for "what's loaded" + ammo_def = _find_ammo_def(registry, held.loaded_ammo_type) + if ammo_def is None: + return None + + held.loaded_ammo_count -= 1 + cone = effective_aim_cone(character, hand_slot, registry) + + if weapon_def.blast_radius > 0: + return perform_rocket_shot(character, world, registry, weapon_def, ammo_def, aim_direction, cone, rng) + if weapon_def.pellet_count > 1 or ammo_def.ammo_type == "shotgun_slug": + return perform_shotgun_blast(character, world, registry, weapon_def, ammo_def, aim_direction, cone, rng) + + effective_weapon_def = _with_ammo_bonus(weapon_def, ammo_def) + result = perform_shoot( + character, world, registry, effective_weapon_def, aim_direction, rng=rng, + cone_degrees=cone, projectile_type=ammo_def.ammo_type, + ) + _apply_shot_knockback(world, character, result, ammo_def, aim_direction) + return result + + +def _find_ammo_def(registry: DefRegistry, ammo_type: str) -> ItemDef | None: + for item_def in registry.item_defs.values(): + if item_def.ammo_type == ammo_type: + return item_def + return None diff --git a/resources/logic/steering.py b/resources/logic/steering.py new file mode 100644 index 0000000..67c65cd --- /dev/null +++ b/resources/logic/steering.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from engine.character import Character +from engine.map import MapEmbedding, rotated_size +from engine.world import World + + +def at_helm(character: Character, world: World) -> MapEmbedding | None: + """Returns the embedding a character can steer, if they're standing on a helm tile of an + embedded map (e.g. a ship's wheel) - None otherwise. + """ + if character.position is None: + return None + map_ = world.maps[character.position.map_id] + embedding = map_.parent_embedding + if embedding is None: + return None + tile = map_.get_tile(character.position.x, character.position.y, character.position.z) + room_def = world.registry.get_tile_layer_def("room", tile.layer_id("room")) + if room_def is None or not room_def.is_helm: + return None + return embedding + + +def _footprint_is_clear(world: World, embedding: MapEmbedding, anchor: tuple[int, int, int], rotation: int) -> bool: + parent = embedding.parent_map + width, height = rotated_size(embedding.child_map.width, embedding.child_map.height, rotation) + for ly in range(height): + for lx in range(width): + px, py, pz = anchor[0] + lx, anchor[1] + ly, anchor[2] + if not parent.in_bounds(px, py, pz): + return False + if not parent.get_tile(px, py, pz).is_walkable(world.registry): + return False + return True + + +def try_steer_ship(character: Character, world: World, dx: int, dy: int, dz: int = 0) -> bool: + """Moves the embedded map the character is helming by one step, if the destination footprint + is clear in the parent map. Entities aboard need no updates - only the anchor changes. + """ + embedding = at_helm(character, world) + if embedding is None: + return False + new_anchor = (embedding.anchor[0] + dx, embedding.anchor[1] + dy, embedding.anchor[2] + dz) + if not _footprint_is_clear(world, embedding, new_anchor, embedding.rotation): + return False + embedding.move_to(new_anchor) + return True + + +def try_turn_ship(character: Character, world: World, quarter_turns: int) -> bool: + """Turns the embedded map the character is helming, if the rotated footprint is clear.""" + embedding = at_helm(character, world) + if embedding is None: + return False + new_rotation = (embedding.rotation + quarter_turns) % 4 + if not _footprint_is_clear(world, embedding, embedding.anchor, new_rotation): + return False + embedding.rotate_to(new_rotation) + return True diff --git a/resources/logic/weather.py b/resources/logic/weather.py new file mode 100644 index 0000000..941b9a0 --- /dev/null +++ b/resources/logic/weather.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import random + +from engine.character import Ailment, Character +from engine.defs import DefRegistry +from engine.map import Map +from engine.world import World + +HEATSTROKE_SLOT = "torso" +HEATSTROKE_PROGRESS_PER_TICK = 5.0 +HEATSTROKE_RECOVERY_PER_TICK = 3.0 # while sheltered + +LIGHTNING_BURN_FRACTION = 0.5 # "about half" the body parts +LIGHTNING_INTEGRITY_DAMAGE = 60.0 # "large amounts" +LIGHTNING_BURN_SEVERITY = 0.9 + + +def is_exposed(map_: Map, x: float, y: float, z: float, registry: DefRegistry) -> bool: + """True if the tile has no roof layer over it - open to the sky/weather.""" + return map_.get_tile(x, y, z).roof is None + + +def apply_heatstroke_tick(character: Character, exposed: bool, ticks: int) -> None: + """Progresses (while exposed) or recovers (while sheltered) a heatstroke ailment with a + 0-100 progress meter, attached to the torso (core body temperature). Fully recovering + removes the ailment; it's otherwise created on first exposure. + """ + if ticks <= 0: + return + part = character.get_or_create_body_part_override(HEATSTROKE_SLOT) + if part is None: + return + ailment = next((a for a in part.ailments if a.id == "heatstroke"), None) + + if exposed: + if ailment is None: + ailment = Ailment(id="heatstroke", name="Heatstroke") + part.ailments.append(ailment) + ailment.progress = min(100.0, ailment.progress + HEATSTROKE_PROGRESS_PER_TICK * ticks) + ailment.severity = ailment.progress / 100.0 + elif ailment is not None: + ailment.progress = max(0.0, ailment.progress - HEATSTROKE_RECOVERY_PER_TICK * ticks) + if ailment.progress <= 0.0: + part.ailments.remove(ailment) + else: + ailment.severity = ailment.progress / 100.0 + + +def find_random_exposed_position( + map_: Map, registry: DefRegistry, rng: random.Random, z: int = 0 +) -> tuple[int, int, int] | None: + """A random walkable, unroofed tile on the map's surface (z) layer - None if there isn't one.""" + candidates = [ + (x, y, z) + for y in range(map_.height) + for x in range(map_.width) + if is_exposed(map_, x, y, z, registry) and map_.get_tile(x, y, z).is_walkable(registry) + ] + if not candidates: + return None + return rng.choice(candidates) + + +def strike_lightning( + world: World, map_id: str, registry: DefRegistry, rng: random.Random | None = None +) -> tuple[int, int, int] | None: + """Strikes a random unroofed surface tile; if a character is standing there, burns about + half their body parts badly (large integrity damage + a severe burn ailment on each). + Returns the struck position, or None if the map has no exposed tile to strike. + """ + rng = rng or random.Random() + map_ = world.maps[map_id] + position = find_random_exposed_position(map_, registry, rng) + if position is None: + return None + target = world.entity_at(map_id, *position) + if isinstance(target, Character): + _burn_random_body_parts(target, rng) + return position + + +def _burn_random_body_parts(character: Character, rng: random.Random) -> None: + slots = [p.slot for p in character.default_body_parts] + if not slots: + return + count = max(1, round(len(slots) * LIGHTNING_BURN_FRACTION)) + for slot in rng.sample(slots, min(count, len(slots))): + part = character.get_or_create_body_part_override(slot) + if part is None: + continue + part.integrity = max(0.0, part.integrity - LIGHTNING_INTEGRITY_DAMAGE) + if part.integrity <= 0.0: + part.removed = True + part.ailments.append(Ailment(id="burn", name="Burn", severity=LIGHTNING_BURN_SEVERITY, progress=100.0)) + + +def apply_weather_tick( + world: World, + registry: DefRegistry, + map_id: str, + ticks: int, + rng: random.Random | None = None, + lightning_chance_per_tick: float = 0.3, +) -> tuple[int, int, int] | None: + """Call once per game tick for a given map: progresses heatstroke for everyone there based + on roof exposure, and rolls a chance of a lightning strike per elapsed tick. Returns the + position of the last lightning strike this call, if any. + """ + rng = rng or random.Random() + map_ = world.maps[map_id] + for entity in world.entities_on_map(map_id): + if not isinstance(entity, Character) or entity.position is None: + continue + exposed = is_exposed(map_, entity.position.x, entity.position.y, entity.position.z, registry) + apply_heatstroke_tick(entity, exposed, ticks) + + struck = None + for _ in range(ticks): + if rng.random() < lightning_chance_per_tick: + struck = strike_lightning(world, map_id, registry, rng) or struck + return struck diff --git a/server.py b/server.py new file mode 100644 index 0000000..a1e4f5b --- /dev/null +++ b/server.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import argparse +import asyncio + +from engine.network.protocol import DEFAULT_PORT +from engine.network.server import GameServer + + +async def run(host: str, port: int) -> None: + server = GameServer(host=host, port=port) + await server.start() + print(f"Listening on {host}:{server.port}") + await server.serve_forever() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Dedicated multiplayer server.") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + args = parser.parse_args() + asyncio.run(run(args.host, args.port)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_abilities.py b/tests/test_abilities.py new file mode 100644 index 0000000..6cfb4fb --- /dev/null +++ b/tests/test_abilities.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def test_species_def_carries_sample_abilities(registry): + species = registry.get_species_def("human") + ability_ids = {a.id for a in species.abilities} + assert ability_ids == {"leap", "melee_attack", "ranged_attack", "time_warp_sphere"} + leap = next(a for a in species.abilities if a.id == "leap") + assert leap.required_slots == ("left_leg", "right_leg") + + +def test_can_perform_ability_true_when_required_parts_present(registry): + character = make_human(registry) + assert character.can_perform_ability("leap", registry) is True + + +def test_can_perform_ability_false_when_a_required_part_is_missing(registry): + character = make_human(registry, body_parts=[BodyPart("left_leg", "human_left_leg", removed=True)]) + assert character.can_perform_ability("leap", registry) is False + + +def test_can_perform_ability_false_for_unknown_ability(registry): + character = make_human(registry) + assert character.can_perform_ability("fly", registry) is False + + +def test_can_perform_ability_false_without_species(registry): + character = Character() + assert character.can_perform_ability("leap", registry) is False + + +def test_world_try_leap_gated_by_missing_legs(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + + legless = make_human( + registry, + position=EntityPosition("field", 2, 2, 0), + body_parts=[BodyPart("left_leg", "human_left_leg", removed=True)], + ) + assert world.try_leap(legless, 1, 0, 0) is False + assert (legless.position.x, legless.position.y) == (2, 2) # unchanged + + +def test_world_try_leap_allowed_with_both_legs(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + + healthy = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + assert world.try_leap(healthy, 1, 0, 0) is True + assert (healthy.position.x, healthy.position.y) == (4, 2) diff --git a/tests/test_ability_bar.py b/tests/test_ability_bar.py new file mode 100644 index 0000000..05220c9 --- /dev/null +++ b/tests/test_ability_bar.py @@ -0,0 +1,113 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.ui import AbilityBar, AbilityBarSlot +from engine.world import World +from resources.logic.actions import activate_ability_bar_slot + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 15, 15, 1) + for x in range(15): + for y in range(15): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +# --- AbilityBar state --------------------------------------------------------------------- + + +def test_ability_bar_starts_with_ten_empty_slots(): + bar = AbilityBar() + assert len(bar.slots) == 10 + assert all(slot is None for slot in bar.slots) + assert bar.selected_index is None + assert bar.selected_slot() is None + + +def test_assign_places_a_slot_at_the_given_index(): + bar = AbilityBar() + slot = AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="time_warp_sphere") + bar.assign(2, slot) + assert bar.slots[2] is slot + + +def test_select_sets_selected_index_and_selected_slot_reads_it_back(): + bar = AbilityBar() + slot = AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere") + bar.assign(5, slot) + bar.select(5) + assert bar.selected_index == 5 + assert bar.selected_slot() is slot + + +def test_select_out_of_range_is_ignored(): + bar = AbilityBar() + bar.select(0) + bar.select(99) + assert bar.selected_index == 0 # unchanged by the invalid select + + +def test_mouse_click_and_hotkey_both_just_call_select(): + # No separate "click" concept - a mouse click on bar position i and pressing numkey i+1 + # both resolve to the same select(i) call once input is wired to a bar index. + bar = AbilityBar() + slot = AbilityBarSlot(kind="hand", source_id="left_hand", ability_id="time_warp_sphere") + bar.assign(0, slot) + bar.select(0) + assert bar.selected_slot() is slot + + +# --- activate_ability_bar_slot resolver ----------------------------------------------------- + + +def test_activate_ability_bar_slot_dispatches_to_hand(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + caster.wield_item("right_hand", "chronowand", registry) + world.add_entity(caster) + + slot = AbilityBarSlot(kind="hand", source_id="right_hand", ability_id="time_warp_sphere") + result = activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0) + assert result is not None + assert len(world.time_warp_zones) == 1 + + +def test_activate_ability_bar_slot_dispatches_to_implant(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + + slot = AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere") + result = activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0) + assert result is not None + assert len(world.time_warp_zones) == 1 + + +def test_activate_ability_bar_slot_unknown_kind_returns_none(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + world.add_entity(caster) + + slot = AbilityBarSlot(kind="bogus", source_id="right_hand", ability_id="time_warp_sphere") + assert activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=3.0) is None diff --git a/tests/test_ability_cooldowns.py b/tests/test_ability_cooldowns.py new file mode 100644 index 0000000..1812880 --- /dev/null +++ b/tests/test_ability_cooldowns.py @@ -0,0 +1,137 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.ui import AbilityBarSlot +from engine.world import World +from resources.logic.actions import ABILITY_COOLDOWNS, activate_ability_bar_slot, activate_hand, activate_implant + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +# --- Character.is_ability_ready / start_ability_cooldown ------------------------------------ + + +def test_ability_ready_by_default(registry): + character = make_human(registry) + assert character.is_ability_ready("time_warp_sphere", now=0.0) is True + + +def test_start_ability_cooldown_blocks_until_it_expires(registry): + character = make_human(registry) + character.start_ability_cooldown("time_warp_sphere", now=10.0, duration=5.0) + assert character.is_ability_ready("time_warp_sphere", now=12.0) is False + assert character.is_ability_ready("time_warp_sphere", now=15.0) is True + + +def test_zero_duration_cooldown_is_a_no_op(registry): + character = make_human(registry) + character.start_ability_cooldown("time_warp_sphere", now=10.0, duration=0.0) + assert character.is_ability_ready("time_warp_sphere", now=10.0) is True + + +# --- activate_hand (chronowand) respects cooldown -------------------------------------------- + + +def test_chronowand_cast_starts_a_cooldown(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.wield_item("right_hand", "chronowand", registry) + world.add_entity(caster) + + activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0) + assert caster.is_ability_ready("time_warp_sphere", now=0.0) is False + + +def test_chronowand_cannot_recast_while_on_cooldown(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.wield_item("right_hand", "chronowand", registry) + world.add_entity(caster) + + activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0) + result = activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=1.0) + assert result is None + assert len(world.time_warp_zones) == 1 # still just the first cast + + +def test_chronowand_can_recast_after_cooldown_expires(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.wield_item("right_hand", "chronowand", registry) + world.add_entity(caster) + + activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0) + cooldown = ABILITY_COOLDOWNS["time_warp_sphere"] + result = activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=cooldown + 0.1) + assert result is not None + assert len(world.time_warp_zones) == 2 + + +# --- activate_implant respects cooldown ------------------------------------------------------- + + +def test_implant_cannot_recast_while_on_cooldown(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + + activate_implant(caster, world, registry, "chronoimplant", now=0.0) + result = activate_implant(caster, world, registry, "chronoimplant", now=1.0) + assert result is None + assert len(world.time_warp_zones) == 1 + + +# --- cooldown is shared across activation paths (hand, implant, ability bar) ------------------ + + +def test_cooldown_is_shared_between_hand_and_implant_paths(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.wield_item("right_hand", "chronowand", registry) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + + activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=0.0) + # same underlying ability (time_warp_sphere), so the implant is also on cooldown now + result = activate_implant(caster, world, registry, "chronoimplant", now=1.0) + assert result is None + assert len(world.time_warp_zones) == 1 + + +def test_ability_bar_slot_respects_cooldown_too(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + slot = AbilityBarSlot(kind="implant", source_id="chronoimplant", ability_id="time_warp_sphere") + + activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=0.0) + result = activate_ability_bar_slot(caster, world, registry, slot, (1, 0, 0), now=1.0) + assert result is None + assert len(world.time_warp_zones) == 1 diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..f7c77b3 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,141 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.actions import activate_hand + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world, field + + +class FixedRng: + def __init__(self, roll=15, choice_value="torso"): + self._roll = roll + self._choice_value = choice_value + + def randint(self, _lo, _hi): + return self._roll + + def choice(self, seq): + return self._choice_value if self._choice_value is not None else seq[0] + + +def test_empty_hand_attacks_unarmed(registry): + world, _field = make_world(registry) + attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + attacker.bio["athletics"] = 10 + defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) + world.add_entity(attacker) + world.add_entity(defender) + + result = activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng()) + assert result is not None + assert result.hit is True + assert result.weapon_bonus == 0.0 + + +def test_empty_hand_with_no_target_ahead_is_a_no_op(registry): + world, _field = make_world(registry) + attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + world.add_entity(attacker) + assert activate_hand(attacker, world, registry, "right_hand", (1, 0, 0)) is None + + +def test_wielded_melee_weapon_attacks(registry): + world, _field = make_world(registry) + attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + attacker.wield_item("right_hand", "staff_oak", registry) + defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) + world.add_entity(attacker) + world.add_entity(defender) + + result = activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng()) + assert result.weapon_bonus == pytest.approx(6.0) # staff_oak + + +def test_wielded_gun_shoots(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + shooter.wield_item("right_hand", "pistol_basic", registry) + target = make_human(registry, position=EntityPosition("field", 3, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + + result = activate_hand(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng()) + assert result.hit is True + assert result.target_entity_id == target.entity_id + + +def test_wielded_deconstructor_interacts_with_tile_ahead(registry): + world, field = make_world(registry) + field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall") + character = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + character.wield_item("right_hand", "multi_deconstructor", registry) + world.add_entity(character) + + result = activate_hand(character, world, registry, "right_hand", (1, 0, 0)) + assert result is True + assert field.get_tile(6, 5, 0).room is None + + +def test_left_hand_deconstructor_uses_right_click_semantics(registry): + world, field = make_world(registry) + field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall") + character = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + character.wield_item("left_hand", "multi_deconstructor", registry) + character.inventory = Inventory(4, 4) + world.add_entity(character) + + result = activate_hand(character, world, registry, "left_hand", (1, 0, 0)) + assert result is not None + assert result.def_id == "wood_planks" # right-click behavior: yields an item + assert field.get_tile(6, 5, 0).room is None + + +def test_wielded_constructor_builds_on_bare_tile_ahead(registry): + world, field = make_world(registry) + character = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + character.wield_item("right_hand", "constructor_tool", registry) + character.inventory = Inventory(4, 4) + planks_def = registry.get_item_def("wood_planks") + character.inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0) + world.add_entity(character) + + result = activate_hand(character, world, registry, "right_hand", (1, 0, 0)) + assert result is True + assert field.get_tile(6, 5, 0).room.def_id == "wood_wall" + + +def test_deconstructor_out_of_bounds_target_is_a_no_op(registry): + world, _field = make_world(registry) + character = make_human(registry, position=EntityPosition("field", 0, 0, 0)) + character.wield_item("right_hand", "multi_deconstructor", registry) + world.add_entity(character) + + assert activate_hand(character, world, registry, "right_hand", (-1, 0, 0)) is None diff --git a/tests/test_aim.py b/tests/test_aim.py new file mode 100644 index 0000000..7b37c6e --- /dev/null +++ b/tests/test_aim.py @@ -0,0 +1,95 @@ +import math + +import pytest + +from engine.aim import apply_cone_deviation, snap_to_8way +from engine.camera import OrthoCamera + + +class FixedUniformRng: + def __init__(self, value: float): + self._value = value + + def uniform(self, _a, _b): + return self._value + + +@pytest.mark.parametrize( + "dx,dy,expected", + [ + (5, 0, (1, 0)), + (0, 5, (0, 1)), + (-5, 0, (-1, 0)), + (0, -5, (0, -1)), + (5, 5, (1, 1)), + (-5, 5, (-1, 1)), + (-5, -5, (-1, -1)), + (5, -5, (1, -1)), + (0, 0, (0, 1)), + (5, 0.1, (1, 0)), # nearly east, snaps to east not northeast + ], +) +def test_snap_to_8way(dx, dy, expected): + assert snap_to_8way(dx, dy) == expected + + +def test_snap_to_8way_covers_full_circle(): + for degrees in range(0, 360, 5): + radians = math.radians(degrees) + dx, dy = math.cos(radians), math.sin(radians) + dirx, diry = snap_to_8way(dx, dy) + assert (dirx, diry) != (0, 0) + assert abs(dirx) <= 1 and abs(diry) <= 1 + + +def test_apply_cone_deviation_with_zero_cone_is_a_no_op(): + assert apply_cone_deviation(1, 0, 0.0, FixedUniformRng(50.0)) == (1, 0) + + +def test_apply_cone_deviation_with_zero_deviation_keeps_direction(): + assert apply_cone_deviation(1, 0, 30.0, FixedUniformRng(0.0)) == (1, 0) + + +def test_apply_cone_deviation_can_shift_to_an_adjacent_direction(): + # east (1,0) deviated by +40 degrees should snap to northeast (1,1) -> y-down world, + # so a positive angle rotates toward (0,1)/south side + result = apply_cone_deviation(1, 0, 90.0, FixedUniformRng(40.0)) + assert result != (1, 0) + assert abs(result[0]) <= 1 and abs(result[1]) <= 1 + + +def test_apply_cone_deviation_stays_within_grid_directions(): + rng = FixedUniformRng(179.0) + for base_dx, base_dy in [(1, 0), (0, 1), (-1, 0), (0, -1)]: + dirx, diry = apply_cone_deviation(base_dx, base_dy, 20.0, rng) + assert (dirx, diry) != (0, 0) + + +def test_screen_to_world_center_of_viewport_is_camera_target(): + camera = OrthoCamera(viewport_w=800, viewport_h=600, tiles_visible_y=12.0) + camera.set_target(5.0, 5.0) + wx, wy = camera.screen_to_world(400, 300) + assert wx == pytest.approx(5.0, abs=0.05) + assert wy == pytest.approx(5.0, abs=0.05) + + +def test_screen_to_world_top_left_is_up_and_left_of_target(): + camera = OrthoCamera(viewport_w=800, viewport_h=600, tiles_visible_y=12.0) + camera.set_target(5.0, 5.0) + wx, wy = camera.screen_to_world(0, 0) + assert wx < 5.0 + assert wy < 5.0 + + +def test_screen_to_world_round_trips_with_scale_offset(): + camera = OrthoCamera(viewport_w=800, viewport_h=600, tiles_visible_y=12.0) + camera.set_target(3.0, 7.0) + (scale_x, scale_y), (offset_x, offset_y) = camera.scale_offset() + + wx, wy = camera.screen_to_world(200, 450) + ndc_x = wx * scale_x + offset_x + ndc_y = wy * scale_y + offset_y + + # (200,450) in an 800x600 viewport -> ndc (-0.5, -0.5) in the y-up NDC space + assert ndc_x == pytest.approx(-0.5, abs=1e-6) + assert ndc_y == pytest.approx(-0.5, abs=1e-6) diff --git a/tests/test_armor.py b/tests/test_armor.py new file mode 100644 index 0000000..3d4dee9 --- /dev/null +++ b/tests/test_armor.py @@ -0,0 +1,65 @@ +from pathlib import Path + +import pytest + +from engine.character import ClothingPiece, Character +from engine.defs import DefRegistry +from resources.logic.combat import defender_armor, perform_attack + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +class FixedRng: + def __init__(self, roll: int, choice_value=None): + self._roll = roll + self._choice_value = choice_value + + def randint(self, _lo, _hi): + return self._roll + + def choice(self, seq): + return self._choice_value if self._choice_value is not None else seq[0] + + +def test_defender_armor_sums_worn_clothing(registry): + defender = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")]) + assert defender_armor(defender, registry) == pytest.approx(3.0) # leather_jacket's armor_reduction + + +def test_unarmored_defender_has_zero_armor(registry): + defender = make_human(registry) + assert defender_armor(defender, registry) == 0.0 + + +def test_armor_reduces_damage_taken(registry): + attacker = make_human(registry) + attacker.bio["athletics"] = 10 + + unarmored = make_human(registry) + armored = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")]) + + unarmored_result = perform_attack(attacker, unarmored, registry, rng=FixedRng(roll=15, choice_value="torso")) + armored_result = perform_attack(attacker, armored, registry, rng=FixedRng(roll=15, choice_value="torso")) + + assert armored_result.armor_reduction == pytest.approx(3.0) + assert armored_result.damage == pytest.approx(max(0.0, unarmored_result.damage - 3.0)) + + +def test_heavy_armor_can_absorb_a_hit_entirely(registry): + attacker = make_human(registry) + defender = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")]) + + # a roll that only barely clears the DC: margin (1) is smaller than armor_reduction (3) + result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=11, choice_value="torso")) + assert result.hit is True # the check itself succeeded... + assert result.damage == 0.0 # ...but armor soaked up all of it + assert defender.get_body_part("torso").integrity == pytest.approx(100.0) diff --git a/tests/test_blocking.py b/tests/test_blocking.py new file mode 100644 index 0000000..a344d75 --- /dev/null +++ b/tests/test_blocking.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from resources.logic.combat import perform_attack + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +class SequenceRng: + """Deterministic stand-in: returns rolls from a fixed sequence, one call at a time.""" + + def __init__(self, rolls: list[int], choice_value=None): + self._rolls = list(rolls) + self._choice_value = choice_value + + def randint(self, _lo, _hi): + return self._rolls.pop(0) + + def choice(self, seq): + return self._choice_value if self._choice_value is not None else seq[0] + + +def test_non_blocking_defender_uses_flat_difficulty(registry): + attacker = make_human(registry) + defender = make_human(registry) + result = perform_attack(attacker, defender, registry, rng=SequenceRng([15], choice_value="torso")) + assert result.blocked_check is False + assert result.hit is True # 15 > BASE_DIFFICULTY(10) + + +def test_blocking_defender_uses_opposed_check(registry): + attacker = make_human(registry) + attacker.bio["athletics"] = 0 + defender = make_human(registry, is_blocking=True) + defender.bio["athletics"] = 15 # strong defense roll + + # attacker rolls 12, defender rolls 5 but has a high skill -> defender wins + result = perform_attack(attacker, defender, registry, rng=SequenceRng([12, 5], choice_value="torso")) + assert result.blocked_check is True + assert result.hit is False + + +def test_blocking_defender_can_still_be_hit_if_attacker_beats_the_opposed_check(registry): + attacker = make_human(registry) + attacker.bio["athletics"] = 15 + defender = make_human(registry, is_blocking=True) + defender.bio["athletics"] = 0 + + result = perform_attack(attacker, defender, registry, rng=SequenceRng([18, 3], choice_value="torso")) + assert result.blocked_check is True + assert result.hit is True + + +def test_shield_bonus_helps_the_defender_block(registry): + attacker = make_human(registry) # bio athletics=0, so attacker_check == the raw roll + + unshielded = make_human(registry, is_blocking=True) + shielded = make_human(registry, is_blocking=True) + shielded.wield_item("left_hand", "wooden_shield", registry) + + # attacker rolls 10 (check=10), defender rolls 8 (check=8) both times; + # only wooden_shield's +4 block bonus should flip the outcome + result_unshielded = perform_attack(attacker, unshielded, registry, rng=SequenceRng([10, 8], choice_value="torso")) + result_shielded = perform_attack(attacker, shielded, registry, rng=SequenceRng([10, 8], choice_value="torso")) + + assert result_unshielded.hit is True # 10 > 8 + assert result_shielded.hit is False # 10 < 8 + 4 (shield bonus) diff --git a/tests/test_character_creator.py b/tests/test_character_creator.py new file mode 100644 index 0000000..8abd421 --- /dev/null +++ b/tests/test_character_creator.py @@ -0,0 +1,195 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from engine.character_creator import CharacterCreator +from engine.character_library import CharacterLibrary +from engine.defs import DefRegistry + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +@pytest.fixture() +def library(): + return CharacterLibrary(sqlite3.connect(":memory:")) + + +# --- species / gender selection ----------------------------------------------------------- + + +def test_set_species_defaults_gender_to_the_first_valid_option(registry): + creator = CharacterCreator(registry) + assert creator.set_species("human") is True + assert creator.species_id == "human" + assert creator.gender == "male" + + +def test_set_species_rejects_unknown_species(registry): + creator = CharacterCreator(registry) + assert creator.set_species("dragon") is False + assert creator.species_id is None + + +def test_set_gender_accepts_a_valid_option(registry): + creator = CharacterCreator(registry) + creator.set_species("human") + assert creator.set_gender("nonbinary") is True + assert creator.gender == "nonbinary" + + +def test_set_gender_rejects_option_not_valid_for_species(registry): + creator = CharacterCreator(registry) + creator.set_species("alien_tri") # genders: alpha/beta/gamma + assert creator.set_gender("male") is False + assert creator.gender == "alpha" # unchanged, still the auto-picked default + + +def test_set_gender_before_species_is_a_no_op(registry): + creator = CharacterCreator(registry) + assert creator.set_gender("male") is False + assert creator.gender is None + + +def test_switching_species_resets_gender_if_no_longer_valid(registry): + creator = CharacterCreator(registry) + creator.set_species("human") + creator.set_gender("nonbinary") + creator.set_species("bot") # monogender, "none" - "nonbinary" isn't valid here + assert creator.gender == "none" + + +def test_switching_species_keeps_gender_if_still_valid(registry): + creator = CharacterCreator(registry) + creator.set_species("human") + creator.set_gender("female") + creator.set_species("dog") # dog also has "male"/"female" + assert creator.gender == "female" + + +# --- skill point budget --------------------------------------------------------------------- + + +def test_starts_with_the_full_budget_unspent(registry): + creator = CharacterCreator(registry) + assert creator.spent_skill_points() == 0 + assert creator.remaining_skill_points() == creator.skill_points_budget + + +def test_increase_skill_spends_a_point(registry): + creator = CharacterCreator(registry) + assert creator.increase_skill("stealth") is True + assert creator.bio["stealth"] == 1 + assert creator.remaining_skill_points() == creator.skill_points_budget - 1 + + +def test_increase_skill_fails_once_budget_exhausted(registry): + creator = CharacterCreator(registry, skill_points_budget=2) + creator.increase_skill("stealth") + creator.increase_skill("stealth") + assert creator.increase_skill("athletics") is False + assert creator.bio["athletics"] == 0 + + +def test_increase_skill_fails_past_max_level(registry): + creator = CharacterCreator(registry, skill_points_budget=99, max_skill_level=2) + creator.increase_skill("stealth") + creator.increase_skill("stealth") + assert creator.increase_skill("stealth") is False + assert creator.bio["stealth"] == 2 + + +def test_increase_skill_rejects_unknown_skill(registry): + creator = CharacterCreator(registry) + assert creator.increase_skill("lockpicking") is False + + +def test_decrease_skill_refunds_a_point(registry): + creator = CharacterCreator(registry) + creator.increase_skill("stealth") + assert creator.decrease_skill("stealth") is True + assert creator.bio["stealth"] == 0 + assert creator.remaining_skill_points() == creator.skill_points_budget + + +def test_decrease_skill_below_zero_is_a_no_op(registry): + creator = CharacterCreator(registry) + assert creator.decrease_skill("stealth") is False + + +# --- body part preview ------------------------------------------------------------------------ + + +def test_preview_body_parts_empty_before_species_chosen(registry): + creator = CharacterCreator(registry) + assert creator.preview_body_parts() == [] + + +def test_preview_body_parts_matches_species_defaults(registry): + creator = CharacterCreator(registry) + creator.set_species("dog") + slots = {p.slot for p in creator.preview_body_parts()} + assert slots == {"head", "torso", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"} + + +# --- readiness / build_character ---------------------------------------------------------------- + + +def test_not_ready_without_name_species_and_gender(registry): + creator = CharacterCreator(registry) + assert creator.is_ready() is False + creator.set_name("Vex") + assert creator.is_ready() is False + creator.set_species("human") + assert creator.is_ready() is True # species auto-fills a valid gender + + +def test_build_character_returns_none_when_not_ready(registry): + creator = CharacterCreator(registry) + creator.set_name("Vex") + assert creator.build_character() is None + + +def test_build_character_produces_a_character_with_chosen_traits(registry): + creator = CharacterCreator(registry) + creator.set_name(" Vex ") + creator.set_species("reptilian_quad") + creator.increase_skill("athletics") + + character = creator.build_character() + assert character is not None + assert character.name == "Vex" # whitespace trimmed + assert character.species_id == "reptilian_quad" + assert character.gender == "none" + assert character.bio["athletics"] == 1 + assert {p.slot for p in character.default_body_parts} == { + "head", "torso", "left_arm", "right_arm", "left_arm_2", "right_arm_2", "left_leg", "right_leg", + } + + +# --- confirm: saves into the library ------------------------------------------------------------- + + +def test_confirm_saves_into_the_library(registry, library): + creator = CharacterCreator(registry) + creator.set_name("Vex") + creator.set_species("human") + + character = creator.confirm(library, "acct-1", "char-1") + assert character is not None + + loaded = library.load_character("acct-1", "char-1", registry) + assert loaded is not None + assert loaded.name == "Vex" + assert loaded.species_id == "human" + + +def test_confirm_does_nothing_when_not_ready(registry, library): + creator = CharacterCreator(registry) + assert creator.confirm(library, "acct-1", "char-1") is None + assert library.load_character("acct-1", "char-1", registry) is None diff --git a/tests/test_character_fallback.py b/tests/test_character_fallback.py new file mode 100644 index 0000000..ca4f5d4 --- /dev/null +++ b/tests/test_character_fallback.py @@ -0,0 +1,45 @@ +from engine.character import BodyPart, Character + + +def test_get_body_part_falls_back_to_default_when_missing(): + character = Character( + default_body_parts=[ + BodyPart("head", "human_head"), + BodyPart("torso", "human_torso"), + ], + ) + assert character.get_body_part("head").part_def_id == "human_head" + assert character.get_body_part("torso").part_def_id == "human_torso" + + +def test_get_body_part_prefers_explicit_override_over_default(): + character = Character( + body_parts=[BodyPart("head", "human_head_hat")], + default_body_parts=[ + BodyPart("head", "human_head"), + BodyPart("torso", "human_torso"), + ], + ) + assert character.get_body_part("head").part_def_id == "human_head_hat" + assert character.get_body_part("torso").part_def_id == "human_torso" # still falls back + + +def test_get_body_part_returns_none_for_unknown_slot(): + character = Character(default_body_parts=[BodyPart("head", "human_head")]) + assert character.get_body_part("left_arm") is None + + +def test_resolved_body_parts_covers_union_of_both_arrays(): + character = Character( + body_parts=[BodyPart("head", "human_head_hat")], + default_body_parts=[ + BodyPart("head", "human_head"), + BodyPart("torso", "human_torso"), + BodyPart("left_arm", "human_left_arm"), + ], + ) + resolved = character.resolved_body_parts() + assert resolved["head"].part_def_id == "human_head_hat" + assert resolved["torso"].part_def_id == "human_torso" + assert resolved["left_arm"].part_def_id == "human_left_arm" + assert set(resolved.keys()) == {"head", "torso", "left_arm"} diff --git a/tests/test_character_health.py b/tests/test_character_health.py new file mode 100644 index 0000000..299cf66 --- /dev/null +++ b/tests/test_character_health.py @@ -0,0 +1,121 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character, ClothingPiece +from engine.defs import DefRegistry + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character( + species_id="human", + gender="female", + default_body_parts=registry.new_default_body_parts("human"), + **kwargs, + ) + + +def test_blood_status_thresholds(registry): + species = registry.get_species_def("human") + character = make_human(registry, blood_percentage=100.0) + assert character.blood_status(species) == "normal" + + character.blood_percentage = 50.0 + assert character.blood_status(species) == "danger" + + character.blood_percentage = 10.0 + assert character.blood_status(species) == "critical" + + +def test_bio_defaults_to_zeroed_dnd_skills(registry): + character = make_human(registry) + assert character.bio["stealth"] == 0 + assert character.bio["athletics"] == 0 + assert set(character.bio.keys()) >= {"perception", "insight", "acrobatics"} + + +def test_mental_stats_optional_by_default(registry): + character = make_human(registry) + assert character.mental_stats is None + + npc = make_human(registry, mental_stats={"insanity": 0.0}) + assert npc.mental_stats == {"insanity": 0.0} + + +def test_skill_penalty_zero_when_all_parts_healthy(registry): + character = make_human(registry) + assert character.skill_penalty("acrobatics", registry) == 0.0 + + +def test_skill_penalty_full_when_relevant_parts_missing(registry): + character = make_human(registry) + character.body_parts = [ + BodyPart("left_leg", "human_left_leg", removed=True), + BodyPart("right_leg", "human_right_leg", removed=True), + ] + # acrobatics is weighted only on the legs (0.5 each) -> fully lost when both are gone + assert character.skill_penalty("acrobatics", registry) == pytest.approx(1.0) + # athletics also draws from torso/arms, which are untouched -> partial loss + penalty = character.skill_penalty("athletics", registry) + assert 0.0 < penalty < 1.0 + + +def test_skill_penalty_scales_with_integrity_not_just_removal(registry): + character = make_human(registry) + character.body_parts = [BodyPart("head", "human_head", integrity=50.0)] + penalty = character.skill_penalty("perception", registry) + assert penalty == pytest.approx(0.5) + + +def test_get_body_part_returns_none_when_explicitly_removed(registry): + character = make_human(registry) + character.body_parts = [BodyPart("left_arm", "human_left_arm", removed=True)] + assert character.get_body_part("left_arm") is None + assert character.get_body_part("right_arm") is not None # still falls back to default + + +def test_get_clothing_falls_back_to_default_like_body_parts(registry): + character = make_human( + registry, + default_clothing=registry.new_default_clothing("player"), + ) + assert character.get_clothing("back").item_def_id == "backpack_basic" + + +def test_get_clothing_override_beats_default_and_removed_returns_none(registry): + character = make_human( + registry, + clothing=[ClothingPiece("torso", "leather_jacket")], + default_clothing=registry.new_default_clothing("player"), + ) + assert character.get_clothing("torso").item_def_id == "leather_jacket" + assert character.get_clothing("back").item_def_id == "backpack_basic" # still falls back + + character.clothing.append(ClothingPiece("back", "backpack_basic", removed=True)) + assert character.get_clothing("back") is None + + +def test_equipped_backpack_grants_a_lazily_created_inventory(registry): + character = make_human( + registry, + default_clothing=registry.new_default_clothing("player"), + ) + assert character.get_clothing("back").inventory is None + + inventory = character.get_equipped_inventory("back", registry) + assert (inventory.width, inventory.height) == (4, 4) + # same instance on repeated access, and matches what's stored on the clothing piece + assert character.get_equipped_inventory("back", registry) is inventory + assert character.get_clothing("back").inventory is inventory + + +def test_equipped_inventory_is_none_for_non_container_clothing(registry): + character = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")]) + assert character.get_equipped_inventory("torso", registry) is None diff --git a/tests/test_character_library.py b/tests/test_character_library.py new file mode 100644 index 0000000..72578e4 --- /dev/null +++ b/tests/test_character_library.py @@ -0,0 +1,179 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from engine.character import Ailment, BodyPart, Character, ClothingPiece, HeldItem, Organ +from engine.character_library import CharacterLibrary +from engine.defs import DefRegistry +from engine.inventory import Inventory +from engine.item import ItemInstance + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +@pytest.fixture() +def library(): + return CharacterLibrary(sqlite3.connect(":memory:")) + + +def build_character(registry) -> Character: + character = Character( + entity_id="ignored-on-save", # library keys on (account_id, character_id), not entity_id + name="Vex", + species_id="reptilian_quad", + gender="none", + blood_percentage=88.0, + strength=14, + body_parts=[ + BodyPart( + "torso", "reptilian_torso", integrity=70.0, + ailments=[Ailment("poisoned", "Poisoned", severity=0.3, progress=12.0)], + organs=[Organ("reptilian_heart", integrity=60.0)], + ), + ], + default_body_parts=registry.new_default_body_parts("reptilian_quad"), + clothing=[ClothingPiece("torso", "leather_jacket")], + default_clothing=[], + held_items=[HeldItem("right_hand", "chronowand", attachments=["scope_basic"])], + implants=["chronoimplant"], + mental_stats={"insanity": 4.0}, + ) + character.bio["stealth"] = 5 + backpack = Inventory(4, 4) + backpack.place(ItemInstance("rope-1", "sandwich"), registry.get_item_def("sandwich"), 0, 0) + character.clothing[0] = ClothingPiece("torso", "backpack_basic") + character.clothing[0].inventory = backpack + character.inventory = Inventory(6, 5) + character.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0) + return character + + +def test_save_and_load_roundtrip_preserves_identity_and_species(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + + loaded = library.load_character("account-1", "char-1", registry) + assert loaded is not None + assert loaded.name == "Vex" + assert loaded.species_id == "reptilian_quad" + assert loaded.gender == "none" + assert loaded.blood_percentage == pytest.approx(88.0) + assert loaded.strength == 14 + assert loaded.position is None # portable template, not tied to any world + + +def test_roundtrip_preserves_body_parts_ailments_and_organs(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + loaded = library.load_character("account-1", "char-1", registry) + + torso = loaded.get_body_part("torso") + assert torso.part_def_id == "reptilian_torso" + assert torso.integrity == pytest.approx(70.0) + assert [a.id for a in torso.ailments] == ["poisoned"] + assert torso.ailments[0].progress == pytest.approx(12.0) + heart = next(o for o in torso.organs if o.organ_def_id == "reptilian_heart") + assert heart.integrity == pytest.approx(60.0) + + # default (non-overridden) parts and their organs came back too + head = loaded.get_body_part("head") + assert head.part_def_id == "reptilian_head" + assert {o.organ_def_id for o in head.organs} == {"reptilian_brain"} + + +def test_roundtrip_preserves_clothing_and_its_granted_inventory(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + loaded = library.load_character("account-1", "char-1", registry) + + backpack = loaded.get_clothing("torso") + assert backpack.item_def_id == "backpack_basic" + assert backpack.inventory is not None + assert {p.item.item_id for p in backpack.inventory.items()} == {"rope-1"} + + +def test_roundtrip_preserves_held_items_and_attachments(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + loaded = library.load_character("account-1", "char-1", registry) + + held = loaded.get_held_item("right_hand") + assert held.item_def_id == "chronowand" + assert held.attachments == ["scope_basic"] + + +def test_roundtrip_preserves_implants(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + loaded = library.load_character("account-1", "char-1", registry) + assert loaded.implants == ["chronoimplant"] + + +def test_roundtrip_preserves_bio_and_mental_stats(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + loaded = library.load_character("account-1", "char-1", registry) + assert loaded.bio["stealth"] == 5 + assert loaded.mental_stats == {"insanity": 4.0} + + +def test_roundtrip_preserves_top_level_inventory(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + loaded = library.load_character("account-1", "char-1", registry) + placed = {p.item.item_id: (p.x, p.y) for p in loaded.inventory.items()} + assert placed == {"staff-1": (0, 0)} + + +def test_load_character_returns_none_when_not_found(registry, library): + assert library.load_character("account-1", "no-such-char", registry) is None + + +def test_list_characters_returns_id_name_species_for_an_account(registry, library): + library.save_character("account-1", "char-1", build_character(registry)) + other = build_character(registry) + other.name = "Kess" + library.save_character("account-1", "char-2", other) + library.save_character("account-2", "char-3", build_character(registry)) # different account + + listed = library.list_characters("account-1") + assert {(cid, name) for cid, name, species in listed if species} == {("char-1", "Vex"), ("char-2", "Kess")} + + +def test_delete_character_removes_it_and_leaves_others(registry, library): + library.save_character("account-1", "char-1", build_character(registry)) + library.save_character("account-1", "char-2", build_character(registry)) + + library.delete_character("account-1", "char-1") + + assert library.load_character("account-1", "char-1", registry) is None + assert library.load_character("account-1", "char-2", registry) is not None + + +def test_saving_twice_under_the_same_id_overwrites_not_duplicates(registry, library): + character = build_character(registry) + library.save_character("account-1", "char-1", character) + character.blood_percentage = 50.0 + library.save_character("account-1", "char-1", character) + + loaded = library.load_character("account-1", "char-1", registry) + assert loaded.blood_percentage == pytest.approx(50.0) + assert len(library.list_characters("account-1")) == 1 + + +def test_same_character_id_isolated_between_accounts(registry, library): + a = build_character(registry) + a.name = "Account A's Vex" + b = build_character(registry) + b.name = "Account B's Vex" + library.save_character("account-a", "char-1", a) + library.save_character("account-b", "char-1", b) + + assert library.load_character("account-a", "char-1", registry).name == "Account A's Vex" + assert library.load_character("account-b", "char-1", registry).name == "Account B's Vex" diff --git a/tests/test_character_menu.py b/tests/test_character_menu.py new file mode 100644 index 0000000..4002f6f --- /dev/null +++ b/tests/test_character_menu.py @@ -0,0 +1,94 @@ +from pathlib import Path + +import pytest + +from engine.character import Character, ClothingPiece +from engine.defs import DefRegistry +from engine.ui import CharacterMenu + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def test_defaults_to_the_bio_tab(): + menu = CharacterMenu() + assert menu.active_tab == "bio" + + +def test_select_tab_switches_to_equipment(): + menu = CharacterMenu() + menu.select_tab("equipment") + assert menu.active_tab == "equipment" + + +def test_select_tab_ignores_unknown_tab_names(): + menu = CharacterMenu() + menu.select_tab("inventory") # not a real tab + assert menu.active_tab == "bio" + + +def test_switching_tabs_closes_any_open_backpack_popup(registry): + menu = CharacterMenu() + character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")]) + menu.right_click_equipment_slot("back", character, registry) + assert menu.open_backpack_slot == "back" + menu.select_tab("bio") + assert menu.open_backpack_slot is None + + +def test_right_click_backpack_opens_popup(registry): + menu = CharacterMenu() + character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")]) + opened = menu.right_click_equipment_slot("back", character, registry) + assert opened is True + assert menu.open_backpack_slot == "back" + + +def test_right_click_same_slot_again_closes_the_popup(registry): + menu = CharacterMenu() + character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")]) + menu.right_click_equipment_slot("back", character, registry) + closed = menu.right_click_equipment_slot("back", character, registry) + assert closed is False + assert menu.open_backpack_slot is None + + +def test_right_click_a_different_backpack_slot_switches_the_popup(registry): + menu = CharacterMenu() + character = make_human( + registry, + clothing=[ClothingPiece("back", "backpack_basic"), ClothingPiece("torso", "leather_jacket")], + ) + # leather_jacket grants no inventory, so this is really just proving "back" is what opens + menu.right_click_equipment_slot("back", character, registry) + assert menu.open_backpack_slot == "back" + + +def test_right_click_non_inventory_item_does_not_open_a_popup(registry): + menu = CharacterMenu() + character = make_human(registry, clothing=[ClothingPiece("torso", "leather_jacket")]) + opened = menu.right_click_equipment_slot("torso", character, registry) + assert opened is False + assert menu.open_backpack_slot is None + + +def test_right_click_empty_slot_is_a_no_op(registry): + menu = CharacterMenu() + character = make_human(registry) + opened = menu.right_click_equipment_slot("torso", character, registry) + assert opened is False + assert menu.open_backpack_slot is None + + +def test_close_backpack_popup_clears_it(): + menu = CharacterMenu(open_backpack_slot="back") + menu.close_backpack_popup() + assert menu.open_backpack_slot is None diff --git a/tests/test_clothing_fit.py b/tests/test_clothing_fit.py new file mode 100644 index 0000000..0881ca1 --- /dev/null +++ b/tests/test_clothing_fit.py @@ -0,0 +1,151 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character, ClothingPiece +from engine.defs import DefRegistry + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_character(registry, species_id: str, **kwargs) -> Character: + return Character(species_id=species_id, default_body_parts=registry.new_default_body_parts(species_id), **kwargs) + + +# --- arm_slot_count ----------------------------------------------------------------------- + + +def test_arm_slot_count_for_two_armed_species(registry): + human = make_character(registry, "human") + assert human.arm_slot_count() == 2 + + +def test_arm_slot_count_for_four_armed_species(registry): + reptilian = make_character(registry, "reptilian_quad") + assert reptilian.arm_slot_count() == 4 + + +def test_arm_slot_count_for_armless_species(registry): + dog = make_character(registry, "dog") + assert dog.arm_slot_count() == 0 + + +def test_arm_slot_count_ignores_a_missing_arm(registry): + human = make_character(registry, "human") + human.body_parts.append(BodyPart("left_arm", "human_left_arm", removed=True)) + assert human.arm_slot_count() == 1 + + +# --- wear_item: bigger (more sleeves) fits smaller, not vice versa -------------------------- + + +def test_two_armed_species_can_wear_a_four_armed_jacket(registry): + human = make_character(registry, "human") + assert human.wear_item("torso", "reptilian_jacket", registry) is True + assert human.get_clothing("torso").item_def_id == "reptilian_jacket" + + +def test_four_armed_species_cannot_wear_a_two_armed_jacket(registry): + reptilian = make_character(registry, "reptilian_quad") + assert reptilian.wear_item("torso", "leather_jacket", registry) is False + assert reptilian.get_clothing("torso") is None + + +def test_four_armed_species_can_wear_a_four_armed_jacket(registry): + reptilian = make_character(registry, "reptilian_quad") + assert reptilian.wear_item("torso", "reptilian_jacket", registry) is True + + +def test_two_armed_species_can_wear_a_two_armed_jacket(registry): + human = make_character(registry, "human") + assert human.wear_item("torso", "leather_jacket", registry) is True + + +def test_non_sleeved_item_ignores_arm_count_entirely(registry): + # dog_saddlebag has no arm_slots_required set (0 = unconstrained) - arm count is + # irrelevant for a non-sleeved item, regardless of body type + dog = make_character(registry, "dog") + assert dog.wear_item("back", "dog_saddlebag", registry) is True + + +# --- wear_item: body_type gate (a dog can't wear human clothes, full stop) ------------------- + + +def test_dog_cannot_wear_a_human_jacket(registry): + dog = make_character(registry, "dog") + assert dog.wear_item("torso", "leather_jacket", registry) is False + assert dog.get_clothing("torso") is None + + +def test_dog_cannot_wear_a_four_armed_jacket_either(registry): + # arm count would technically fit (0 <= 4) but body_type ("quadruped" vs "biped") rejects + # it outright - a completely different anatomy, not just a sleeve-count mismatch + dog = make_character(registry, "dog") + assert dog.wear_item("torso", "reptilian_jacket", registry) is False + + +def test_dog_cannot_wear_a_human_backpack(registry): + dog = make_character(registry, "dog") + assert dog.wear_item("back", "backpack_basic", registry) is False + + +def test_dog_can_wear_dog_equipment(registry): + dog = make_character(registry, "dog") + assert dog.wear_item("torso", "dog_harness", registry) is True + assert dog.wear_item("back", "dog_saddlebag", registry) is True + + +def test_biped_species_cannot_wear_dog_equipment(registry): + human = make_character(registry, "human") + reptilian = make_character(registry, "reptilian_quad") + assert human.wear_item("torso", "dog_harness", registry) is False + assert reptilian.wear_item("back", "dog_saddlebag", registry) is False + + +def test_reptilian_and_zenari_share_biped_body_type_and_can_wear_each_others_jackets(registry): + zenari = make_character(registry, "alien_tri") + assert zenari.wear_item("torso", "reptilian_jacket", registry) is True # both bipeds, fits (2 <= 4) + + +# --- wear_item: slot / replacement semantics ------------------------------------------------- + + +def test_wear_item_rejects_item_meant_for_a_different_slot(registry): + human = make_character(registry, "human") + assert human.wear_item("back", "leather_jacket", registry) is False # leather_jacket is "torso" + assert human.get_clothing("back") is None + + +def test_wear_item_replaces_whatever_was_in_that_slot(registry): + human = make_character(registry, "human") + human.wear_item("torso", "leather_jacket", registry) + human.wear_item("torso", "reptilian_jacket", registry) + assert human.get_clothing("torso").item_def_id == "reptilian_jacket" + assert len([c for c in human.clothing if c.slot == "torso"]) == 1 + + +def test_wear_item_does_not_replace_on_failed_fit_check(registry): + reptilian = make_character(registry, "reptilian_quad") + reptilian.wear_item("torso", "reptilian_jacket", registry) + assert reptilian.wear_item("torso", "leather_jacket", registry) is False + assert reptilian.get_clothing("torso").item_def_id == "reptilian_jacket" # unchanged + + +def test_take_off_clears_a_slot(registry): + human = make_character(registry, "human") + human.wear_item("torso", "leather_jacket", registry) + human.take_off("torso") + assert human.get_clothing("torso") is None + + +def test_take_off_reveals_a_default_garment_underneath(registry): + human = make_character(registry, "human", default_clothing=[ClothingPiece("torso", "leather_jacket")]) + human.wear_item("torso", "reptilian_jacket", registry) + assert human.get_clothing("torso").item_def_id == "reptilian_jacket" + human.take_off("torso") + assert human.get_clothing("torso").item_def_id == "leather_jacket" # fell back to the default diff --git a/tests/test_combat.py b/tests/test_combat.py new file mode 100644 index 0000000..636059d --- /dev/null +++ b/tests/test_combat.py @@ -0,0 +1,130 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry +from resources.logic.combat import BASE_DIFFICULTY, apply_body_part_damage, choose_hit_slot, perform_attack + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +class FixedRng: + """Deterministic stand-in for random.Random: fixed die roll, fixed choice.""" + + def __init__(self, roll: int, choice_value=None): + self._roll = roll + self._choice_value = choice_value + + def randint(self, _lo, _hi): + return self._roll + + def choice(self, seq): + return self._choice_value if self._choice_value is not None else seq[0] + + +def test_high_roll_and_skill_hits_and_damages_a_body_part(registry): + attacker = make_human(registry) + attacker.bio["athletics"] = 10 + defender = make_human(registry) + + result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=15, choice_value="torso")) + assert result.hit is True + assert result.skill_id == "athletics" + assert result.damage > 0 + assert result.target_slot is not None + hit_part = defender.get_body_part(result.target_slot) + assert hit_part.integrity == pytest.approx(100.0 - result.damage) + + +def test_low_roll_and_no_skill_misses(registry): + attacker = make_human(registry) # bio athletics defaults to 0 + defender = make_human(registry) + + result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=1)) + assert result.hit is False + assert result.damage == 0.0 + assert result.target_slot is None + # nothing was damaged + assert defender.get_body_part("torso").integrity == pytest.approx(100.0) + + +def test_weapon_bonus_increases_damage_on_hit(registry): + attacker = make_human(registry) + attacker.bio["athletics"] = 5 + defender = make_human(registry) + weapon = registry.get_item_def("staff_oak") + + unarmed = perform_attack(attacker, defender, registry, rng=FixedRng(roll=12, choice_value="torso")) + defender2 = make_human(registry) + armed = perform_attack(attacker, defender2, registry, weapon_def=weapon, rng=FixedRng(roll=12, choice_value="torso")) + + assert armed.damage > unarmed.damage + assert armed.weapon_bonus == pytest.approx(weapon.weapon_damage) + + +def test_skill_penalty_from_missing_arm_reduces_effective_skill(registry): + attacker = make_human(registry) + attacker.bio["athletics"] = 10 + attacker.body_parts = [BodyPart("left_arm", "human_left_arm", removed=True)] + defender = make_human(registry) + + result = perform_attack(attacker, defender, registry, rng=FixedRng(roll=10, choice_value="torso")) + assert result.effective_skill < 10.0 + + +def test_apply_body_part_damage_marks_removed_at_zero_integrity(registry): + character = make_human(registry) + apply_body_part_damage(character, "left_arm", 100.0) + part = next(p for p in character.body_parts if p.slot == "left_arm") + assert part.integrity == 0.0 + assert part.removed is True + assert character.get_body_part("left_arm") is None + + +def test_apply_body_part_damage_on_unknown_slot_returns_none(registry): + character = make_human(registry) + assert apply_body_part_damage(character, "tail", 10.0) is None + + +def test_base_difficulty_is_reasonable_default(): + assert BASE_DIFFICULTY == 10 + + +def test_choose_hit_slot_low_precision_hits_easiest_target(registry): + defender = make_human(registry) + assert choose_hit_slot(defender, precision_check=0) == "torso" + + +def test_choose_hit_slot_high_precision_reaches_the_hardest_target(registry): + defender = make_human(registry) + assert choose_hit_slot(defender, precision_check=1000) == "head" + + +def test_choose_hit_slot_skips_missing_body_parts(registry): + defender = make_human(registry, body_parts=[BodyPart("head", "human_head", removed=True)]) + # even at max precision, a missing head can never be the target + assert choose_hit_slot(defender, precision_check=1000) != "head" + + +def test_choose_hit_slot_none_when_no_body_parts_at_all(registry): + defender = Character() + assert choose_hit_slot(defender, precision_check=50) is None + + +def test_higher_precision_check_reaches_further_down_the_target_list(registry): + defender = make_human(registry) + low = choose_hit_slot(defender, precision_check=0) + high = choose_hit_slot(defender, precision_check=20) + from resources.logic.combat import BODY_PART_DIFFICULTY_ORDER + + assert BODY_PART_DIFFICULTY_ORDER.index(high) >= BODY_PART_DIFFICULTY_ORDER.index(low) diff --git a/tests/test_companion.py b/tests/test_companion.py new file mode 100644 index 0000000..c1bda52 --- /dev/null +++ b/tests/test_companion.py @@ -0,0 +1,118 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.item import ItemInstance +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.companion import open_companion_inventory + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_world(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +def spawn_companion(registry, entity_def_id, position) -> Character: + entity_def = registry.get_entity_def(entity_def_id) + dog = Character( + name=entity_def.name, + def_id=entity_def.id, + position=position, + species_id=entity_def.species_id, + gender="male", + default_body_parts=registry.new_default_body_parts(entity_def.species_id), + default_clothing=registry.new_default_clothing(entity_def.id), + ) + return dog + + +def test_companion_dog_entity_wears_harness_and_saddlebag(registry): + dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 5, 5, 0)) + assert dog.get_clothing("torso").item_def_id == "dog_harness" + assert dog.get_clothing("back").item_def_id == "dog_saddlebag" + + +def test_open_companion_inventory_returns_the_saddlebag(registry): + world = make_world(registry) + player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), + position=EntityPosition("field", 5, 5, 0)) + dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 6, 5, 0)) + world.add_entity(player) + world.add_entity(dog) + + inventory = open_companion_inventory(player, world, registry, (1, 0, 0), "back") + assert inventory is not None + assert (inventory.width, inventory.height) == (3, 3) + + +def test_items_placed_in_the_opened_inventory_persist_on_the_dog(registry): + world = make_world(registry) + player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), + position=EntityPosition("field", 5, 5, 0)) + dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 6, 5, 0)) + world.add_entity(player) + world.add_entity(dog) + + inventory = open_companion_inventory(player, world, registry, (1, 0, 0), "back") + inventory.place(ItemInstance("rope-1", "sandwich"), registry.get_item_def("sandwich"), 0, 0) + + # re-fetching via the dog's own gear (not the helper) sees the same items - same object + assert dog.get_equipped_inventory("back", registry) is inventory + assert {p.item.item_id for p in dog.get_equipped_inventory("back", registry).items()} == {"rope-1"} + + +def test_open_companion_inventory_none_when_not_adjacent(registry): + world = make_world(registry) + player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), + position=EntityPosition("field", 5, 5, 0)) + dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 8, 8, 0)) # far away + world.add_entity(player) + world.add_entity(dog) + + assert open_companion_inventory(player, world, registry, (1, 0, 0), "back") is None + + +def test_open_companion_inventory_none_for_empty_tile(registry): + world = make_world(registry) + player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), + position=EntityPosition("field", 5, 5, 0)) + world.add_entity(player) + assert open_companion_inventory(player, world, registry, (1, 0, 0), "back") is None + + +def test_open_companion_inventory_none_targeting_self(registry): + world = make_world(registry) + player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), + position=EntityPosition("field", 5, 5, 0)) + world.add_entity(player) + # aim_direction (0,0,0) means "ahead" is the actor's own tile - entity_at finds the actor + # themself there, which the target-is-actor check must reject + assert open_companion_inventory(player, world, registry, (0, 0, 0), "back") is None + + +def test_open_companion_inventory_none_for_slot_with_nothing_worn(registry): + world = make_world(registry) + player = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), + position=EntityPosition("field", 5, 5, 0)) + dog = spawn_companion(registry, "companion_dog", EntityPosition("field", 6, 5, 0)) + world.add_entity(player) + world.add_entity(dog) + + assert open_companion_inventory(player, world, registry, (1, 0, 0), "head") is None diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5602c9f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from engine.config import DEFAULT_KEYBINDINGS, KeyBindings + +CONF_PATH = Path(__file__).resolve().parent.parent / "config" / "keybindings.conf" + + +def test_default_keybindings_used_when_file_missing(tmp_path): + bindings = KeyBindings.load(tmp_path / "does_not_exist.conf") + assert bindings.action_for_key("w") == "move_up" + assert bindings.action_for_key("ArrowUp") == "move_up" + + +def test_space_alias_normalizes_to_the_real_key_value(): + bindings = KeyBindings.load(Path("/nonexistent")) + assert bindings.action_for_key(" ") == "leap" + + +def test_multiple_keys_share_one_action(tmp_path): + conf = tmp_path / "keybindings.conf" + conf.write_text("[movement]\nmove_up = w, k, ArrowUp\n") + bindings = KeyBindings.load(conf) + assert bindings.action_for_key("w") == "move_up" + assert bindings.action_for_key("k") == "move_up" + assert bindings.action_for_key("ArrowUp") == "move_up" + assert bindings.keys_for_action("move_up") == ["w", "k", "ArrowUp"] + + +def test_file_overrides_only_the_actions_it_mentions(tmp_path): + conf = tmp_path / "keybindings.conf" + conf.write_text("[actions]\nleap = j\n") + bindings = KeyBindings.load(conf) + assert bindings.action_for_key("j") == "leap" + assert bindings.action_for_key(" ") is None # default "space" binding was replaced, not merged + assert bindings.action_for_key("w") == "move_up" # untouched defaults still present + + +def test_unbound_key_returns_none(): + bindings = KeyBindings.load(Path("/nonexistent")) + assert bindings.action_for_key("q") is None + + +def test_real_project_keybindings_conf_loads_and_covers_expected_actions(): + bindings = KeyBindings.load(CONF_PATH) + for action in DEFAULT_KEYBINDINGS: + assert bindings.keys_for_action(action), f"missing action: {action}" + assert bindings.action_for_key("mouse_left") == "activate_right_hand" + assert bindings.action_for_key("shift+mouse_left") == "activate_right_hand_2" diff --git a/tests/test_construction.py b/tests/test_construction.py new file mode 100644 index 0000000..b1a31a2 --- /dev/null +++ b/tests/test_construction.py @@ -0,0 +1,119 @@ +from pathlib import Path + +import pytest + +from engine.defs import DefRegistry +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.tile import Tile, TileLayerInstance +from engine.ui import LayerPickerMenu +from resources.logic.construction import ( + construct_tile_layer, + damage_tile_layer, + deconstruct_tile_layer, + destroy_tile_layer, + present_layers, +) + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_walled_tile() -> Tile: + return Tile( + subfloor=TileLayerInstance("dirt"), + flooring=TileLayerInstance("wood_deck"), + room=TileLayerInstance("wood_wall"), + ) + + +def test_present_layers_lists_only_occupied_layers(): + tile = make_walled_tile() + assert present_layers(tile) == ["subfloor", "flooring", "room"] + + +def test_destroy_clears_layer_with_no_item_yielded(): + tile = make_walled_tile() + assert destroy_tile_layer(tile, "room") is True + assert tile.room is None + assert present_layers(tile) == ["subfloor", "flooring"] + + +def test_destroy_on_empty_layer_is_a_no_op(): + tile = make_walled_tile() + assert destroy_tile_layer(tile, "roof") is False + + +def test_deconstruct_clears_layer_and_yields_its_item(registry): + tile = make_walled_tile() + item = deconstruct_tile_layer(tile, "room", registry) + assert tile.room is None + assert item is not None + assert item.def_id == "wood_planks" + + +def test_deconstruct_layer_without_yield_returns_none(registry): + tile = make_walled_tile() + item = deconstruct_tile_layer(tile, "flooring", registry) # wood_deck has no deconstruct_item_id + assert tile.flooring is None + assert item is None + + +def test_damage_tile_layer_reduces_integrity_and_clears_at_zero(): + tile = make_walled_tile() + assert damage_tile_layer(tile, "room", 40.0) is False + assert tile.room.integrity == pytest.approx(60.0) + assert damage_tile_layer(tile, "room", 60.0) is True + assert tile.room is None + + +def test_construct_tile_layer_consumes_item_and_places_layer(registry): + tile = Tile(subfloor=TileLayerInstance("dirt"), flooring=TileLayerInstance("wood_deck")) + inventory = Inventory(4, 4) + planks_def = registry.get_item_def("wood_planks") + inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0) + + assert construct_tile_layer(tile, "room", planks_def, inventory, "planks-1") is True + assert tile.room.def_id == "wood_wall" + assert inventory.items() == [] + + +def test_construct_fails_if_layer_already_occupied(registry): + tile = make_walled_tile() # room already has a wall + inventory = Inventory(4, 4) + planks_def = registry.get_item_def("wood_planks") + inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0) + + assert construct_tile_layer(tile, "room", planks_def, inventory, "planks-1") is False + assert {p.item.item_id for p in inventory.items()} == {"planks-1"} # item not consumed + + +def test_construct_fails_if_item_does_not_build_this_layer(registry): + tile = Tile() + inventory = Inventory(4, 4) + sandwich_def = registry.get_item_def("sandwich") + inventory.place(ItemInstance("sandwich-1", "sandwich"), sandwich_def, 0, 0) + + assert construct_tile_layer(tile, "room", sandwich_def, inventory, "sandwich-1") is False + + +def test_layer_picker_menu_cycle_wraps_and_confirm_returns_selected(): + menu = LayerPickerMenu(map_id="ship", x=1, y=1, z=0, options=["subfloor", "flooring", "room"]) + assert menu.confirm() == "subfloor" + menu.cycle(1) + assert menu.confirm() == "flooring" + menu.cycle(1) + menu.cycle(1) + assert menu.confirm() == "subfloor" # wrapped around + menu.cycle(-1) + assert menu.confirm() == "room" + + +def test_layer_picker_menu_with_no_options_confirms_none(): + menu = LayerPickerMenu(map_id="ship", x=1, y=1, z=0, options=[]) + assert menu.confirm() is None + menu.cycle(1) # no-op, doesn't crash diff --git a/tests/test_continuous_movement.py b/tests/test_continuous_movement.py new file mode 100644 index 0000000..6f4d288 --- /dev/null +++ b/tests/test_continuous_movement.py @@ -0,0 +1,117 @@ +import pytest + +from engine.entity import Entity, EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerDef, TileLayerInstance +from engine.world import World + + +class FakeRegistry: + def get_tile_layer_def(self, layer, def_id): + if layer == "room" and def_id == "wood_wall": + return TileLayerDef(id="wood_wall", layer="room", walkable=False, blocks_los=True) + return None + + +def make_world(): + parent = Map("world", 10, 10, 1) + for x in range(10): + for y in range(10): + parent.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + + ship = Map("ship", 4, 3, 1) + for x in range(4): + for y in range(3): + ship.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck"))) + for x, y in [(0, 0), (1, 0), (3, 0), (0, 1), (3, 1), (0, 2), (1, 2), (2, 2), (3, 2)]: + ship.get_tile(x, y, 0).room = TileLayerInstance("wood_wall") # (2,0) is the open doorway + + parent.embed(ship, (6, 6, 0)) + + world = World() + world.maps = {"world": parent, "ship": ship} + world.registry = FakeRegistry() + return world, parent, ship + + +def test_continuous_move_lands_at_a_fractional_position(): + world, _parent, _ship = make_world() + player = Entity(position=EntityPosition("world", 2.0, 2.0, 0)) + assert world.try_move_continuous(player, 1, 0, 0, distance=0.35) is True + assert player.position.x == pytest.approx(2.35) + assert player.position.y == pytest.approx(2.0) + + +def test_continuous_move_normalizes_diagonal_direction(): + world, _parent, _ship = make_world() + player = Entity(position=EntityPosition("world", 2.0, 2.0, 0)) + assert world.try_move_continuous(player, 1, 1, 0, distance=1.0) is True + # moved exactly 1 unit total, split evenly between x and y (not 1 in each axis) + import math + + dx = player.position.x - 2.0 + dy = player.position.y - 2.0 + assert math.hypot(dx, dy) == pytest.approx(1.0) + assert dx == pytest.approx(dy) + + +def test_continuous_move_blocked_by_wall_leaves_position_unchanged(): + world, _parent, ship = make_world() + # ship-local (0,0) is a wood_wall; approach it from inside at (1.2,1.2) moving toward it + player = Entity(position=EntityPosition("ship", 1.2, 1.2, 0)) + assert world.try_move_continuous(player, -1, -1, 0, distance=1.5) is False + assert player.position.x == pytest.approx(1.2) + assert player.position.y == pytest.approx(1.2) + + +def test_continuous_move_crosses_into_embedded_ship_at_fractional_local_position(): + world, _parent, ship = make_world() + # doorway column is parent x=8 (ship-local x=2); approach from just north of it + player = Entity(position=EntityPosition("world", 8.3, 5.6, 0)) + assert world.try_move_continuous(player, 0, 1, 0, distance=0.6) is True + assert player.position.map_id == "ship" + assert player.position.x == pytest.approx(2.3) + assert player.position.y == pytest.approx(0.2) + + +def test_continuous_move_disembarks_at_fractional_parent_position(): + world, _parent, ship = make_world() + player = Entity(position=EntityPosition("ship", 2.3, 0.2, 0)) + assert world.try_move_continuous(player, 0, -1, 0, distance=0.6) is True + assert player.position.map_id == "world" + assert player.position.x == pytest.approx(8.3) + assert player.position.y == pytest.approx(5.6) + + +def test_entity_at_matches_by_tile_membership_for_fractional_positions(): + world, _parent, _ship = make_world() + entity = Entity(position=EntityPosition("world", 3.7, 4.2, 0)) + world.add_entity(entity) + assert world.entity_at("world", 3, 4, 0) is entity + assert world.entity_at("world", 4, 4, 0) is None + + +def test_rotate_position_round_trips_through_a_rotated_embedding(): + world, _parent, ship = make_world() + embedding = ship.parent_embedding + embedding.rotate_to(1) + + for lx, ly in [(0.5, 0.5), (2.7, 1.1), (3.99, 0.01)]: + px, py, pz = embedding.local_to_parent_pos(lx, ly, 0) + rx, ry, rz = embedding.parent_to_local_pos(px, py, pz) + assert rx == pytest.approx(lx) + assert ry == pytest.approx(ly) + + +def test_continuous_move_crosses_into_rotated_embedded_ship(): + world, _parent, ship = make_world() + embedding = ship.parent_embedding + embedding.rotate_to(1) # 90 deg CW: 4x3 footprint becomes 3x4 in parent space + + # find where the doorway (ship-local 2,0) lands post-rotation, approach from just outside it + door_parent = embedding.local_to_parent(2, 0, 0) + start_x = door_parent[0] + 0.5 - 1 # one tile back along x, offset into the tile + start_y = door_parent[1] + 0.5 + player = Entity(position=EntityPosition("world", start_x, start_y, 0)) + assert world.try_move_continuous(player, 1, 0, 0, distance=1.0) is True + assert player.position.map_id == "ship" diff --git a/tests/test_db_roundtrip.py b/tests/test_db_roundtrip.py new file mode 100644 index 0000000..8c5056d --- /dev/null +++ b/tests/test_db_roundtrip.py @@ -0,0 +1,142 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from engine.character import Ailment, BodyPart, Character, ClothingPiece +from engine.db import WorldRepository +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map_loader import MapLoader +from engine.world import World + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def build_world(registry) -> World: + loader = MapLoader(DEFS_DIR / "maps") + demo_world = loader.load("demo_world.json") + ship = demo_world.embeddings[0].child_map + + world = World(registry=registry) + world.add_map(demo_world) + world.add_map(ship) + + player = Character( + entity_id="player-1", + name="Player", + def_id="player", + position=EntityPosition("demo_world", 7, 5, 0), + species_id="human", + gender="nonbinary", + blood_percentage=82.5, + body_parts=[ + BodyPart("head", "human_head_hat"), + BodyPart("left_leg", "human_left_leg", integrity=40.0, ailments=[Ailment("frostbite", "Frostbite", 0.5)]), + ], + default_body_parts=registry.new_default_body_parts("human"), + clothing=[ClothingPiece("torso", "leather_jacket")], + default_clothing=registry.new_default_clothing("player"), + mental_stats={"insanity": 12.5}, + ) + player.bio["stealth"] = 3 + backpack_inventory = player.get_equipped_inventory("back", registry) + backpack_inventory.place(ItemInstance("rope-1", "sandwich"), registry.get_item_def("sandwich"), 0, 0) + player.inventory = Inventory(6, 5) + player.inventory.place(ItemInstance("staff-1", "staff_oak"), registry.get_item_def("staff_oak"), 0, 0) + player.inventory.place(ItemInstance("sandwich-1", "sandwich"), registry.get_item_def("sandwich"), 1, 0) + + world.add_entity(player) + world.player = player + return world + + +def test_save_and_load_roundtrip_preserves_entity_and_inventory(registry): + world = build_world(registry) + repo = WorldRepository(sqlite3.connect(":memory:")) + repo.save_world(world) + + loader = MapLoader(DEFS_DIR / "maps") + reloaded = repo.load_world(registry, loader) + + assert reloaded is not None + assert reloaded.player is not None + assert reloaded.player.entity_id == "player-1" + assert (reloaded.player.position.map_id, reloaded.player.position.x, reloaded.player.position.y) == ( + "demo_world", + 7, + 5, + ) + + assert isinstance(reloaded.player, Character) + assert reloaded.player.get_body_part("head").part_def_id == "human_head_hat" + assert reloaded.player.get_body_part("torso").part_def_id == "human_torso" + + placed = {p.item.item_id: (p.x, p.y) for p in reloaded.player.inventory.items()} + assert placed == {"staff-1": (0, 0), "sandwich-1": (1, 0)} + + +def test_save_and_load_roundtrip_preserves_health_fields(registry): + world = build_world(registry) + repo = WorldRepository(sqlite3.connect(":memory:")) + repo.save_world(world) + + loader = MapLoader(DEFS_DIR / "maps") + reloaded = repo.load_world(registry, loader) + player = reloaded.player + + assert player.species_id == "human" + assert player.gender == "nonbinary" + assert player.blood_percentage == pytest.approx(82.5) + assert player.mental_stats == {"insanity": 12.5} + assert player.bio["stealth"] == 3 + assert player.bio["athletics"] == 0 + + left_leg = player.get_body_part("left_leg") + assert left_leg.integrity == pytest.approx(40.0) + assert [a.id for a in left_leg.ailments] == ["frostbite"] + assert left_leg.ailments[0].severity == pytest.approx(0.5) + + assert player.get_clothing("torso").item_def_id == "leather_jacket" + backpack = player.get_clothing("back") + assert backpack.item_def_id == "backpack_basic" + assert backpack.inventory is not None + assert (backpack.inventory.width, backpack.inventory.height) == (4, 4) + assert {p.item.item_id for p in backpack.inventory.items()} == {"rope-1"} + + +def test_load_world_returns_none_when_no_save_exists(registry): + repo = WorldRepository(sqlite3.connect(":memory:")) + loader = MapLoader(DEFS_DIR / "maps") + assert repo.load_world(registry, loader) is None + + +def test_runtime_tile_edits_survive_save_and_load(registry): + world = build_world(registry) + demo_world = world.maps["demo_world"] + + # simulate base-building: knock a wall down on the ship, damage a flooring tile elsewhere + ship = world.maps["ship_small"] + ship.get_tile(0, 0, 0).room = None + demo_world.get_tile(3, 3, 0).flooring.integrity = 42.0 + + repo = WorldRepository(sqlite3.connect(":memory:")) + repo.save_world(world) + + loader = MapLoader(DEFS_DIR / "maps") + reloaded = repo.load_world(registry, loader) + + reloaded_ship = reloaded.maps["ship_small"] + assert reloaded_ship.get_tile(0, 0, 0).room is None + + reloaded_demo = reloaded.maps["demo_world"] + edited_tile = reloaded_demo.get_tile(3, 3, 0) + assert edited_tile.flooring.def_id == "grass" + assert edited_tile.flooring.integrity == pytest.approx(42.0) diff --git a/tests/test_defs_loading.py b/tests/test_defs_loading.py new file mode 100644 index 0000000..cac97ab --- /dev/null +++ b/tests/test_defs_loading.py @@ -0,0 +1,115 @@ +from pathlib import Path + +import pytest + +from engine.defs import DefRegistry +from engine.map_loader import MapLoader + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def test_tile_layer_defs_load_from_json(registry): + grass = registry.get_tile_layer_def("flooring", "grass") + assert grass is not None + assert grass.walkable is True + + wall = registry.get_tile_layer_def("room", "wood_wall") + assert wall is not None + assert wall.walkable is False + assert wall.blocks_los is True + + +def test_item_defs_load_with_correct_footprints(registry): + staff = registry.get_item_def("staff_oak") + assert (staff.width, staff.height) == (1, 5) + + sandwich = registry.get_item_def("sandwich") + assert (sandwich.width, sandwich.height) == (2, 2) + assert sandwich.stackable is True + + +def test_entity_def_references_species_and_inventory_size(registry): + player = registry.get_entity_def("player") + assert player.species_id == "human" + assert player.inventory_size == (6, 5) + assert [c.item_def_id for c in player.default_clothing] == ["backpack_basic"] + assert player.default_clothing[0].slot == "back" + + +def test_item_def_clothing_fields(registry): + backpack = registry.get_item_def("backpack_basic") + assert backpack.clothing_slot == "back" + assert backpack.grants_inventory_size == (4, 4) + + staff = registry.get_item_def("staff_oak") + assert staff.clothing_slot is None + assert staff.grants_inventory_size is None + + +def test_new_default_clothing_returns_independent_instances(registry): + a = registry.new_default_clothing("player") + b = registry.new_default_clothing("player") + assert a is not b + assert a[0] is not b[0] + a[0].removed = True + assert b[0].removed is False + + +def test_species_def_resolves_default_body_parts_with_correct_slots(registry): + human = registry.get_species_def("human") + slots = {bp.slot for bp in human.default_body_parts} + assert slots == {"head", "torso", "left_arm", "right_arm", "left_leg", "right_leg"} + assert human.genders == ["male", "female", "nonbinary"] + assert human.blood_danger_threshold == 50.0 + assert human.blood_critical_threshold == 25.0 + + +def test_new_default_body_parts_returns_independent_instances(registry): + parts_a = registry.new_default_body_parts("human") + parts_b = registry.new_default_body_parts("human") + assert parts_a is not parts_b + parts_a[0].integrity = 10.0 + assert parts_b[0].integrity == 100.0 + + +def test_body_part_def_carries_bleeding_and_skill_weights(registry): + head = registry.get_body_part_def("human_head") + assert head.bleeding_weight == pytest.approx(0.10) + assert head.skill_weights["perception"] == pytest.approx(0.6) + + +def test_demo_world_loads_with_ship_embedded_at_anchor(): + loader = MapLoader(DEFS_DIR / "maps") + world = loader.load("demo_world.json") + + assert (world.width, world.height, world.depth) == (10, 10, 1) + assert len(world.embeddings) == 1 + + embedding = world.embeddings[0] + assert embedding.anchor == (6, 6, 0) + assert embedding.child_map.map_id == "ship_small" + assert (embedding.child_map.width, embedding.child_map.height) == (4, 3) + assert embedding.child_map.parent_embedding is embedding + + +def test_demo_world_tiles_resolve_against_tile_defs(registry): + loader = MapLoader(DEFS_DIR / "maps") + world = loader.load("demo_world.json") + + tile = world.get_tile(0, 0, 0) + assert tile.flooring.def_id == "grass" + assert tile.is_walkable(registry) is True + + ship = world.embeddings[0].child_map + wall_tile = ship.get_tile(0, 0, 0) + assert wall_tile.room.def_id == "wood_wall" + assert wall_tile.is_walkable(registry) is False + + door_tile = ship.get_tile(2, 0, 0) + assert door_tile.room is None + assert door_tile.is_walkable(registry) is True diff --git a/tests/test_gamepad.py b/tests/test_gamepad.py new file mode 100644 index 0000000..f50cfe3 --- /dev/null +++ b/tests/test_gamepad.py @@ -0,0 +1,99 @@ +from engine.gamepad import ( + AXIS_LEFT_TRIGGER, + AXIS_LEFT_X, + AXIS_RIGHT_TRIGGER, + DEFAULT_GAMEPAD_BINDINGS, + GamepadBindings, + GamepadState, + apply_deadzone, +) + + +# --- apply_deadzone --------------------------------------------------------------------------- + + +def test_deadzone_zeroes_small_values(): + assert apply_deadzone(0.05) == 0.0 + assert apply_deadzone(-0.1) == 0.0 + + +def test_deadzone_passes_through_large_values(): + assert apply_deadzone(0.5) == 0.5 + assert apply_deadzone(-0.9) == -0.9 + + +def test_deadzone_respects_custom_threshold(): + assert apply_deadzone(0.3, deadzone=0.5) == 0.0 + assert apply_deadzone(0.6, deadzone=0.5) == 0.6 + + +# --- GamepadState ------------------------------------------------------------------------------ + + +def test_axis_out_of_range_defaults_to_zero(): + state = GamepadState(axes=[0.1, 0.2]) + assert state.axis(AXIS_LEFT_X) == 0.1 + assert state.axis(5) == 0.0 + + +def test_digital_buttons_includes_pressed_real_buttons(): + state = GamepadState(buttons={"gp_a": True, "gp_b": False}) + assert state.digital_buttons() == {"gp_a"} + + +def test_digital_buttons_thresholds_triggers_into_pseudo_buttons(): + axes = [0.0] * 6 + axes[AXIS_LEFT_TRIGGER] = 0.9 + axes[AXIS_RIGHT_TRIGGER] = 0.1 + state = GamepadState(axes=axes) + assert state.digital_buttons() == {"gp_left_trigger"} + + +def test_digital_buttons_combines_real_and_trigger_buttons(): + axes = [0.0] * 6 + axes[AXIS_RIGHT_TRIGGER] = 1.0 + state = GamepadState(buttons={"gp_a": True}, axes=axes) + assert state.digital_buttons() == {"gp_a", "gp_right_trigger"} + + +# --- GamepadBindings --------------------------------------------------------------------------- + + +def test_default_bindings_resolve_expected_actions(): + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + assert bindings.action_for_button("gp_a") == "leap" + assert bindings.action_for_button("gp_left_trigger") == "activate_left_hand_2" + + +def test_unbound_button_resolves_to_none(): + bindings = GamepadBindings({"leap": ["gp_a"]}) + assert bindings.action_for_button("gp_y") is None + + +def test_buttons_for_action_returns_the_configured_list(): + bindings = GamepadBindings({"leap": ["gp_a", "gp_start"]}) + assert bindings.buttons_for_action("leap") == ["gp_a", "gp_start"] + + +def test_load_falls_back_to_defaults_when_file_missing(tmp_path): + bindings = GamepadBindings.load(tmp_path / "does_not_exist.conf") + assert bindings.action_for_button("gp_a") == "leap" + + +def test_load_reads_conf_file_and_overrides_defaults(tmp_path): + conf = tmp_path / "controller_bindings.conf" + conf.write_text("[actions]\nleap = gp_y\n") + bindings = GamepadBindings.load(conf) + assert bindings.action_for_button("gp_y") == "leap" + assert bindings.action_for_button("gp_a") is None # overridden, not appended + # untouched actions still fall back to the defaults + assert bindings.action_for_button("gp_left_bumper") == "block" + + +def test_load_reads_the_real_shipped_controller_bindings_conf(): + from pathlib import Path + + path = Path(__file__).resolve().parent.parent / "config" / "controller_bindings.conf" + bindings = GamepadBindings.load(path) + assert bindings.action_for_button("gp_a") == "leap" + assert bindings.action_for_button("gp_dpad_up") == "select_ability_1" diff --git a/tests/test_health_ticks.py b/tests/test_health_ticks.py new file mode 100644 index 0000000..0c1d3c6 --- /dev/null +++ b/tests/test_health_ticks.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry +from resources.logic.health_ticks import apply_bleeding_tick + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def test_no_bleeding_when_no_parts_removed(registry): + character = Character(default_body_parts=registry.new_default_body_parts("human")) + apply_bleeding_tick(character, registry, ticks=5) + assert character.blood_percentage == pytest.approx(100.0) + + +def test_removed_part_bleeds_at_its_defs_rate_per_tick(registry): + character = Character( + default_body_parts=registry.new_default_body_parts("human"), + body_parts=[BodyPart("left_arm", "human_left_arm", removed=True)], + ) + # human_left_arm bleeding_speed is 2.0/tick + apply_bleeding_tick(character, registry, ticks=3) + assert character.blood_percentage == pytest.approx(100.0 - 2.0 * 3) + + +def test_multiple_removed_parts_stack_bleeding(registry): + character = Character( + default_body_parts=registry.new_default_body_parts("human"), + body_parts=[ + BodyPart("left_leg", "human_left_leg", removed=True), # 3.0/tick + BodyPart("right_leg", "human_right_leg", removed=True), # 3.0/tick + ], + ) + apply_bleeding_tick(character, registry, ticks=2) + assert character.blood_percentage == pytest.approx(100.0 - (3.0 + 3.0) * 2) + + +def test_blood_percentage_does_not_go_below_zero(registry): + character = Character( + default_body_parts=registry.new_default_body_parts("human"), + body_parts=[BodyPart("torso", "human_torso", removed=True)], # 6.0/tick + ) + apply_bleeding_tick(character, registry, ticks=100) + assert character.blood_percentage == 0.0 + + +def test_zero_ticks_is_a_no_op(registry): + character = Character( + default_body_parts=registry.new_default_body_parts("human"), + body_parts=[BodyPart("left_arm", "human_left_arm", removed=True)], + ) + apply_bleeding_tick(character, registry, ticks=0) + assert character.blood_percentage == pytest.approx(100.0) diff --git a/tests/test_implants.py b/tests/test_implants.py new file mode 100644 index 0000000..8479a1b --- /dev/null +++ b/tests/test_implants.py @@ -0,0 +1,105 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.actions import activate_implant + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 15, 15, 1) + for x in range(15): + for y in range(15): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +# --- install/uninstall ----------------------------------------------------------------------- + + +def test_install_implant_adds_to_character(registry): + character = make_human(registry) + assert character.install_implant("chronoimplant", registry) is True + assert "chronoimplant" in character.implants + + +def test_install_implant_rejects_non_implant_item(registry): + character = make_human(registry) + assert character.install_implant("chronowand", registry) is False + assert character.implants == [] + + +def test_install_implant_rejects_duplicate(registry): + character = make_human(registry) + character.install_implant("chronoimplant", registry) + assert character.install_implant("chronoimplant", registry) is False + assert character.implants == ["chronoimplant"] + + +def test_uninstall_implant_removes_it(registry): + character = make_human(registry) + character.install_implant("chronoimplant", registry) + character.uninstall_implant("chronoimplant") + assert character.implants == [] + + +def test_uninstall_implant_no_op_if_not_installed(registry): + character = make_human(registry) + character.uninstall_implant("chronoimplant") # should not raise + assert character.implants == [] + + +# --- activate_implant ------------------------------------------------------------------------- + + +def test_activate_implant_casts_time_warp_sphere(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + + assert world.time_warp_zones == [] + result = activate_implant(caster, world, registry, "chronoimplant", now=3.0) + assert len(world.time_warp_zones) == 1 + assert result is world.time_warp_zones[0] + assert result.expires_at > 3.0 + + +def test_activate_implant_returns_none_if_not_installed(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + world.add_entity(caster) + + result = activate_implant(caster, world, registry, "chronoimplant", now=3.0) + assert result is None + assert world.time_warp_zones == [] + + +def test_activate_implant_returns_none_for_non_ability_implant(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + caster.install_implant("chronoimplant", registry) + world.add_entity(caster) + # a hypothetical implant with no grants_ability should be a safe no-op, not a crash + object.__setattr__(registry.get_item_def("chronoimplant"), "grants_ability", None) + + result = activate_implant(caster, world, registry, "chronoimplant", now=3.0) + assert result is None diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 0000000..6aa1239 --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,198 @@ +from engine.config import KeyBindings +from engine.gamepad import DEFAULT_GAMEPAD_BINDINGS, GamepadBindings, GamepadState +from engine.input import InputState + + +def test_move_key_sets_pending_move_and_updates_facing(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) + assert input_state.consume_move() == (1, 0, 0) + assert input_state.facing == (1, 0, 0) + assert input_state.consume_move() is None # consumed + + +def test_leap_key_uses_last_facing_direction(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "w"}) + input_state.consume_move() + input_state.handle_event({"event_type": "key_down", "key": " "}) + assert input_state.consume_leap() == (0, -1, 0) + assert input_state.consume_leap() is None + + +def test_leap_key_before_any_move_uses_default_facing(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": " "}) + assert input_state.consume_leap() == (0, 1, 0) + + +def test_key_up_events_are_ignored_for_movement(): + input_state = InputState() + input_state.handle_event({"event_type": "key_up", "key": "d"}) + assert input_state.consume_move() is None + + +def test_held_key_contributes_to_current_move_vector(): + input_state = InputState() + assert input_state.current_move_vector() == (0.0, 0.0, 0.0) + input_state.handle_event({"event_type": "key_down", "key": "d"}) + assert input_state.current_move_vector() == (1.0, 0.0, 0.0) + + +def test_releasing_a_held_key_removes_its_contribution(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) + input_state.handle_event({"event_type": "key_up", "key": "d"}) + assert input_state.current_move_vector() == (0.0, 0.0, 0.0) + + +def test_holding_two_keys_combines_into_a_diagonal_vector(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) + input_state.handle_event({"event_type": "key_down", "key": "s"}) + assert input_state.current_move_vector() == (1.0, 1.0, 0.0) + + +def test_opposite_held_keys_cancel_out(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) + input_state.handle_event({"event_type": "key_down", "key": "a"}) + assert input_state.current_move_vector() == (0.0, 0.0, 0.0) + + +def test_block_key_is_a_held_state_not_a_one_shot(): + input_state = InputState() + assert input_state.is_blocking is False + input_state.handle_event({"event_type": "key_down", "key": "b"}) + assert input_state.is_blocking is True + input_state.handle_event({"event_type": "key_up", "key": "b"}) + assert input_state.is_blocking is False + + +def test_mouse_left_click_activates_right_hand(): + input_state = InputState() + input_state.handle_event({"event_type": "pointer_down", "button": 0, "modifiers": ()}) + assert input_state.consume_hand_activation() == "right_hand" + assert input_state.consume_hand_activation() is None + + +def test_mouse_right_click_activates_left_hand(): + input_state = InputState() + input_state.handle_event({"event_type": "pointer_down", "button": 2, "modifiers": ()}) + assert input_state.consume_hand_activation() == "left_hand" + + +def test_shift_plus_mouse_click_activates_the_second_hand_pair(): + input_state = InputState() + input_state.handle_event({"event_type": "pointer_down", "button": 0, "modifiers": ("Shift",)}) + assert input_state.consume_hand_activation() == "right_hand_2" + + +def test_pointer_move_tracks_position(): + input_state = InputState() + assert input_state.pointer_pos is None + input_state.handle_event({"event_type": "pointer_move", "x": 12.5, "y": 34.0}) + assert input_state.pointer_pos == (12.5, 34.0) + + +def test_unbound_key_is_ignored(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "q"}) + assert input_state.consume_move() is None + assert input_state.consume_leap() is None + + +def test_custom_keybindings_are_respected(): + bindings = KeyBindings({"move_up": ["j"]}) + input_state = InputState(bindings) + input_state.handle_event({"event_type": "key_down", "key": "j"}) + assert input_state.consume_move() == (0, -1, 0) + + +def test_numkey_1_selects_ability_bar_slot_0(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "1"}) + assert input_state.consume_ability_select() == 0 + assert input_state.consume_ability_select() is None # consumed + + +def test_numkey_0_selects_ability_bar_slot_9(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "0"}) + assert input_state.consume_ability_select() == 9 + + +def test_all_ten_numkeys_map_to_distinct_slots(): + input_state = InputState() + seen = set() + for key in "1234567890": + input_state.handle_event({"event_type": "key_down", "key": key}) + seen.add(input_state.consume_ability_select()) + assert seen == set(range(10)) + + +# --- gamepad ------------------------------------------------------------------------------- + + +def test_gamepad_left_stick_drives_current_move_vector(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(axes=[0.8, -0.5, 0, 0, 0, 0]), bindings) + assert input_state.current_move_vector() == (0.8, -0.5, 0.0) + + +def test_gamepad_stick_within_deadzone_contributes_nothing(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(axes=[0.05, -0.05, 0, 0, 0, 0]), bindings) + assert input_state.current_move_vector() == (0.0, 0.0, 0.0) + + +def test_gamepad_stick_adds_to_held_keyboard_movement(): + input_state = InputState() + input_state.handle_event({"event_type": "key_down", "key": "d"}) # move_right: (1, 0, 0) + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(axes=[0.5, 0, 0, 0, 0, 0]), bindings) + assert input_state.current_move_vector() == (1.5, 0.0, 0.0) + + +def test_gamepad_button_press_triggers_bound_action(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(buttons={"gp_a": True}), bindings) # gp_a -> leap + assert input_state.consume_leap() == input_state.facing + + +def test_gamepad_block_is_held_not_one_shot(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(buttons={"gp_left_bumper": True}), bindings) + assert input_state.is_blocking is True + input_state.apply_gamepad_state(GamepadState(buttons={"gp_left_bumper": False}), bindings) + assert input_state.is_blocking is False + + +def test_gamepad_trigger_press_activates_bound_hand(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + axes = [0.0] * 6 + axes[4] = 0.9 # left trigger -> gp_left_trigger -> activate_left_hand_2 + input_state.apply_gamepad_state(GamepadState(axes=axes), bindings) + assert input_state.consume_hand_activation() == "left_hand_2" + + +def test_gamepad_holding_a_button_across_frames_does_not_refire(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(buttons={"gp_a": True}), bindings) + input_state.consume_leap() + input_state.apply_gamepad_state(GamepadState(buttons={"gp_a": True}), bindings) # still held + assert input_state.consume_leap() is None # no new leap queued on the second frame + + +def test_gamepad_unbound_button_is_ignored(): + input_state = InputState() + bindings = GamepadBindings(dict(DEFAULT_GAMEPAD_BINDINGS)) + input_state.apply_gamepad_state(GamepadState(buttons={"gp_guide": True}), bindings) + assert input_state.consume_leap() is None + assert input_state.consume_hand_activation() is None diff --git a/tests/test_interaction.py b/tests/test_interaction.py new file mode 100644 index 0000000..901a5e4 --- /dev/null +++ b/tests/test_interaction.py @@ -0,0 +1,112 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.construction import interact_with_tile, wielded_tool + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 5, 5, 1) + for x in range(5): + for y in range(5): + field.set_tile(x, y, 0, Tile(subfloor=TileLayerInstance("dirt"), room=TileLayerInstance("wood_wall"))) + world = World(registry=registry) + world.add_map(field) + return world, field + + +def test_wielded_tool_finds_a_held_tool_kind_item(registry): + character = make_human(registry) + assert wielded_tool(character, registry) is None + character.wield_item("right_hand", "multi_deconstructor", registry) + tool = wielded_tool(character, registry) + assert tool is not None + assert tool.id == "multi_deconstructor" + + +def test_interact_without_a_wielded_tool_is_a_no_op(registry): + world, field = make_world(registry) + character = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left") + assert result is None + assert field.get_tile(2, 2, 0).room is not None # untouched + + +def test_deconstructor_left_click_destroys_with_no_item(registry): + world, field = make_world(registry) + character = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + character.wield_item("right_hand", "multi_deconstructor", registry) + + result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left") + assert result is True + assert field.get_tile(2, 2, 0).room is None + + +def test_deconstructor_right_click_yields_item_into_inventory(registry): + world, field = make_world(registry) + character = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + character.wield_item("right_hand", "multi_deconstructor", registry) + character.inventory = Inventory(4, 4) + + result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "right") + assert result is not None + assert result.def_id == "wood_planks" + assert field.get_tile(2, 2, 0).room is None + assert {p.item.def_id for p in character.inventory.items()} == {"wood_planks"} + + +def test_deconstructor_right_click_without_room_in_inventory_still_clears_layer(registry): + world, field = make_world(registry) + character = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + character.wield_item("right_hand", "multi_deconstructor", registry) + character.inventory = None # no inventory to receive the item + + result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "right") + assert result is not None + assert field.get_tile(2, 2, 0).room is None + + +def test_constructor_consumes_matching_material_and_builds_layer(registry): + world, field = make_world(registry) + field.get_tile(2, 2, 0).room = None # bare slot to build on + character = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + character.wield_item("right_hand", "constructor_tool", registry) + character.inventory = Inventory(4, 4) + planks_def = registry.get_item_def("wood_planks") + character.inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0) + + result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left") + assert result is True + assert field.get_tile(2, 2, 0).room.def_id == "wood_wall" + assert character.inventory.items() == [] + + +def test_constructor_fails_without_matching_material(registry): + world, field = make_world(registry) + field.get_tile(2, 2, 0).room = None + character = make_human(registry, position=EntityPosition("field", 2, 2, 0)) + character.wield_item("right_hand", "constructor_tool", registry) + character.inventory = Inventory(4, 4) + + result = interact_with_tile(character, world, "field", 2, 2, 0, "room", "left") + assert result is False + assert field.get_tile(2, 2, 0).room is None diff --git a/tests/test_inventory.py b/tests/test_inventory.py new file mode 100644 index 0000000..5b69fd9 --- /dev/null +++ b/tests/test_inventory.py @@ -0,0 +1,49 @@ +from engine.inventory import Inventory +from engine.item import ItemDef, ItemInstance + +STAFF = ItemDef(id="staff_oak", name="Oak Staff", width=1, height=5) +SANDWICH = ItemDef(id="sandwich", name="Sandwich", width=2, height=2, stackable=True, max_stack=4) + + +def test_place_non_overlapping_items_succeeds(): + inv = Inventory(6, 5) + staff = ItemInstance("item-1", "staff_oak") + sandwich = ItemInstance("item-2", "sandwich") + + assert inv.place(staff, STAFF, 0, 0) is True + assert inv.place(sandwich, SANDWICH, 1, 0) is True + assert {p.item.item_id for p in inv.items()} == {"item-1", "item-2"} + + +def test_overlapping_placement_rejected(): + inv = Inventory(6, 5) + staff = ItemInstance("item-1", "staff_oak") + other = ItemInstance("item-2", "sandwich") + + assert inv.place(staff, STAFF, 0, 0) is True + assert inv.can_place(SANDWICH, 0, 2) is False # overlaps rows 2-3 of the staff at x=0 + assert inv.place(other, SANDWICH, 0, 2) is False + + +def test_out_of_bounds_placement_rejected(): + inv = Inventory(6, 4) + assert inv.can_place(STAFF, 0, 0) is False # height 5 doesn't fit in a 4-tall grid at all + + +def test_remove_frees_footprint_for_reuse(): + inv = Inventory(6, 5) + staff = ItemInstance("item-1", "staff_oak") + inv.place(staff, STAFF, 0, 0) + + removed = inv.remove("item-1", STAFF) + assert removed is staff + assert inv.can_place(STAFF, 0, 0) is True + assert inv.place(staff, STAFF, 0, 0) is True + + +def test_find_first_fit_returns_leftmost_topmost_slot(): + inv = Inventory(6, 5) + inv.place(ItemInstance("item-1", "staff_oak"), STAFF, 0, 0) + + slot = inv.find_first_fit(SANDWICH) + assert slot == (1, 0) diff --git a/tests/test_knockback.py b/tests/test_knockback.py new file mode 100644 index 0000000..674a38c --- /dev/null +++ b/tests/test_knockback.py @@ -0,0 +1,232 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import Entity, EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.actions import activate_hand +from resources.logic.knockback import ( + apply_knockback, + apply_recoil, + direction_between, + melee_knockback_distance, + resolve_explosion_knockback, + resolve_melee_knockback, + resolve_ranged_knockback, +) +from resources.logic.ranged_combat import fire_held_weapon + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 15, 15, 1) + for x in range(15): + for y in range(15): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world, field + + +class FixedRng: + def __init__(self, roll=15, choice_value="torso"): + self._roll = roll + self._choice_value = choice_value + + def randint(self, _lo, _hi): + return self._roll + + def choice(self, seq): + return self._choice_value if self._choice_value is not None else seq[0] + + def uniform(self, _a, _b): + return 0.0 + + +# --- primitives ---------------------------------------------------------------------------- + + +def test_direction_between_computes_signed_unit_steps(): + a = EntityPosition("field", 5, 5, 0) + b = EntityPosition("field", 8, 3, 0) + assert direction_between(a, b) == (1, -1, 0) + + +def test_apply_knockback_moves_entity_up_to_distance(): + world, _field = make_world(None) + entity = Entity(position=EntityPosition("field", 5, 5, 0)) + world.registry = None + world.add_entity(entity) + world.registry = _FreeRegistry() + moved = apply_knockback(world, entity, (1, 0, 0), 3) + assert moved == 3 + assert (entity.position.x, entity.position.y) == (8, 5) + + +class _FreeRegistry: + def get_tile_layer_def(self, _layer, _def_id): + return None + + +def test_apply_knockback_stops_at_a_wall(): + world, field = make_world(None) + world.registry = _FreeRegistry() + field.get_tile(7, 5, 0).room = TileLayerInstance("wood_wall") + + class WallAwareRegistry: + def get_tile_layer_def(self, layer, def_id): + if layer == "room" and def_id == "wood_wall": + from engine.tile import TileLayerDef + + return TileLayerDef(id="wood_wall", layer="room", walkable=False) + return None + + world.registry = WallAwareRegistry() + entity = Entity(position=EntityPosition("field", 5, 5, 0)) + world.add_entity(entity) + moved = apply_knockback(world, entity, (1, 0, 0), 5) + assert moved == 1 # stopped just before the wall at x=7 + assert entity.position.x == 6 + + +def test_melee_knockback_distance_scales_with_strength(registry): + weak = make_human(registry, strength=5) + strong = make_human(registry, strength=20) + staff = registry.get_item_def("staff_oak") + assert melee_knockback_distance(strong, staff, registry) > melee_knockback_distance(weak, staff, registry) + + +def test_melee_knockback_distance_unarmed_uses_default_base(registry): + character = make_human(registry, strength=10) + assert melee_knockback_distance(character, None, registry) == round(0.3 * 1.0) + + +def test_apply_recoil_skips_rockets(): + world, _field = make_world(None) + world.registry = _FreeRegistry() + shooter = Entity(position=EntityPosition("field", 5, 5, 0)) + world.add_entity(shooter) + moved = apply_recoil(world, shooter, (1, 0, 0), knockback_dealt=10, is_rocket=True) + assert moved == 0 + assert shooter.position.x == 5 + + +def test_apply_recoil_pushes_shooter_backward_proportional_to_knockback(): + world, _field = make_world(None) + world.registry = _FreeRegistry() + shooter = Entity(position=EntityPosition("field", 5, 5, 0)) + world.add_entity(shooter) + moved = apply_recoil(world, shooter, (1, 0, 0), knockback_dealt=10, is_rocket=False) + assert moved == round(10 * 0.3) + assert shooter.position.x == 5 - moved + + +# --- resolve_melee_knockback ---------------------------------------------------------------- + + +def test_resolve_melee_knockback_pushes_defender_and_recoils_attacker(registry): + world, _field = make_world(registry) + attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20) + defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) + world.add_entity(attacker) + world.add_entity(defender) + staff = registry.get_item_def("staff_oak") + + moved = resolve_melee_knockback(world, attacker, defender, staff, hit=True, registry=registry) + assert moved > 0 + assert defender.position.x > 6 # pushed away from the attacker + + +def test_resolve_melee_knockback_no_op_on_miss(registry): + world, _field = make_world(registry) + attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20) + defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) + world.add_entity(attacker) + world.add_entity(defender) + staff = registry.get_item_def("staff_oak") + + moved = resolve_melee_knockback(world, attacker, defender, staff, hit=False, registry=registry) + assert moved == 0 + assert defender.position.x == 6 + + +# --- resolve_ranged_knockback / resolve_explosion_knockback ------------------------------- + + +def test_resolve_ranged_knockback_pushes_target_and_recoils_shooter(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + heavy = registry.get_item_def("ammo_heavy_caliber") + + moved = resolve_ranged_knockback(world, shooter, target, heavy, (1, 0, 0)) + assert moved == round(heavy.ranged_knockback) + assert target.position.x == 5 + moved + assert shooter.position.x < 0.0 + 1 # shooter recoiled backward from x=0 + + +def test_resolve_explosion_knockback_pushes_entities_radially(registry): + world, _field = make_world(registry) + east = make_human(registry, position=EntityPosition("field", 7, 5, 0)) + west = make_human(registry, position=EntityPosition("field", 3, 5, 0)) + world.add_entity(east) + world.add_entity(west) + rocket = registry.get_item_def("ammo_rocket") + + resolve_explosion_knockback(world, rocket, center=(5, 5, 0), hit_entities=[(east, 20.0), (west, 20.0)]) + assert east.position.x > 7 # pushed further east, away from center + assert west.position.x < 3 # pushed further west, away from center + + +# --- end-to-end through activate_hand / fire_held_weapon ----------------------------------- + + +def test_activate_hand_melee_knocks_back_target(registry): + world, _field = make_world(registry) + attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20) + attacker.bio["athletics"] = 15 + attacker.wield_item("right_hand", "staff_oak", registry) + defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) + world.add_entity(attacker) + world.add_entity(defender) + + activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=18)) + assert defender.position.x > 6 + + +def test_fire_held_weapon_heavy_caliber_knocks_back_even_when_blocked(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + shooter.wield_item("right_hand", "rifle_heavy", registry) + shooter.inventory = Inventory(4, 4) + ammo_def = registry.get_item_def("ammo_heavy_caliber") + shooter.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=5), ammo_def, 0, 0) + shooter.reload("right_hand", registry) + + # heavy_shield doesn't cover heavy_caliber... use one that does, and set is_blocking so the + # opposed-check path runs (knockback should still land regardless of that outcome) + defender = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) + world.add_entity(shooter) + world.add_entity(defender) + + original_x = defender.position.x + fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15)) + assert defender.position.x > original_x diff --git a/tests/test_leap.py b/tests/test_leap.py new file mode 100644 index 0000000..5957291 --- /dev/null +++ b/tests/test_leap.py @@ -0,0 +1,66 @@ +from engine.entity import Entity, EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World + + +class FakeRegistry: + def get_tile_layer_def(self, _layer, _def_id): + return None + + +def make_world_with_gap(): + # a 10x10 field with a "water" gap (still walkable=True at the data level, but no dock + # path) between the player's dock and a ship anchored two tiles away + parent = Map("world", 10, 10, 1) + for x in range(10): + for y in range(10): + parent.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + + ship = Map("ship", 3, 3, 1) + for x in range(3): + for y in range(3): + ship.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck"))) + + parent.embed(ship, (7, 5, 0)) # ship's footprint is x:7-9, y:5-7 + return parent, ship + + +def test_leap_crosses_a_gap_that_a_single_step_could_not(): + parent, ship = make_world_with_gap() + world = World() + world.maps = {"world": parent, "ship": ship} + world.registry = FakeRegistry() + + player = Entity(position=EntityPosition("world", 5, 6, 0)) + # a single step east only reaches (6,6,0), still short of the ship at x=7 + assert world.try_move(player, 1, 0, 0) is True + assert (player.position.x, player.position.y) == (6, 6) + + # leaping east 2 tiles clears the remaining gap and lands aboard the ship + assert world.try_leap(player, 1, 0, 0) is True + assert player.position.map_id == "ship" + assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0) + + +def test_leap_distance_is_configurable(): + parent, ship = make_world_with_gap() + world = World() + world.maps = {"world": parent, "ship": ship} + world.registry = FakeRegistry() + + player = Entity(position=EntityPosition("world", 4, 6, 0)) + assert world.try_leap(player, 1, 0, 0, distance=1) is True + assert player.position.map_id == "world" # a 1-tile leap from x=4 lands short of the ship at x=7 + assert (player.position.x, player.position.y) == (5, 6) + + +def test_leap_lands_out_of_bounds_fails_like_a_normal_move(): + parent, ship = make_world_with_gap() + world = World() + world.maps = {"world": parent, "ship": ship} + world.registry = FakeRegistry() + + player = Entity(position=EntityPosition("world", 0, 0, 0)) + assert world.try_leap(player, -1, 0, 0) is False + assert (player.position.x, player.position.y) == (0, 0) diff --git a/tests/test_map_embedding.py b/tests/test_map_embedding.py new file mode 100644 index 0000000..a0dde55 --- /dev/null +++ b/tests/test_map_embedding.py @@ -0,0 +1,141 @@ +from engine.entity import Entity, EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerDef, TileLayerInstance +from engine.world import World + + +def make_world(): + parent = Map("world", 10, 10, 1) + child = Map("ship", 4, 3, 1) + for x in range(4): + for y in range(3): + child.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck"))) + # leave (2, 0, 0) open as the doorway, wall the rest of the front/back/sides + for x, y in [(0, 0), (1, 0), (3, 0), (0, 2), (3, 2)]: + tile = child.get_tile(x, y, 0) + tile.room = TileLayerInstance("wood_wall") + + for x in range(10): + for y in range(10): + parent.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + + parent.embed(child, (6, 6, 0)) + return parent, child + + +def test_local_to_parent_and_back_are_inverses(): + _parent, child = make_world() + embedding = child.parent_embedding + for lx, ly, lz in [(0, 0, 0), (2, 0, 0), (3, 2, 0)]: + px, py, pz = embedding.local_to_parent(lx, ly, lz) + assert embedding.parent_to_local(px, py, pz) == (lx, ly, lz) + + +def test_embedding_at_detects_child_footprint(): + parent, child = make_world() + embedding = child.parent_embedding + assert parent.embedding_at(8, 6, 0) is embedding # inside ship footprint (6..9, 6..8) + assert parent.embedding_at(0, 0, 0) is None # outside ship footprint + + +class FakeRegistry: + def get_tile_layer_def(self, layer, def_id): + if layer == "room" and def_id == "wood_wall": + return TileLayerDef(id="wood_wall", layer="room", walkable=False, blocks_los=True) + return None + + +def test_try_move_boards_ship_from_parent_map(): + parent, child = make_world() + + world = World() + world.maps = {"world": parent, "ship": child} + world.registry = FakeRegistry() + + player = Entity(position=EntityPosition("world", 7, 5, 0)) # just below the doorway at (8,6) + assert world.try_move(player, 1, 1, 0) is True + assert player.position.map_id == "ship" + assert (player.position.x, player.position.y, player.position.z) == (2, 0, 0) + + +def test_try_move_disembarks_ship_back_to_parent_map(): + parent, child = make_world() + world = World() + world.maps = {"world": parent, "ship": child} + world.registry = FakeRegistry() + + # standing in the doorway (ship-local 2,0,0), step north off the ship's edge + player = Entity(position=EntityPosition("ship", 2, 0, 0)) + assert world.try_move(player, 0, -1, 0) is True + assert player.position.map_id == "world" + assert (player.position.x, player.position.y, player.position.z) == (8, 5, 0) + + +def test_try_move_blocked_by_wall(): + parent, child = make_world() + world = World() + world.maps = {"world": parent, "ship": child} + world.registry = FakeRegistry() + + player = Entity(position=EntityPosition("ship", 1, 1, 0)) + assert world.try_move(player, -1, -1, 0) is False # (0,0) is a wood_wall + assert player.position.map_id == "ship" + assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0) + + +def test_rotated_embedding_local_to_parent_and_back_are_inverses(): + _parent, child = make_world() + embedding = child.parent_embedding + embedding.rotate_to(1) # 90 degrees clockwise: 4x3 footprint becomes 3x4 + assert embedding.footprint_size() == (3, 4) + for lx, ly, lz in [(0, 0, 0), (2, 0, 0), (3, 2, 0), (1, 1, 0)]: + px, py, pz = embedding.local_to_parent(lx, ly, lz) + assert embedding.parent_to_local(px, py, pz) == (lx, ly, lz) + + +def test_boarding_still_works_after_ship_rotates(): + parent, child = make_world() + embedding = child.parent_embedding + embedding.rotate_to(1) + + world = World() + world.maps = {"world": parent, "ship": child} + world.registry = FakeRegistry() + + # the doorway (ship-local 2,0) now lands at a different parent cell post-rotation + door_parent = embedding.local_to_parent(2, 0, 0) + approach = (door_parent[0] - 1, door_parent[1] - 1, 0) + player = Entity(position=EntityPosition("world", *approach)) + assert world.try_move(player, 1, 1, 0) is True + assert player.position.map_id == "ship" + assert (player.position.x, player.position.y, player.position.z) == (2, 0, 0) + + +def test_moving_and_rotating_ship_does_not_touch_aboard_entities_local_position(): + _parent, child = make_world() + embedding = child.parent_embedding + + sailor = Entity(position=EntityPosition("ship", 2, 1, 0)) + original_local = (sailor.position.x, sailor.position.y, sailor.position.z) + + embedding.move_to((0, 0, 0)) + embedding.rotate_to(2) + + assert (sailor.position.x, sailor.position.y, sailor.position.z) == original_local + + +def test_disembarking_resolves_against_current_anchor_after_ship_moves(): + parent, child = make_world() + embedding = child.parent_embedding + + world = World() + world.maps = {"world": parent, "ship": child} + world.registry = FakeRegistry() + + embedding.move_to((5, 5, 0)) # ship has sailed elsewhere since boarding + + player = Entity(position=EntityPosition("ship", 2, 0, 0)) + assert world.try_move(player, 0, -1, 0) is True + assert player.position.map_id == "world" + # disembarks relative to the ship's *current* anchor, not the original (6,6,0) + assert (player.position.x, player.position.y, player.position.z) == (7, 4, 0) diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..5916a76 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,86 @@ +import asyncio + +from engine.network.client import GameClient +from engine.network.protocol import DEFAULT_PORT, decode_message, encode_message +from engine.network.server import GameServer + + +def run(coro): + return asyncio.run(asyncio.wait_for(coro, timeout=5)) + + +def test_default_port_is_42069(): + assert DEFAULT_PORT == 42069 + + +def test_encode_decode_round_trip(): + message = {"type": "move", "entity_id": "p1", "x": 3, "y": 4} + decoded = decode_message(encode_message(message).rstrip(b"\n")) + assert decoded == message + + +def test_two_clients_see_each_others_join_and_move(): + async def scenario(): + server = GameServer(host="127.0.0.1", port=0) + await server.start() + try: + alice = GameClient("alice", "Alice", host="127.0.0.1", port=server.port) + bob = GameClient("bob", "Bob", host="127.0.0.1", port=server.port) + + await alice.connect() + await bob.connect() # bob's hello -> alice should see a "join" for bob + join_event = await alice.receive() + assert join_event == {"type": "join", "entity_id": "bob", "name": "Bob"} + + await bob.send_move("demo_world", 5, 5, 0) + move_event = await alice.receive() + assert move_event == {"type": "move", "entity_id": "bob", "map_id": "demo_world", "x": 5, "y": 5, "z": 0} + + await bob.close() + leave_event = await alice.receive() + assert leave_event == {"type": "leave", "entity_id": "bob"} + + await alice.close() + finally: + await server.stop() + + run(scenario()) + + +def test_broadcast_excludes_the_sender(): + async def scenario(): + server = GameServer(host="127.0.0.1", port=0) + await server.start() + try: + alice = GameClient("alice", "Alice", host="127.0.0.1", port=server.port) + await alice.connect() + await alice.send_move("demo_world", 1, 1, 0) + # nothing else connected, so alice's own move should never be echoed back to her; + # confirm by racing a short timeout against receive() + try: + await asyncio.wait_for(alice.receive(), timeout=0.2) + assert False, "sender should not receive its own broadcast" + except asyncio.TimeoutError: + pass + await alice.close() + finally: + await server.stop() + + run(scenario()) + + +def test_server_reports_connected_clients(): + async def scenario(): + server = GameServer(host="127.0.0.1", port=0) + await server.start() + try: + assert server.clients == {} + alice = GameClient("alice", "Alice", host="127.0.0.1", port=server.port) + await alice.connect() + await asyncio.sleep(0.1) # let the server process the hello + assert "alice" in server.clients + await alice.close() + finally: + await server.stop() + + run(scenario()) diff --git a/tests/test_organs.py b/tests/test_organs.py new file mode 100644 index 0000000..5a4d7f7 --- /dev/null +++ b/tests/test_organs.py @@ -0,0 +1,202 @@ +from pathlib import Path + +import pytest + +from engine.character import Ailment, BodyPart, Character +from engine.defs import DefRegistry +from resources.logic.organs import apply_organ_interactions_tick + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +# --- def loading + auto-attachment --------------------------------------------------------- + + +def test_organ_defs_load_with_check_weights_and_affects_organs(registry): + heart = registry.get_organ_def("human_heart") + assert heart.host_slot == "torso" + assert heart.check_weights["strength"] == pytest.approx(0.6) + assert heart.affects_organs["human_kidneys"] == pytest.approx(0.05) + + +def test_new_default_body_parts_auto_attaches_organs_by_host_slot(registry): + parts = registry.new_default_body_parts("human") + torso = next(p for p in parts if p.slot == "torso") + organ_ids = {o.organ_def_id for o in torso.organs} + assert organ_ids == {"human_heart", "human_lungs", "human_liver", "human_kidneys"} + + head = next(p for p in parts if p.slot == "head") + assert {o.organ_def_id for o in head.organs} == {"human_brain"} + + arm = next(p for p in parts if p.slot == "left_arm") + assert arm.organs == [] + + +def test_new_default_body_parts_organs_are_independent_per_call(registry): + a = registry.new_default_body_parts("human") + b = registry.new_default_body_parts("human") + torso_a = next(p for p in a if p.slot == "torso") + torso_b = next(p for p in b if p.slot == "torso") + heart_a = next(o for o in torso_a.organs if o.organ_def_id == "human_heart") + heart_b = next(o for o in torso_b.organs if o.organ_def_id == "human_heart") + heart_a.integrity = 10.0 + assert heart_b.integrity == 100.0 + + +# --- get_or_create_body_part_override preserves organs/ailments ---------------------------- + + +def test_override_creation_preserves_default_organs(registry): + character = make_human(registry) + # damage integrity for an unrelated reason - this must not silently drop the organs + override = character.get_or_create_body_part_override("torso") + assert {o.organ_def_id for o in override.organs} == {"human_heart", "human_lungs", "human_liver", "human_kidneys"} + + +def test_override_creation_preserves_existing_ailments(registry): + character = make_human(registry) + default_torso = character.get_body_part("torso") + default_torso.ailments.append(Ailment(id="preexisting", name="Preexisting")) + override = character.get_or_create_body_part_override("torso") + assert any(a.id == "preexisting" for a in override.ailments) + + +# --- organ_penalty / total_check_penalty ---------------------------------------------------- + + +def test_organ_penalty_zero_when_healthy(registry): + character = make_human(registry) + assert character.organ_penalty("strength", registry) == 0.0 + + +def test_organ_penalty_rises_with_heart_damage(registry): + character = make_human(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 50.0 + penalty = character.organ_penalty("strength", registry) + assert penalty > 0.0 + + +def test_organ_penalty_zero_for_unrelated_check(registry): + character = make_human(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 0.0 + heart.removed = True + # heart doesn't weight "perception" at all + assert character.organ_penalty("perception", registry) == 0.0 + + +def test_total_check_penalty_equals_skill_penalty_when_organs_healthy(registry): + character = make_human(registry) + character.body_parts = [BodyPart("left_leg", "human_left_leg", integrity=20.0)] + assert character.total_check_penalty("acrobatics", registry) == pytest.approx( + character.skill_penalty("acrobatics", registry) + ) + + +def test_total_check_penalty_combines_body_part_and_organ_damage(registry): + character = make_human(registry) + character.body_parts = [BodyPart("left_arm", "human_left_arm", integrity=0.0, removed=True)] + torso = character.get_or_create_body_part_override("torso") + lungs = next(o for o in torso.organs if o.organ_def_id == "human_lungs") + lungs.integrity = 40.0 # damaged but not removed, so neither penalty alone saturates at 1.0 + + combined = character.total_check_penalty("athletics", registry) + body_only = character.skill_penalty("athletics", registry) + organ_only = character.organ_penalty("athletics", registry) + assert combined > body_only + assert combined > organ_only + assert combined <= 1.0 + + +# --- effective_strength / effective_speed ---------------------------------------------------- + + +def test_effective_strength_reduced_by_damaged_heart(registry): + character = make_human(registry, strength=20) + healthy = character.effective_strength(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 20.0 + damaged = character.effective_strength(registry) + assert damaged < healthy + + +def test_effective_speed_reduced_by_damaged_heart_and_lungs(registry): + character = make_human(registry) + healthy_speed = character.effective_speed(30.0, registry) + torso = character.get_or_create_body_part_override("torso") + for organ_id in ("human_heart", "human_lungs"): + organ = next(o for o in torso.organs if o.organ_def_id == organ_id) + organ.integrity = 0.0 + organ.removed = True + damaged_speed = character.effective_speed(30.0, registry) + assert damaged_speed < healthy_speed + + +# --- organ-to-organ interaction tick ---------------------------------------------------------- + + +def test_apply_organ_interactions_tick_damages_kidneys_from_a_weak_heart(registry): + character = make_human(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 0.0 # fully damaged -> damage_fraction = 1.0 + + apply_organ_interactions_tick(character, registry, ticks=10) + + kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys") + # rate 0.05 * damage_fraction 1.0 * 10 ticks = 0.5 damage + assert kidneys.integrity == pytest.approx(99.5) + + +def test_apply_organ_interactions_tick_no_op_when_organs_healthy(registry): + character = make_human(registry) + apply_organ_interactions_tick(character, registry, ticks=10) + kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys") + assert kidneys.integrity == 100.0 + + +def test_apply_organ_interactions_tick_zero_ticks_is_a_no_op(registry): + character = make_human(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 0.0 + apply_organ_interactions_tick(character, registry, ticks=0) + kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys") + assert kidneys.integrity == 100.0 + + +def test_apply_organ_interactions_tick_does_not_cascade_within_one_call(registry): + character = make_human(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 0.0 # heart affects kidneys, but kidneys don't affect anything themselves + + apply_organ_interactions_tick(character, registry, ticks=1) + # sanity: no exception, no unexpected organ (e.g. liver) got touched by a cascade + liver = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_liver") + assert liver.integrity == 100.0 + + +def test_removed_organ_no_longer_contributes_to_further_interactions(registry): + character = make_human(registry) + torso = character.get_or_create_body_part_override("torso") + heart = next(o for o in torso.organs if o.organ_def_id == "human_heart") + heart.integrity = 0.0 + heart.removed = True + + apply_organ_interactions_tick(character, registry, ticks=100) + kidneys = next(o for o in character.get_body_part("torso").organs if o.organ_def_id == "human_kidneys") + assert kidneys.integrity == 100.0 # removed heart is skipped as a source entirely diff --git a/tests/test_ranged_combat.py b/tests/test_ranged_combat.py new file mode 100644 index 0000000..327e487 --- /dev/null +++ b/tests/test_ranged_combat.py @@ -0,0 +1,268 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.ranged_combat import effective_aim_cone, fire_held_weapon, perform_explosion + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 15, 15, 1) + for x in range(15): + for y in range(15): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world, field + + +class FixedRng: + def __init__(self, roll=15, choice_value=None, uniform_value=0.0): + self._roll = roll + self._choice_value = choice_value + self._uniform_value = uniform_value + + def randint(self, _lo, _hi): + return self._roll + + def choice(self, seq): + return self._choice_value if self._choice_value is not None else seq[0] + + def uniform(self, _a, _b): + return self._uniform_value + + +# --- effective_aim_cone ----------------------------------------------------------------- + + +def test_effective_aim_cone_zero_for_unarmed_hand(registry): + character = make_human(registry) + assert effective_aim_cone(character, "right_hand", registry) == 0.0 + + +def test_effective_aim_cone_zero_for_non_cone_weapon(registry): + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + assert effective_aim_cone(character, "right_hand", registry) == 0.0 + + +def test_effective_aim_cone_uses_weapon_base_cone(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + rifle = registry.get_item_def("rifle_heavy") + assert effective_aim_cone(character, "right_hand", registry) == pytest.approx(rifle.aim_cone_degrees) + + +def test_effective_aim_cone_narrows_with_skill(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + baseline = effective_aim_cone(character, "right_hand", registry) + character.bio["sleight_of_hand"] = 10 + with_skill = effective_aim_cone(character, "right_hand", registry) + assert with_skill < baseline + + +def test_effective_aim_cone_narrows_with_attachments(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + baseline = effective_aim_cone(character, "right_hand", registry) + character.attach_mod("right_hand", "scope_basic", registry) + with_scope = effective_aim_cone(character, "right_hand", registry) + assert with_scope == pytest.approx(baseline - 3.0) + + +def test_effective_aim_cone_never_below_minimum(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + character.bio["sleight_of_hand"] = 1000 # absurdly high, should clamp not go negative + assert effective_aim_cone(character, "right_hand", registry) >= 1.0 + + +# --- attach_mod / magazine_capacity / reload -------------------------------------------- + + +def test_attach_mod_fails_on_non_ranged_weapon(registry): + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + assert character.attach_mod("right_hand", "scope_basic", registry) is False + + +def test_attach_mod_succeeds_on_ranged_weapon(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + assert character.attach_mod("right_hand", "scope_basic", registry) is True + assert "scope_basic" in character.get_held_item("right_hand").attachments + + +def test_magazine_capacity_includes_extended_mag_bonus(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + base_capacity = character.magazine_capacity("right_hand", registry) + character.attach_mod("right_hand", "extended_mag", registry) + assert character.magazine_capacity("right_hand", registry) == base_capacity + 5 + + +def test_reload_consumes_compatible_ammo_from_inventory(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + character.inventory = Inventory(4, 4) + ammo_def = registry.get_item_def("ammo_heavy_caliber") + character.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=10), ammo_def, 0, 0) + + assert character.reload("right_hand", registry) is True + held = character.get_held_item("right_hand") + assert held.loaded_ammo_type == "heavy_caliber" + assert held.loaded_ammo_count == 5 # rifle_heavy magazine_size + remaining = next(p for p in character.inventory.items() if p.item.item_id == "ammo-1") + assert remaining.item.quantity == 5 + + +def test_reload_fails_without_compatible_ammo(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + character.inventory = Inventory(4, 4) + shell_def = registry.get_item_def("ammo_shotgun_shell") + character.inventory.place(ItemInstance("ammo-1", "ammo_shotgun_shell", quantity=10), shell_def, 0, 0) + assert character.reload("right_hand", registry) is False + + +def test_reload_fails_when_magazine_already_full(registry): + character = make_human(registry) + character.wield_item("right_hand", "rifle_heavy", registry) + character.inventory = Inventory(4, 4) + ammo_def = registry.get_item_def("ammo_heavy_caliber") + character.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=20), ammo_def, 0, 0) + character.reload("right_hand", registry) + assert character.reload("right_hand", registry) is False + + +def test_reload_fails_for_non_ammo_weapon(registry): + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + character.inventory = Inventory(4, 4) + assert character.reload("right_hand", registry) is False + + +# --- fire_held_weapon -------------------------------------------------------------------- + + +def test_fire_held_weapon_fails_when_unloaded(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + shooter.wield_item("right_hand", "rifle_heavy", registry) + world.add_entity(shooter) + assert fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0)) is None + + +def test_fire_held_weapon_decrements_ammo_and_hits_target(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + shooter.wield_item("right_hand", "rifle_heavy", registry) + shooter.inventory = Inventory(4, 4) + ammo_def = registry.get_item_def("ammo_heavy_caliber") + shooter.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=5), ammo_def, 0, 0) + shooter.reload("right_hand", registry) + + target = make_human(registry, position=EntityPosition("field", 3, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + + result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15)) + assert shooter.get_held_item("right_hand").loaded_ammo_count == 4 + assert result.hit is True + assert result.target_entity_id == target.entity_id + + +def test_fire_held_weapon_shotgun_fires_multiple_pellets(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + shooter.wield_item("right_hand", "shotgun_basic", registry) + shooter.inventory = Inventory(4, 4) + shell_def = registry.get_item_def("ammo_shotgun_shell") + shooter.inventory.place(ItemInstance("shells-1", "ammo_shotgun_shell", quantity=5), shell_def, 0, 0) + shooter.reload("right_hand", registry) + world.add_entity(shooter) + + result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15)) + assert len(result.pellets) == 6 # shotgun_basic pellet_count + + +def test_fire_held_weapon_shotgun_slug_fires_single_projectile(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + shooter.wield_item("right_hand", "shotgun_basic", registry) + shooter.inventory = Inventory(4, 4) + slug_def = registry.get_item_def("ammo_shotgun_slug") + shooter.inventory.place(ItemInstance("slugs-1", "ammo_shotgun_slug", quantity=5), slug_def, 0, 0) + shooter.reload("right_hand", registry) + world.add_entity(shooter) + + result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15)) + assert len(result.pellets) == 1 + + +def test_fire_held_weapon_rocket_launcher_explodes(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + shooter.wield_item("right_hand", "rocket_launcher", registry) + shooter.inventory = Inventory(4, 4) + rocket_def = registry.get_item_def("ammo_rocket") + shooter.inventory.place(ItemInstance("rockets-1", "ammo_rocket", quantity=2), rocket_def, 0, 0) + shooter.reload("right_hand", registry) + target = make_human(registry, position=EntityPosition("field", 6, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + + result = fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15)) + assert result.impact is not None + assert any(target is t for t, _dmg in result.hit_entities) + + +# --- perform_explosion -------------------------------------------------------------------- + + +def test_perform_explosion_damages_entities_with_falloff_by_distance(registry): + world, field = make_world(registry) + close = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + far = make_human(registry, position=EntityPosition("field", 7, 5, 0)) + world.add_entity(close) + world.add_entity(far) + + result = perform_explosion(world, "field", (5, 5, 0), radius=2, base_damage=30.0) + hit_map = {id(t): dmg for t, dmg in result.hit_entities} + close_damage = hit_map[id(close)] + far_damage = hit_map[id(far)] + assert close_damage > far_damage + + +def test_perform_explosion_damages_walls_within_radius(registry): + world, field = make_world(registry) + field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall") + perform_explosion(world, "field", (5, 5, 0), radius=2, base_damage=200.0) + # 200 damage with falloff should be enough to destroy a wall within radius 1 + assert field.get_tile(6, 5, 0).room is None + + +def test_perform_explosion_does_not_affect_tiles_outside_radius(registry): + world, field = make_world(registry) + field.get_tile(10, 5, 0).room = TileLayerInstance("wood_wall") + perform_explosion(world, "field", (5, 5, 0), radius=1, base_damage=200.0) + assert field.get_tile(10, 5, 0).room is not None diff --git a/tests/test_ship_test_map.py b/tests/test_ship_test_map.py new file mode 100644 index 0000000..e706930 --- /dev/null +++ b/tests/test_ship_test_map.py @@ -0,0 +1,181 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import Entity, EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map_loader import MapLoader +from engine.world import World +from resources.logic.actions import activate_hand +from resources.logic.steering import at_helm, try_steer_ship, try_turn_ship +from resources.logic.weather import is_exposed + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +@pytest.fixture() +def world_and_ship(registry): + loader = MapLoader(DEFS_DIR / "maps") + harbor = loader.load("harbor_world.json") + embedding = harbor.embeddings[0] + ship = embedding.child_map + + world = World(registry=registry) + world.add_map(harbor) + world.add_map(ship) + return world, harbor, ship, embedding + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +# --- embedding + rotation --------------------------------------------------------------------- + + +def test_ship_is_embedded_with_a_quarter_turn_rotation(world_and_ship): + _world, _harbor, _ship, embedding = world_and_ship + assert embedding.rotation == 1 + assert embedding.child_map.map_id == "ship_test" + + +def test_rotated_footprint_places_local_cells_at_the_expected_parent_cells(world_and_ship): + _world, _harbor, _ship, embedding = world_and_ship + # rotation=1 on a square footprint: local (x, y) -> parent (anchor + (height-1-y, x)) + assert embedding.local_to_parent(0, 2, 0) == (6, 4, 0) # hold doorway + assert embedding.local_to_parent(3, 3, 0) == (5, 7, 0) # hold stairs_up + assert embedding.local_to_parent(1, 1, 1) == (7, 5, 1) # helm (not reachable from parent, see below) + + +# --- boarding: parent field -> ship hold via a wall doorway ----------------------------------- + + +def test_walking_through_the_doorway_boards_the_ship(world_and_ship, registry): + world, harbor, _ship, embedding = world_and_ship + door_parent = embedding.local_to_parent(0, 2, 0) + outside = (door_parent[0] - 1, door_parent[1], door_parent[2]) + assert harbor.get_tile(*outside).is_walkable(registry) + + player = Entity(position=EntityPosition("harbor_world", *outside)) + assert world.try_move(player, 1, 0, 0) is True + assert (player.position.map_id, player.position.x, player.position.y, player.position.z) == ( + "ship_test", 0, 2, 0, + ) + + +def test_ship_perimeter_wall_blocks_boarding_anywhere_else(world_and_ship, registry): + world, _harbor, _ship, embedding = world_and_ship + wall_parent = embedding.local_to_parent(0, 1, 0) # a walled hold cell, not the doorway + outside = (wall_parent[0] - 1, wall_parent[1], wall_parent[2]) + player = Entity(position=EntityPosition("harbor_world", *outside)) + assert world.try_move(player, 1, 0, 0) is False + + +# --- staircase: hold (z=0) <-> main deck (z=1), same (x, y) column ---------------------------- + + +def test_staircase_carries_player_from_main_deck_down_to_the_hold(world_and_ship, registry): + world, _harbor, _ship, _embedding = world_and_ship + player = make_human(registry, position=EntityPosition("ship_test", 3, 2, 1)) + world.add_entity(player) + assert world.try_move(player, 0, 1, 0) is True # steps onto (3,3,1) stairs_down + assert (player.position.x, player.position.y, player.position.z) == (3, 3, 0) + + +def test_staircase_round_trip_back_up_after_stepping_off_and_on(world_and_ship, registry): + world, _harbor, _ship, _embedding = world_and_ship + player = make_human(registry, position=EntityPosition("ship_test", 3, 2, 1)) + world.add_entity(player) + world.try_move(player, 0, 1, 0) # down to the hold, standing on stairs_up at (3,3,0) + assert world.try_move(player, -1, 0, 0) is True # step off, to (2,3,0) + assert player.position.z == 0 + assert world.try_move(player, 1, 0, 0) is True # step back onto stairs_up at (3,3,0) + assert (player.position.x, player.position.y, player.position.z) == (3, 3, 1) + + +# --- steering: move and turn the ship from its helm -------------------------------------------- + + +def test_standing_at_the_helm_is_detected(world_and_ship, registry): + world, _harbor, _ship, embedding = world_and_ship + helmsman = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1)) + world.add_entity(helmsman) + assert at_helm(helmsman, world) is embedding + + +def test_steering_moves_the_ships_anchor(world_and_ship, registry): + world, _harbor, _ship, embedding = world_and_ship + helmsman = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1)) + world.add_entity(helmsman) + original_anchor = embedding.anchor + assert try_steer_ship(helmsman, world, 1, 0, 0) is True + assert embedding.anchor == (original_anchor[0] + 1, original_anchor[1], original_anchor[2]) + + +def test_turning_the_ship_changes_its_rotation(world_and_ship, registry): + world, _harbor, _ship, embedding = world_and_ship + helmsman = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1)) + world.add_entity(helmsman) + assert try_turn_ship(helmsman, world, 1) is True + assert embedding.rotation == 2 + + +def test_not_at_helm_cannot_steer(world_and_ship, registry): + world, _harbor, _ship, embedding = world_and_ship + bystander = make_human(registry, position=EntityPosition("ship_test", 3, 1, 1)) + world.add_entity(bystander) + original_anchor = embedding.anchor + assert try_steer_ship(bystander, world, 1, 0, 0) is False + assert embedding.anchor == original_anchor + + +# --- weather exposure: roofed hold/helm booth vs. unroofed open deck -------------------------- + + +def test_hold_is_sheltered_from_weather(world_and_ship, registry): + _world, _harbor, ship, _embedding = world_and_ship + assert is_exposed(ship, 2, 2, 0, registry) is False + + +def test_helm_booth_is_sheltered_but_the_open_deck_around_it_is_not(world_and_ship, registry): + _world, _harbor, ship, _embedding = world_and_ship + assert is_exposed(ship, 1, 1, 1, registry) is False # under the helm booth's roof + assert is_exposed(ship, 3, 1, 1, registry) is True # open deck, no roof + + +# --- deconstructor/constructor tools work on the ship's own tiles ----------------------------- + + +def test_deconstructor_removes_the_helm_booths_wall(world_and_ship, registry): + world, _harbor, ship, _embedding = world_and_ship + character = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1)) + character.wield_item("right_hand", "multi_deconstructor", registry) + character.inventory = Inventory(4, 4) + world.add_entity(character) + + assert ship.get_tile(2, 1, 1).room is not None + result = activate_hand(character, world, registry, "right_hand", (1, 0, 0)) + assert result is True + assert ship.get_tile(2, 1, 1).room is None + + +def test_constructor_rebuilds_a_wall_from_carried_planks(world_and_ship, registry): + world, _harbor, ship, _embedding = world_and_ship + ship.get_tile(2, 1, 1).room = None # bare slot, as if just deconstructed + character = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1)) + character.wield_item("right_hand", "constructor_tool", registry) + character.inventory = Inventory(4, 4) + character.inventory.place(ItemInstance("planks-1", "wood_planks"), registry.get_item_def("wood_planks"), 0, 0) + world.add_entity(character) + + result = activate_hand(character, world, registry, "right_hand", (1, 0, 0)) + assert result is True + assert ship.get_tile(2, 1, 1).room.def_id == "wood_wall" diff --git a/tests/test_shooting.py b/tests/test_shooting.py new file mode 100644 index 0000000..15a9816 --- /dev/null +++ b/tests/test_shooting.py @@ -0,0 +1,158 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.combat import find_ranged_target, perform_shoot + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world, field + + +class ScriptedRng: + """Deterministic stand-in: independent, ordered queues for randint() and choice() calls.""" + + def __init__(self, randints=None, choices=None): + self._randints = list(randints or []) + self._choices = list(choices or []) + + def randint(self, _lo, _hi): + return self._randints.pop(0) + + def choice(self, seq): + if self._choices: + return self._choices.pop(0) + return seq[0] + + +def test_find_ranged_target_hits_entity_in_line(registry): + world, field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 3, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + + found, impact, steps = find_ranged_target(world, "field", 0, 5, 0, 1, 0, 0, max_range=6) + assert found is target + assert impact == (3, 5, 0) + assert steps == 3 + + +def test_find_ranged_target_stops_at_a_wall(registry): + world, field = make_world(registry) + field.get_tile(2, 5, 0).room = TileLayerInstance("wood_wall") + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 5, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + + found, impact, _steps = find_ranged_target(world, "field", 0, 5, 0, 1, 0, 0, max_range=6) + assert found is None + assert impact == (2, 5, 0) + + +def test_find_ranged_target_respects_max_range(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 9, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + + found, _impact, _steps = find_ranged_target(world, "field", 0, 5, 0, 1, 0, 0, max_range=3) + assert found is None + + +def test_perform_shoot_hits_and_damages_target(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + target = make_human(registry, position=EntityPosition("field", 3, 5, 0)) + world.add_entity(shooter) + world.add_entity(target) + pistol = registry.get_item_def("pistol_basic") + + result = perform_shoot(shooter, world, registry, pistol, (1, 0, 0), rng=ScriptedRng(randints=[15])) + assert result.hit is True + assert result.blocked is False + assert result.target_entity_id == target.entity_id + assert result.attack.target_slot is not None + assert target.get_body_part(result.attack.target_slot).integrity < 100.0 + + +def test_shield_blocks_matching_projectile_type_without_ricochet_target(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + blocker = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) + blocker.wield_item("left_hand", "wooden_shield", registry) + world.add_entity(shooter) + world.add_entity(blocker) + bow = registry.get_item_def("bow_basic") # projectile_type "arrow", which wooden_shield blocks + + # ricochet direction (choice=1 -> deflect south) finds nothing there, so the bounce fizzles + result = perform_shoot(shooter, world, registry, bow, (1, 0, 0), rng=ScriptedRng(choices=[1])) + assert result.blocked is True + assert result.ricocheted is True + assert result.hit is False + assert result.target_entity_id is None # nothing found along the ricochet path + + +def test_shield_blocks_and_ricochet_hits_a_second_target(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + blocker = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) + blocker.wield_item("left_hand", "wooden_shield", registry) + bystander = make_human(registry, position=EntityPosition("field", 3, 7, 0)) # 2 tiles south of the blocker + world.add_entity(shooter) + world.add_entity(blocker) + world.add_entity(bystander) + bow = registry.get_item_def("bow_basic") + + result = perform_shoot(shooter, world, registry, bow, (1, 0, 0), rng=ScriptedRng(randints=[15], choices=[1])) + assert result.blocked is True + assert result.ricocheted is True + assert result.target_entity_id == bystander.entity_id + assert result.hit is True + assert result.attack.target_slot is not None + assert bystander.get_body_part(result.attack.target_slot).integrity < 100.0 + + +def test_non_matching_projectile_type_breaks_through_shield(registry): + world, _field = make_world(registry) + shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) + blocker = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) + blocker.wield_item("left_hand", "wooden_shield", registry) # only blocks "arrow" + world.add_entity(shooter) + world.add_entity(blocker) + pistol = registry.get_item_def("pistol_basic") # projectile_type "bullet" + + # blocker is still in a blocking stance, so the opposed check still applies even though the + # shield doesn't cover "bullet" - it just can't hard-block/ricochet this projectile type + result = perform_shoot( + shooter, world, registry, pistol, (1, 0, 0), rng=ScriptedRng(randints=[15, 1], choices=["torso"]) + ) + assert result.blocked is False + assert result.ricocheted is False + assert result.target_entity_id == blocker.entity_id + assert result.attack.blocked_check is True diff --git a/tests/test_species_samples.py b/tests/test_species_samples.py new file mode 100644 index 0000000..8dbbc48 --- /dev/null +++ b/tests/test_species_samples.py @@ -0,0 +1,210 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.inventory import Inventory +from engine.item import ItemInstance +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.actions import activate_hand +from resources.logic.organs import apply_organ_interactions_tick + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_character(registry, species_id: str, **kwargs) -> Character: + return Character(species_id=species_id, default_body_parts=registry.new_default_body_parts(species_id), **kwargs) + + +def make_world(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world, field + + +# --- explicit, fully bespoke default_organs per species ------------------------------------- + + +def test_human_species_gets_its_organs(registry): + parts = registry.new_default_body_parts("human") + torso = next(p for p in parts if p.slot == "torso") + assert {o.organ_def_id for o in torso.organs} == {"human_heart", "human_lungs", "human_liver", "human_kidneys"} + + +def test_bot_species_gets_bespoke_mechanical_organs(registry): + parts = registry.new_default_body_parts("bot") + head = next(p for p in parts if p.slot == "head") + torso = next(p for p in parts if p.slot == "torso") + assert {o.organ_def_id for o in head.organs} == {"bot_cpu", "bot_npu"} + assert {o.organ_def_id for o in torso.organs} == { + "bot_power_cell", "bot_hydraulic_fluid_bladder", "bot_cooling_fan", + } + + +def test_bot_power_cell_affects_multiple_organs(registry): + bot = make_character(registry, "bot") + torso = bot.get_or_create_body_part_override("torso") + power_cell = next(o for o in torso.organs if o.organ_def_id == "bot_power_cell") + power_cell.integrity = 0.0 + + apply_organ_interactions_tick(bot, registry, ticks=10) + + head = bot.get_body_part("head") + cpu = next(o for o in head.organs if o.organ_def_id == "bot_cpu") + npu = next(o for o in head.organs if o.organ_def_id == "bot_npu") + fluid_bladder = next(o for o in bot.get_body_part("torso").organs if o.organ_def_id == "bot_hydraulic_fluid_bladder") + assert cpu.integrity < 100.0 + assert npu.integrity < 100.0 + assert fluid_bladder.integrity < 100.0 + + +def test_reptilian_venom_gland_is_bespoke_not_shared_with_other_species(registry): + parts = registry.new_default_body_parts("reptilian_quad") + torso = next(p for p in parts if p.slot == "torso") + assert {o.organ_def_id for o in torso.organs} == { + "reptilian_heart", "reptilian_lung", "reptilian_liver", "reptilian_venom_gland", + } + + +def test_zenari_organs_are_bespoke(registry): + parts = registry.new_default_body_parts("alien_tri") + torso = next(p for p in parts if p.slot == "torso") + head = next(p for p in parts if p.slot == "head") + assert {o.organ_def_id for o in torso.organs} == { + "zenari_cardial_node", "zenari_respirator", "zenari_metabolizer", "zenari_filtration_organ", + } + assert {o.organ_def_id for o in head.organs} == {"zenari_cortex"} + + +def test_dog_organs_are_bespoke_not_shared_with_human(registry): + parts = registry.new_default_body_parts("dog") + torso = next(p for p in parts if p.slot == "torso") + assert {o.organ_def_id for o in torso.organs} == {"dog_heart", "dog_lungs", "dog_liver", "dog_kidneys"} + + +def test_no_species_shares_an_organ_id_with_another(registry): + species_ids = ("human", "reptilian_quad", "alien_tri", "bot", "dog") + seen: dict[str, str] = {} + for species_id in species_ids: + species = registry.get_species_def(species_id) + for organ_id in species.default_organs: + assert organ_id not in seen, f"{organ_id!r} shared between {seen.get(organ_id)!r} and {species_id!r}" + seen[organ_id] = species_id + + +# --- reptilian_quad: 4 arm slots ------------------------------------------------------------- + + +def test_reptilian_has_four_arm_slots(registry): + species = registry.get_species_def("reptilian_quad") + slots = {p.slot for p in species.default_body_parts} + assert {"left_arm", "right_arm", "left_arm_2", "right_arm_2"} <= slots + + +def test_reptilian_can_wield_two_two_handed_tools_at_once(registry): + reptilian = make_character(registry, "reptilian_quad") + assert reptilian.wield_item("right_hand", "multi_deconstructor", registry) is True + assert reptilian.wield_item("right_hand_2", "constructor_tool", registry) is True + assert reptilian.get_held_item("left_hand").item_def_id == "multi_deconstructor" + assert reptilian.get_held_item("left_hand_2").item_def_id == "constructor_tool" + + +def test_reptilian_secondary_hand_pair_does_not_disturb_primary(registry): + reptilian = make_character(registry, "reptilian_quad") + reptilian.wield_item("right_hand", "staff_oak", registry) + reptilian.wield_item("right_hand_2", "multi_deconstructor", registry) + assert reptilian.get_held_item("right_hand").item_def_id == "staff_oak" + assert reptilian.get_held_item("left_hand") is None # staff is one-handed, doesn't touch left_hand + assert reptilian.get_held_item("left_hand_2").item_def_id == "multi_deconstructor" + + +def test_two_armed_alien_cannot_use_secondary_hand_pair(registry): + alien = make_character(registry, "alien_tri") + assert alien.wield_item("right_hand_2", "staff_oak", registry) is False + + +# --- alien_tri: 3 genders -------------------------------------------------------------------- + + +def test_alien_tri_species_declares_three_genders(registry): + species = registry.get_species_def("alien_tri") + assert species.genders == ["alpha", "beta", "gamma"] + + +# --- dog: quadruped, no arms ----------------------------------------------------------------- + + +def test_dog_has_no_arm_slots(registry): + species = registry.get_species_def("dog") + slots = {p.slot for p in species.default_body_parts} + assert slots == {"head", "torso", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"} + + +def test_dog_cannot_wield_anything(registry): + dog = make_character(registry, "dog") + assert dog.wield_item("right_hand", "staff_oak", registry) is False + assert dog.held_items == [] + + +def test_dog_effective_speed_uses_all_four_legs(registry): + dog = make_character(registry, "dog") + human = make_character(registry, "human") + # 4 legs at 0.25 speed_factor each should sum to the same total factor as 2 legs at 0.5 each + assert dog.effective_speed(30.0, registry) == pytest.approx(human.effective_speed(30.0, registry)) + + +def test_dog_can_perform_bite_attack_but_not_arm_dependent_abilities(registry): + dog = make_character(registry, "dog") + assert dog.can_perform_ability("melee_attack", registry) is True + assert dog.can_perform_ability("time_warp_sphere", registry) is False # not in dog's ability list at all + + +# --- one-ability-per-required-hand: de/constructor tools, verified across species ----------- + + +def test_constructor_via_left_hand_also_builds(registry): + world, field = make_world(registry) + character = make_character(registry, "human", position=EntityPosition("field", 5, 5, 0)) + character.wield_item("left_hand", "constructor_tool", registry) + character.inventory = Inventory(4, 4) + planks_def = registry.get_item_def("wood_planks") + character.inventory.place(ItemInstance("planks-1", "wood_planks"), planks_def, 0, 0) + world.add_entity(character) + + result = activate_hand(character, world, registry, "left_hand", (1, 0, 0)) + assert result is True + assert field.get_tile(6, 5, 0).room.def_id == "wood_wall" + + +def test_reptilian_secondary_pair_deconstructor_independent_of_primary_pair_tool(registry): + """A 4-armed species can wield a 2-handed tool in each hand pair; activating one hand + pair's tool must not affect the other pair's separately-wielded tool - each required hand + only ever triggers the ability of whatever it itself is holding. + """ + world, field = make_world(registry) + field.get_tile(6, 5, 0).room = TileLayerInstance("wood_wall") + reptilian = make_character(registry, "reptilian_quad", position=EntityPosition("field", 5, 5, 0)) + reptilian.wield_item("right_hand", "multi_deconstructor", registry) + reptilian.wield_item("right_hand_2", "constructor_tool", registry) + world.add_entity(reptilian) + + # right_hand's deconstructor: left-click semantics (button="left") -> destroy, no item yielded + result = activate_hand(reptilian, world, registry, "right_hand", (1, 0, 0)) + assert result is True + assert field.get_tile(6, 5, 0).room is None + + # the secondary pair's constructor is untouched by the above and still independently usable + assert reptilian.get_held_item("left_hand_2").item_def_id == "constructor_tool" diff --git a/tests/test_speed.py b/tests/test_speed.py new file mode 100644 index 0000000..895b2f7 --- /dev/null +++ b/tests/test_speed.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def test_two_healthy_legs_give_full_base_speed(registry): + character = make_human(registry) + assert character.effective_speed(30.0, registry) == pytest.approx(30.0) + + +def test_missing_one_leg_halves_speed(registry): + character = make_human(registry, body_parts=[BodyPart("left_leg", "human_left_leg", removed=True)]) + assert character.effective_speed(30.0, registry) == pytest.approx(15.0) + + +def test_missing_both_legs_gives_zero_speed(registry): + character = make_human( + registry, + body_parts=[ + BodyPart("left_leg", "human_left_leg", removed=True), + BodyPart("right_leg", "human_right_leg", removed=True), + ], + ) + assert character.effective_speed(30.0, registry) == 0.0 + + +def test_damaged_leg_contributes_proportionally_to_integrity(registry): + character = make_human(registry, body_parts=[BodyPart("left_leg", "human_left_leg", integrity=50.0)]) + # left leg contributes 0.5 * 0.5 = 0.25, right leg contributes 0.5 -> total 0.75 + assert character.effective_speed(30.0, registry) == pytest.approx(30.0 * 0.75) + + +def test_bionic_leg_replacement_is_faster_than_a_normal_leg(registry): + normal = make_human(registry) + bionic = make_human(registry, body_parts=[BodyPart("left_leg", "bionic_leg")]) + + normal_speed = normal.effective_speed(30.0, registry) + bionic_speed = bionic.effective_speed(30.0, registry) + assert bionic_speed > normal_speed + # 0.5 (right leg) + 1.2 (bionic left leg) = 1.7 + assert bionic_speed == pytest.approx(30.0 * 1.7) + + +def test_non_leg_parts_do_not_affect_speed(registry): + character = make_human(registry, body_parts=[BodyPart("head", "human_head", removed=True)]) + assert character.effective_speed(30.0, registry) == pytest.approx(30.0) diff --git a/tests/test_staircases.py b/tests/test_staircases.py new file mode 100644 index 0000000..205a99d --- /dev/null +++ b/tests/test_staircases.py @@ -0,0 +1,95 @@ +import pytest + +from engine.entity import Entity, EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerDef, TileLayerInstance +from engine.world import World + + +class FakeRegistry: + def get_tile_layer_def(self, layer, def_id): + if layer == "room" and def_id == "stairs_up": + return TileLayerDef(id="stairs_up", layer="room", walkable=True, staircase_direction="up") + if layer == "room" and def_id == "stairs_down": + return TileLayerDef(id="stairs_down", layer="room", walkable=True, staircase_direction="down") + if layer == "room" and def_id == "wood_wall": + return TileLayerDef(id="wood_wall", layer="room", walkable=False) + return None + + +def make_two_story_map(): + tower = Map("tower", 3, 3, 2) + for x in range(3): + for y in range(3): + for z in range(2): + tower.set_tile(x, y, z, Tile(flooring=TileLayerInstance("wood_deck"))) + tower.get_tile(1, 1, 0).room = TileLayerInstance("stairs_up") + tower.get_tile(1, 1, 1).room = TileLayerInstance("stairs_down") + + world = World(registry=FakeRegistry()) + world.add_map(tower) + return world, tower + + +def test_tile_staircase_direction_reads_room_layer(): + tower = make_two_story_map()[1] + registry = FakeRegistry() + assert tower.get_tile(1, 1, 0).staircase_direction(registry) == "up" + assert tower.get_tile(1, 1, 1).staircase_direction(registry) == "down" + assert tower.get_tile(0, 0, 0).staircase_direction(registry) is None + + +def test_walking_onto_stairs_up_carries_entity_to_next_floor(): + world, _tower = make_two_story_map() + player = Entity(position=EntityPosition("tower", 0, 1, 0)) + assert world.try_move(player, 1, 0, 0) is True + assert (player.position.x, player.position.y, player.position.z) == (1, 1, 1) + + +def test_walking_onto_stairs_down_carries_entity_to_floor_below(): + world, _tower = make_two_story_map() + player = Entity(position=EntityPosition("tower", 0, 1, 1)) + assert world.try_move(player, 1, 0, 0) is True + assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0) + + +def test_landing_on_staircase_does_not_cascade_further(): + # standing on the stairs_down tile at z=1 doesn't repeatedly bounce back down on each call + world, _tower = make_two_story_map() + player = Entity(position=EntityPosition("tower", 1, 1, 1)) + # a no-op self-check: player is already there; simulate a step off and back via two moves + assert world.try_move(player, 1, 0, 0) is True # off the stairs, to (2,1,1) + assert player.position.z == 1 + assert world.try_move(player, -1, 0, 0) is True # back onto stairs_down at (1,1,1) + assert player.position.z == 0 # single hop down, landed on ground floor + + +def test_staircase_at_top_floor_edge_does_not_crash_or_move_out_of_bounds(): + tower = Map("tower", 3, 3, 1) # only one floor - "up" has nowhere to go + for x in range(3): + for y in range(3): + tower.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("wood_deck"))) + tower.get_tile(1, 1, 0).room = TileLayerInstance("stairs_up") + world = World(registry=FakeRegistry()) + world.add_map(tower) + + player = Entity(position=EntityPosition("tower", 0, 1, 0)) + assert world.try_move(player, 1, 0, 0) is True + assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0) # stayed put, no crash + + +def test_staircase_blocked_if_target_floor_tile_unwalkable(): + world, tower = make_two_story_map() + tower.get_tile(1, 1, 1).room = TileLayerInstance("wood_wall") # block the landing spot above + player = Entity(position=EntityPosition("tower", 0, 1, 0)) + assert world.try_move(player, 1, 0, 0) is True # the horizontal step itself still succeeds + assert (player.position.x, player.position.y, player.position.z) == (1, 1, 0) # but no ascent + + +def test_continuous_move_onto_staircase_also_transitions_floor(): + world, _tower = make_two_story_map() + player = Entity(position=EntityPosition("tower", 0.3, 1.4, 0)) + assert world.try_move_continuous(player, 1, 0, 0, distance=0.8) is True + assert player.position.z == 1 + assert player.position.x == pytest.approx(1.1) + assert player.position.y == pytest.approx(1.4) diff --git a/tests/test_start_screen.py b/tests/test_start_screen.py new file mode 100644 index 0000000..ec17265 --- /dev/null +++ b/tests/test_start_screen.py @@ -0,0 +1,123 @@ +import sqlite3 +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.character_library import CharacterLibrary +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.start_screen import StartScreen, join_world_with_character +from engine.tile import Tile, TileLayerInstance +from engine.world import World + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +@pytest.fixture() +def library(): + return CharacterLibrary(sqlite3.connect(":memory:")) + + +def make_world(registry): + field = Map("field", 10, 10, 1) + for x in range(10): + for y in range(10): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +def save_sample_character(library, registry, account_id, character_id, name, species_id="human"): + character = Character( + name=name, species_id=species_id, default_body_parts=registry.new_default_body_parts(species_id), + ) + library.save_character(account_id, character_id, character) + + +# --- StartScreen ------------------------------------------------------------------------------- + + +def test_load_for_account_lists_that_accounts_characters(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + save_sample_character(library, registry, "acct-2", "char-2", "Other") # different account + + screen = StartScreen.load_for_account("acct-1", library) + assert [row[1] for row in screen.characters] == ["Vex"] + + +def test_select_sets_selected_index(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + save_sample_character(library, registry, "acct-1", "char-2", "Kess") + screen = StartScreen.load_for_account("acct-1", library) + + screen.select(1) + assert screen.selected_index == 1 + + +def test_select_out_of_range_is_ignored(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + screen = StartScreen.load_for_account("acct-1", library) + + screen.select(5) + assert screen.selected_index is None + + +def test_selected_character_id_reads_back_the_right_character(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + save_sample_character(library, registry, "acct-1", "char-2", "Kess") + screen = StartScreen.load_for_account("acct-1", library) + + # list_characters orders by name, so "Kess" (char-2) sorts before "Vex" (char-1) + screen.select(1) + assert screen.selected_character_id() == "char-1" + + +def test_selected_character_id_none_before_any_selection(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + screen = StartScreen.load_for_account("acct-1", library) + assert screen.selected_character_id() is None + + +def test_refresh_reloads_the_list_and_clears_selection(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + screen = StartScreen.load_for_account("acct-1", library) + screen.select(0) + + save_sample_character(library, registry, "acct-1", "char-2", "Kess") + screen.refresh(library) + + assert len(screen.characters) == 2 + assert screen.selected_index is None + + +# --- join_world_with_character ------------------------------------------------------------------ + + +def test_join_world_spawns_the_loaded_character_at_the_given_position(registry, library): + save_sample_character(library, registry, "acct-1", "char-1", "Vex") + world = make_world(registry) + + character = join_world_with_character( + world, library, "acct-1", "char-1", registry, EntityPosition("field", 3, 4, 0) + ) + assert character is not None + assert character.name == "Vex" + assert (character.position.map_id, character.position.x, character.position.y) == ("field", 3, 4) + assert world.entities[character.entity_id] is character + + +def test_join_world_returns_none_for_an_unknown_character(registry, library): + world = make_world(registry) + result = join_world_with_character( + world, library, "acct-1", "no-such-char", registry, EntityPosition("field", 0, 0, 0) + ) + assert result is None + assert world.entities == {} diff --git a/tests/test_steering.py b/tests/test_steering.py new file mode 100644 index 0000000..2d78402 --- /dev/null +++ b/tests/test_steering.py @@ -0,0 +1,109 @@ +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map_loader import MapLoader +from engine.tile import TileLayerInstance +from engine.world import World +from resources.logic.steering import at_helm, try_steer_ship, try_turn_ship + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def build_demo_world(registry) -> World: + loader = MapLoader(DEFS_DIR / "maps") + demo_world = loader.load("demo_world.json") + ship = demo_world.embeddings[0].child_map + world = World(registry=registry) + world.add_map(demo_world) + world.add_map(ship) + return world + + +def test_at_helm_true_when_standing_on_the_helm_tile(registry): + world = build_demo_world(registry) + sailor = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0)) + embedding = at_helm(sailor, world) + assert embedding is not None + assert embedding.child_map.map_id == "ship_small" + + +def test_at_helm_none_elsewhere_on_the_ship(registry): + world = build_demo_world(registry) + sailor = make_human(registry, position=EntityPosition("ship_small", 1, 1, 0)) + assert at_helm(sailor, world) is None + + +def test_at_helm_none_when_not_aboard_an_embedded_map(registry): + world = build_demo_world(registry) + person = make_human(registry, position=EntityPosition("demo_world", 1, 1, 0)) + assert at_helm(person, world) is None + + +def test_steering_moves_the_ship_and_leaves_aboard_entities_local_position_untouched(registry): + world = build_demo_world(registry) + ship = world.maps["ship_small"] + embedding = ship.parent_embedding + original_anchor = embedding.anchor + + helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0)) + passenger = make_human(registry, position=EntityPosition("ship_small", 1, 1, 0)) + + assert try_steer_ship(helmsman, world, 0, 1, 0) is True + assert embedding.anchor == (original_anchor[0], original_anchor[1] + 1, original_anchor[2]) + # nobody aboard needed to move - their local coordinates are exactly as before + assert (helmsman.position.x, helmsman.position.y) == (2, 1) + assert (passenger.position.x, passenger.position.y) == (1, 1) + + +def test_steering_fails_if_destination_runs_into_something_unwalkable(registry): + world = build_demo_world(registry) + parent = world.maps["demo_world"] + # ship's footprint is x:6-9, y:6-8; moving west by 1 would occupy x:5-8, y:6-8 - + # put a wall right in that path + parent.get_tile(5, 7, 0).room = TileLayerInstance("wood_wall") + + helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0)) + embedding = world.maps["ship_small"].parent_embedding + before = embedding.anchor + assert try_steer_ship(helmsman, world, -1, 0, 0) is False + assert embedding.anchor == before + + +def test_steering_fails_off_the_parent_map_edge(registry): + world = build_demo_world(registry) + embedding = world.maps["ship_small"].parent_embedding + embedding.move_to((0, 0, 0)) # right at the parent's edge + + helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0)) + assert try_steer_ship(helmsman, world, -1, 0, 0) is False + + +def test_turning_the_ship_rotates_the_embedding(registry): + world = build_demo_world(registry) + embedding = world.maps["ship_small"].parent_embedding + helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0)) + + assert try_turn_ship(helmsman, world, 1) is True + assert embedding.rotation == 1 + + +def test_non_helmsman_cannot_steer(registry): + world = build_demo_world(registry) + embedding = world.maps["ship_small"].parent_embedding + before = embedding.anchor + bystander = make_human(registry, position=EntityPosition("ship_small", 1, 1, 0)) + assert try_steer_ship(bystander, world, 1, 0, 0) is False + assert embedding.anchor == before diff --git a/tests/test_time_warp.py b/tests/test_time_warp.py new file mode 100644 index 0000000..52451e3 --- /dev/null +++ b/tests/test_time_warp.py @@ -0,0 +1,191 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.tile import Tile, TileLayerInstance +from engine.world import TimeWarpZone, World +from resources.logic.abilities import cast_time_warp_sphere +from resources.logic.actions import activate_hand + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def make_world(registry): + field = Map("field", 15, 15, 1) + for x in range(15): + for y in range(15): + field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) + world = World(registry=registry) + world.add_map(field) + return world + + +# --- TimeWarpZone / World plumbing -------------------------------------------------------- + + +def test_zone_contains_checks_radius_and_map_and_layer(): + zone = TimeWarpZone(map_id="field", center=(5.0, 5.0, 0.0), radius=2.0, speed_factor=0.3, expires_at=10.0) + assert zone.contains("field", 6.0, 5.0, 0.0) is True + assert zone.contains("field", 8.0, 5.0, 0.0) is False # outside radius + assert zone.contains("other_map", 6.0, 5.0, 0.0) is False + assert zone.contains("field", 6.0, 5.0, 1.0) is False # different layer + + +def test_zone_affects_respects_whitelist(): + zone = TimeWarpZone( + map_id="field", center=(5.0, 5.0, 0.0), radius=2.0, speed_factor=0.3, expires_at=10.0, + immune_entity_ids={"caster-1"}, + ) + assert zone.affects("caster-1") is False + assert zone.affects("anyone-else") is True + + +def test_speed_multiplier_at_applies_zone_factor_inside_radius(registry): + world = make_world(registry) + world.time_warp_zones.append( + TimeWarpZone(map_id="field", center=(5.0, 5.0, 0.0), radius=2.0, speed_factor=0.25, expires_at=10.0) + ) + assert world.speed_multiplier_at("someone", "field", 5.5, 5.0, 0.0, now=1.0) == pytest.approx(0.25) + + +def test_speed_multiplier_at_is_1_outside_radius(registry): + world = make_world(registry) + world.time_warp_zones.append( + TimeWarpZone(map_id="field", center=(5.0, 5.0, 0.0), radius=2.0, speed_factor=0.25, expires_at=10.0) + ) + assert world.speed_multiplier_at("someone", "field", 10.0, 5.0, 0.0, now=1.0) == 1.0 + + +def test_speed_multiplier_at_ignores_expired_zones(registry): + world = make_world(registry) + world.time_warp_zones.append( + TimeWarpZone(map_id="field", center=(5.0, 5.0, 0.0), radius=2.0, speed_factor=0.25, expires_at=10.0) + ) + assert world.speed_multiplier_at("someone", "field", 5.5, 5.0, 0.0, now=11.0) == 1.0 + + +def test_speed_multiplier_at_1_for_whitelisted_entity(registry): + world = make_world(registry) + world.time_warp_zones.append( + TimeWarpZone( + map_id="field", center=(5.0, 5.0, 0.0), radius=2.0, speed_factor=0.25, expires_at=10.0, + immune_entity_ids={"caster-1"}, + ) + ) + assert world.speed_multiplier_at("caster-1", "field", 5.5, 5.0, 0.0, now=1.0) == 1.0 + + +def test_multiple_overlapping_zones_multiply_together(registry): + world = make_world(registry) + world.time_warp_zones.append( + TimeWarpZone(map_id="field", center=(5.0, 5.0, 0.0), radius=3.0, speed_factor=0.5, expires_at=10.0) + ) + world.time_warp_zones.append( + TimeWarpZone(map_id="field", center=(5.0, 5.0, 0.0), radius=3.0, speed_factor=0.5, expires_at=10.0) + ) + assert world.speed_multiplier_at("someone", "field", 5.0, 5.0, 0.0, now=1.0) == pytest.approx(0.25) + + +def test_expire_time_warp_zones_removes_only_expired(): + world = World() + active = TimeWarpZone(map_id="field", center=(0, 0, 0), radius=1, speed_factor=0.5, expires_at=10.0) + expired = TimeWarpZone(map_id="field", center=(0, 0, 0), radius=1, speed_factor=0.5, expires_at=5.0) + world.time_warp_zones = [active, expired] + world.expire_time_warp_zones(now=6.0) + assert world.time_warp_zones == [active] + + +# --- cast_time_warp_sphere ------------------------------------------------------------------ + + +def test_cast_time_warp_sphere_defaults_center_to_caster_position(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 4.0, 6.0, 0.0)) + zone = cast_time_warp_sphere(caster, world, now=0.0) + assert zone.center == (4.0, 6.0, 0.0) + assert zone in world.time_warp_zones + + +def test_cast_time_warp_sphere_caster_is_always_immune(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 4.0, 6.0, 0.0)) + zone = cast_time_warp_sphere(caster, world, now=0.0) + assert caster.entity_id in zone.immune_entity_ids + + +def test_cast_time_warp_sphere_respects_explicit_whitelist(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 4.0, 6.0, 0.0)) + zone = cast_time_warp_sphere(caster, world, now=0.0, whitelist={"ally-1"}) + assert "ally-1" in zone.immune_entity_ids + assert caster.entity_id in zone.immune_entity_ids # still auto-added + + +def test_cast_time_warp_sphere_all_parameters_are_configurable(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 4.0, 6.0, 0.0)) + zone = cast_time_warp_sphere( + caster, world, now=2.0, center=(1.0, 1.0, 0.0), radius=7.0, speed_factor=0.1, duration=12.0 + ) + assert zone.center == (1.0, 1.0, 0.0) + assert zone.radius == 7.0 + assert zone.speed_factor == pytest.approx(0.1) + assert zone.expires_at == pytest.approx(14.0) + + +def test_time_warp_sphere_slows_a_non_whitelisted_entity_but_not_the_caster(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + victim = make_human(registry, position=EntityPosition("field", 6.0, 5.0, 0.0)) + world.add_entity(caster) + world.add_entity(victim) + + cast_time_warp_sphere(caster, world, now=0.0, radius=3.0, speed_factor=0.2, duration=5.0) + + caster_mult = world.speed_multiplier_at(caster.entity_id, "field", 5.0, 5.0, 0.0, now=1.0) + victim_mult = world.speed_multiplier_at(victim.entity_id, "field", 6.0, 5.0, 0.0, now=1.0) + assert caster_mult == 1.0 + assert victim_mult == pytest.approx(0.2) + + +# --- item attachment point (chronowand) via activate_hand ----------------------------------- + + +def test_chronowand_casts_time_warp_sphere_via_activate_hand(registry): + world = make_world(registry) + caster = make_human(registry, position=EntityPosition("field", 5.0, 5.0, 0.0)) + caster.wield_item("right_hand", "chronowand", registry) + world.add_entity(caster) + + assert world.time_warp_zones == [] + result = activate_hand(caster, world, registry, "right_hand", (1, 0, 0), now=3.0) + assert len(world.time_warp_zones) == 1 + assert result is world.time_warp_zones[0] + assert result.expires_at > 3.0 + + +def test_species_def_carries_time_warp_sphere_ability(registry): + species = registry.get_species_def("human") + ability = next(a for a in species.abilities if a.id == "time_warp_sphere") + assert ability.required_slots == ("head",) + + +def test_can_perform_ability_gates_time_warp_sphere_on_head(registry): + intact = make_human(registry) + assert intact.can_perform_ability("time_warp_sphere", registry) is True + + headless = make_human(registry, body_parts=[BodyPart("head", "human_head", removed=True)]) + assert headless.can_perform_ability("time_warp_sphere", registry) is False diff --git a/tests/test_timing.py b/tests/test_timing.py new file mode 100644 index 0000000..27e7a44 --- /dev/null +++ b/tests/test_timing.py @@ -0,0 +1,51 @@ +import pytest + +from engine.timing import GameClock, Scheduler + + +def test_game_clock_yields_whole_ticks_and_keeps_remainder(): + clock = GameClock(tick_interval=1.0) + assert clock.advance(0.4) == 0 + assert clock.advance(0.4) == 0 + assert clock.advance(0.4) == 1 # 1.2s total -> 1 whole tick, 0.2s carried over + assert clock.tick_count == 1 + assert clock.total_time == pytest.approx(1.2) + + +def test_game_clock_handles_multiple_ticks_in_one_advance(): + clock = GameClock(tick_interval=0.5) + assert clock.advance(1.7) == 3 + assert clock.tick_count == 3 + + +def test_scheduler_runs_one_off_event_once_due(): + scheduler = Scheduler() + calls = [] + scheduler.schedule_in(now=0.0, delay=5.0, event_id="e1", callback=lambda: calls.append("e1")) + + assert scheduler.run_due(4.9) == [] + assert scheduler.run_due(5.0) == ["e1"] + assert calls == ["e1"] + assert scheduler.run_due(100.0) == [] # already consumed, doesn't fire again + + +def test_scheduler_reschedules_repeating_event(): + scheduler = Scheduler() + calls = [] + scheduler.schedule_in(now=0.0, delay=1.0, event_id="tick", callback=lambda: calls.append("tick"), interval=1.0) + + assert scheduler.run_due(1.0) == ["tick"] + assert scheduler.run_due(1.9) == [] + assert scheduler.run_due(2.0) == ["tick"] + assert calls == ["tick", "tick"] + assert scheduler.pending_count() == 1 + + +def test_scheduler_runs_multiple_due_events_in_run_at_order(): + scheduler = Scheduler() + calls = [] + scheduler.schedule_at(2.0, "second", lambda: calls.append("second")) + scheduler.schedule_at(1.0, "first", lambda: calls.append("first")) + + assert scheduler.run_due(2.0) == ["first", "second"] + assert calls == ["first", "second"] diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..f5990cf --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,184 @@ +import random +from pathlib import Path + +import pytest + +from engine.character import Character +from engine.defs import DefRegistry +from engine.entity import EntityPosition +from engine.map import Map +from engine.map_loader import MapLoader +from engine.tile import Tile, TileLayerInstance +from engine.world import World +from resources.logic.weather import ( + HEATSTROKE_SLOT, + apply_heatstroke_tick, + apply_weather_tick, + find_random_exposed_position, + is_exposed, + strike_lightning, +) + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def load_storm_field(registry): + loader = MapLoader(DEFS_DIR / "maps") + return loader.load("storm_field.json") + + +# --- is_exposed -------------------------------------------------------------------------- + + +def test_is_exposed_true_in_the_open(registry): + field = load_storm_field(registry) + assert is_exposed(field, 0, 0, 0, registry) is True + + +def test_is_exposed_false_under_the_shelter_roof(registry): + field = load_storm_field(registry) + assert is_exposed(field, 8, 8, 0, registry) is False + + +# --- heatstroke ---------------------------------------------------------------------------- + + +def test_heatstroke_progresses_while_exposed(registry): + character = make_human(registry) + apply_heatstroke_tick(character, exposed=True, ticks=3) + ailment = next(a for a in character.get_body_part(HEATSTROKE_SLOT).ailments if a.id == "heatstroke") + assert ailment.progress == pytest.approx(15.0) + assert ailment.severity == pytest.approx(0.15) + + +def test_heatstroke_progress_caps_at_100(registry): + character = make_human(registry) + apply_heatstroke_tick(character, exposed=True, ticks=1000) + ailment = next(a for a in character.get_body_part(HEATSTROKE_SLOT).ailments if a.id == "heatstroke") + assert ailment.progress == 100.0 + + +def test_heatstroke_recovers_while_sheltered(registry): + character = make_human(registry) + apply_heatstroke_tick(character, exposed=True, ticks=5) + apply_heatstroke_tick(character, exposed=False, ticks=2) + ailment = next(a for a in character.get_body_part(HEATSTROKE_SLOT).ailments if a.id == "heatstroke") + assert ailment.progress == pytest.approx(25.0 - 6.0) + + +def test_heatstroke_ailment_removed_once_fully_recovered(registry): + character = make_human(registry) + apply_heatstroke_tick(character, exposed=True, ticks=1) + apply_heatstroke_tick(character, exposed=False, ticks=10) + part = character.get_body_part(HEATSTROKE_SLOT) + assert not any(a.id == "heatstroke" for a in part.ailments) + + +def test_no_heatstroke_ailment_if_never_exposed(registry): + character = make_human(registry) + apply_heatstroke_tick(character, exposed=False, ticks=5) + part = character.get_body_part(HEATSTROKE_SLOT) + assert not any(a.id == "heatstroke" for a in part.ailments) + + +def test_heatstroke_zero_ticks_is_a_no_op(registry): + character = make_human(registry) + apply_heatstroke_tick(character, exposed=True, ticks=0) + part = character.get_body_part(HEATSTROKE_SLOT) + assert not any(a.id == "heatstroke" for a in part.ailments) + + +# --- lightning ----------------------------------------------------------------------------- + + +def test_find_random_exposed_position_never_returns_the_sheltered_patch(registry): + field = load_storm_field(registry) + rng = random.Random(1234) + for _ in range(200): + pos = find_random_exposed_position(field, registry, rng) + assert pos is not None + assert is_exposed(field, *pos, registry) is True + + +def test_strike_lightning_returns_none_on_a_fully_roofed_map(registry): + roofed = Map("roofed", 3, 3, 1) + for x in range(3): + for y in range(3): + roofed.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"), roof=TileLayerInstance("shingles"))) + world = World(registry=registry) + world.add_map(roofed) + assert strike_lightning(world, "roofed", registry, random.Random(1)) is None + + +def test_strike_lightning_burns_about_half_the_body_parts_when_it_hits_someone(registry): + field = load_storm_field(registry) + world = World(registry=registry) + world.add_map(field) + + rng = random.Random(42) + # find an exposed tile deterministically and place the victim exactly there + pos = find_random_exposed_position(field, registry, random.Random(42)) + victim = make_human(registry, position=EntityPosition("storm_field", pos[0], pos[1], pos[2])) + world.add_entity(victim) + + struck = strike_lightning(world, "storm_field", registry, rng) + assert struck == pos + + burned_parts = [p for p in victim.body_parts if any(a.id == "burn" for a in p.ailments)] + assert 2 <= len(burned_parts) <= 4 # ~half of 6 default parts + for part in burned_parts: + assert part.integrity <= 40.0 # took "large" damage (started at 100, -60) + + +def test_strike_lightning_misses_if_nobody_is_on_the_struck_tile(registry): + field = load_storm_field(registry) + world = World(registry=registry) + world.add_map(field) + # no entities added at all + struck = strike_lightning(world, "storm_field", registry, random.Random(7)) + assert struck is not None # still strikes a tile... + assert is_exposed(field, *struck, registry) is True + + +# --- apply_weather_tick orchestration -------------------------------------------------------- + + +def test_apply_weather_tick_progresses_heatstroke_for_exposed_entities(registry): + field = load_storm_field(registry) + world = World(registry=registry) + world.add_map(field) + victim = make_human(registry, position=EntityPosition("storm_field", 0, 0, 0)) + world.add_entity(victim) + + apply_weather_tick(world, registry, "storm_field", ticks=2, rng=random.Random(1), lightning_chance_per_tick=0.0) + ailment = next(a for a in victim.get_body_part(HEATSTROKE_SLOT).ailments if a.id == "heatstroke") + assert ailment.progress == pytest.approx(10.0) + + +def test_apply_weather_tick_does_not_progress_heatstroke_under_shelter(registry): + field = load_storm_field(registry) + world = World(registry=registry) + world.add_map(field) + sheltered = make_human(registry, position=EntityPosition("storm_field", 8, 8, 0)) + world.add_entity(sheltered) + + apply_weather_tick(world, registry, "storm_field", ticks=3, rng=random.Random(1), lightning_chance_per_tick=0.0) + part = sheltered.get_body_part(HEATSTROKE_SLOT) + assert not any(a.id == "heatstroke" for a in part.ailments) + + +def test_apply_weather_tick_can_strike_lightning_with_high_chance(registry): + field = load_storm_field(registry) + world = World(registry=registry) + world.add_map(field) + struck = apply_weather_tick(world, registry, "storm_field", ticks=5, rng=random.Random(2), lightning_chance_per_tick=1.0) + assert struck is not None diff --git a/tests/test_wielding.py b/tests/test_wielding.py new file mode 100644 index 0000000..97b5207 --- /dev/null +++ b/tests/test_wielding.py @@ -0,0 +1,88 @@ +from pathlib import Path + +import pytest + +from engine.character import BodyPart, Character +from engine.defs import DefRegistry + +DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" + + +@pytest.fixture() +def registry(): + return DefRegistry.load(DEFS_DIR) + + +def make_human(registry, **kwargs) -> Character: + return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) + + +def test_wield_one_handed_item_in_a_hand(registry): + character = make_human(registry) + assert character.wield_item("right_hand", "staff_oak", registry) is True + assert character.get_held_item("right_hand").item_def_id == "staff_oak" + assert character.get_held_item("left_hand") is None + + +def test_wield_fails_without_the_corresponding_arm(registry): + character = make_human(registry) + character.body_parts = [BodyPart("right_arm", "human_right_arm", removed=True)] + assert character.wield_item("right_hand", "staff_oak", registry) is False + assert character.get_held_item("right_hand") is None + + +def test_two_handed_item_occupies_both_hands(registry): + character = make_human(registry) + assert character.wield_item("right_hand", "multi_deconstructor", registry) is True + assert character.get_held_item("right_hand").item_def_id == "multi_deconstructor" + assert character.get_held_item("left_hand").item_def_id == "multi_deconstructor" + + +def test_two_handed_item_fails_if_paired_arm_missing(registry): + character = make_human(registry) + character.body_parts = [BodyPart("left_arm", "human_left_arm", removed=True)] + assert character.wield_item("right_hand", "multi_deconstructor", registry) is False + assert character.held_items == [] # no partial wield + + +def test_wielding_replaces_whatever_was_in_that_hand(registry): + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + character.wield_item("right_hand", "pistol_basic", registry) + assert character.get_held_item("right_hand").item_def_id == "pistol_basic" + assert len([h for h in character.held_items if h.hand_slot == "right_hand"]) == 1 + + +def test_two_handed_wield_clears_a_previously_one_handed_offhand_item(registry): + character = make_human(registry) + character.wield_item("left_hand", "wooden_shield", registry) + character.wield_item("right_hand", "multi_deconstructor", registry) + assert character.get_held_item("left_hand").item_def_id == "multi_deconstructor" + assert character.get_held_item("right_hand").item_def_id == "multi_deconstructor" + + +def test_unwield_clears_a_hand(registry): + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + character.unwield("right_hand") + assert character.get_held_item("right_hand") is None + + +def test_four_armed_hand_slots_unavailable_on_a_two_armed_human(registry): + character = make_human(registry) + assert character.wield_item("right_hand_2", "staff_oak", registry) is False + + +def test_wielded_shield_detects_shield_by_stats(registry): + character = make_human(registry) + assert character.wielded_shield(registry) is None + character.wield_item("left_hand", "wooden_shield", registry) + shield = character.wielded_shield(registry) + assert shield is not None + assert shield.id == "wooden_shield" + + +def test_wielded_shield_ignores_non_shield_items(registry): + character = make_human(registry) + character.wield_item("right_hand", "staff_oak", registry) + assert character.wielded_shield(registry) is None