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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mFzQQZYexNaSB69e2oU95
main
Amir Alexander Abdelbaki 2026-07-15 13:35:59 +02:00
commit 67c9435d9e
111 changed files with 11671 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
data/*.sqlite3
resources/textures/**/*.png

View File

@ -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

31
config/keybindings.conf Normal file
View File

@ -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

0
engine/__init__.py Normal file
View File

42
engine/aim.py Normal file
View File

@ -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))

42
engine/camera.py Normal file
View File

@ -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

446
engine/character.py Normal file
View File

@ -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

113
engine/character_creator.py Normal file
View File

@ -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

435
engine/character_library.py Normal file
View File

@ -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

66
engine/config.py Normal file
View File

@ -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, [])

428
engine/db.py Normal file
View File

@ -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,
)

223
engine/defs.py Normal file
View File

@ -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
]

43
engine/entity.py Normal file
View File

@ -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

122
engine/gamepad.py Normal file
View File

@ -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, [])

174
engine/input.py Normal file
View File

@ -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

59
engine/inventory.py Normal file
View File

@ -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())

54
engine/item.py Normal file
View File

@ -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

166
engine/map.py Normal file
View File

@ -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]

47
engine/map_loader.py Normal file
View File

@ -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

View File

43
engine/network/client.py Normal file
View File

@ -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

View File

@ -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"))

76
engine/network/server.py Normal file
View File

@ -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)

View File

@ -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)

View File

16
engine/render/device.py Normal file
View File

@ -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

150
engine/render/pipeline.py Normal file
View File

@ -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<f32>) + offset(vec2<f32>)
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

125
engine/render/renderer.py Normal file
View File

@ -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)
)

View File

@ -0,0 +1,42 @@
struct CameraUniform {
scale: vec2<f32>,
offset: vec2<f32>,
};
@group(0) @binding(0) var<uniform> camera: CameraUniform;
@group(1) @binding(0) var quad_texture: texture_2d<f32>;
@group(1) @binding(1) var quad_sampler: sampler;
struct VertexIn {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
};
struct InstanceIn {
@location(2) world_pos: vec2<f32>,
@location(3) size: vec2<f32>,
};
struct VertexOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@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<f32>(ndc, 0.0, 1.0);
out.uv = vin.uv;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
let color = textureSample(quad_texture, quad_sampler, in.uv);
if (color.a < 0.01) {
discard;
}
return color;
}

37
engine/render/texture.py Normal file
View File

@ -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()

14
engine/render/window.py Normal file
View File

@ -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()

27
engine/skills.py Normal file
View File

@ -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}

31
engine/species.py Normal file
View File

@ -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

67
engine/start_screen.py Normal file
View File

@ -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

60
engine/tile.py Normal file
View File

@ -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

63
engine/timing.py Normal file
View File

@ -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)

111
engine/ui.py Normal file
View File

@ -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

192
engine/world.py Normal file
View File

@ -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

190
main.py Normal file
View File

@ -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()

3
pyproject.toml Normal file
View File

@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

5
pyrightconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"venvPath": ".",
"venv": ".venv",
"include": ["engine", "resources/logic", "tests", "main.py"]
}

2
requirements-dev.txt Normal file
View File

@ -0,0 +1,2 @@
-r requirements.txt
pytest

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
wgpu
glfw
numpy
Pillow

0
resources/__init__.py Normal file
View File

View File

@ -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
}
}

View File

@ -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
}
}

284
resources/defs/items.json Normal file
View File

@ -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"
}
}

View File

@ -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] }
]
}

View File

@ -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 }
]
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

136
resources/defs/organs.json Normal file
View File

@ -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 }
}
}

137
resources/defs/species.json Normal file
View File

@ -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": []
}
}

20
resources/defs/tiles.json Normal file
View File

@ -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 }
}
}

View File

View File

@ -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

145
resources/logic/actions.py Normal file
View File

@ -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

269
resources/logic/combat.py Normal file
View File

@ -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,
)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)

52
resources/logic/organs.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

122
resources/logic/weather.py Normal file
View File

@ -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

26
server.py Normal file
View File

@ -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()

79
tests/test_abilities.py Normal file
View File

@ -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)

113
tests/test_ability_bar.py Normal file
View File

@ -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

View File

@ -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

141
tests/test_actions.py Normal file
View File

@ -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

95
tests/test_aim.py Normal file
View File

@ -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)

65
tests/test_armor.py Normal file
View File

@ -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)

79
tests/test_blocking.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"

View File

@ -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

151
tests/test_clothing_fit.py Normal file
View File

@ -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

130
tests/test_combat.py Normal file
View File

@ -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)

118
tests/test_companion.py Normal file
View File

@ -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

48
tests/test_config.py Normal file
View File

@ -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"

119
tests/test_construction.py Normal file
View File

@ -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

View File

@ -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"

142
tests/test_db_roundtrip.py Normal file
View File

@ -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)

115
tests/test_defs_loading.py Normal file
View File

@ -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

99
tests/test_gamepad.py Normal file
View File

@ -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"

View File

@ -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)

105
tests/test_implants.py Normal file
View File

@ -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

198
tests/test_input.py Normal file
View File

@ -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

112
tests/test_interaction.py Normal file
View File

@ -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

49
tests/test_inventory.py Normal file
View File

@ -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)

232
tests/test_knockback.py Normal file
View File

@ -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

66
tests/test_leap.py Normal file
View File

@ -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)

141
tests/test_map_embedding.py Normal file
View File

@ -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)

86
tests/test_network.py Normal file
View File

@ -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())

202
tests/test_organs.py Normal file
View File

@ -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

268
tests/test_ranged_combat.py Normal file
View File

@ -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

Some files were not shown because too many files have changed in this diff Show More