Add item pickup/drop, NPC AI framework, hacking, and turrets

- Item pickup (key) and drag-to-drop from a dedicated container popup card
  (backpacks, pockets), replacing the earlier inline inventory grid
- NPC AI slot framework (engine/ai.py, resources/logic/ai.py) with a wander
  behavior, an NPC spawner UI (dev-mode, reuses the character creator), and
  a "secret items password" creator field
- Hacking mechanic: derived tech skill (intelligence + perception), a sample
  lockable door, a hacking tool item, and six cyberdeck implants with
  bonus/roll-floor effects (including a secret dev-only one)
- Turrets: character faction/tag targeting, turn-rate-limited tracking of
  the closest hostile, and cpu/psu/motors/gun/camera body parts with
  weapon-type variants

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyY5qQB6XHRoBFNbsgdRQn
main
Amir Alexander Abdelbaki 2026-07-16 16:44:46 +02:00
parent 616c945509
commit e04761f7b5
65 changed files with 4243 additions and 146 deletions

View File

@ -15,6 +15,7 @@
[actions]
leap = gp_a
block = gp_left_bumper
pickup = gp_left_thumb
[hands]
activate_right_hand = gp_right_bumper

View File

@ -14,9 +14,12 @@ swim_down = Control
block = b
pause = Escape
toggle_character_card = c
cycle_character_tab = Tab
toggle_dev_mode = F1
dev_cycle_spawn = F2
dev_spawn_object = F3
dev_spawn_npc_menu = F4
pickup = f
[hands]
activate_right_hand = mouse_left

19
engine/ai.py Normal file
View File

@ -0,0 +1,19 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class AIController:
"""The "AI slot" on an NPC Character (see Character.ai): names which behavior drives this
character each tick (see resources/logic/ai.py::AI_BEHAVIORS) plus whatever small bit of
scratch state that behavior needs to remember between ticks - a next-move cooldown, a
patrol waypoint index, a target entity id, etc. Purely data, same as AbilityBarSlot
(engine/ui.py) - the dispatch/behavior logic itself lives in resources/logic/ai.py.
A Character with `ai is None` is never ticked at all - that's what makes the player (and
any inert/decorative Character) exempt without needing a special case anywhere.
"""
behavior_id: str
state: dict = field(default_factory=dict)

View File

@ -3,10 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, Sequence, TypeVar
from engine.ai import AIController
from engine.entity import Entity
from engine.inventory import Inventory
from engine.skills import default_bio
MAX_MEDICAL_LOG_ENTRIES = 20
@dataclass
class Ailment:
@ -28,6 +31,12 @@ class AilmentDef:
id: str
name: str
check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.5}
causes_bleeding: bool = False # e.g. a synthetic's popped_fluid_hose - see
# resources/logic/health_ticks.py::apply_bleeding_tick and BodyPart.bandaged
bleed_rate: float = 4.0 # blood_percentage lost per tick at severity 1.0, only meaningful
# when causes_bleeding is set - deliberately independent of the host BodyPartDef's own
# bleeding_speed (which is 0.0 on every synthetic part - bots don't bleed *normally*, this
# ailment is the one exception, so it needs its own rate rather than borrowing that field)
class _SlottedOverride(Protocol):
@ -83,6 +92,9 @@ class BodyPart:
ailments: list[Ailment] = field(default_factory=list)
organs: list[Organ] = field(default_factory=list)
removed: bool = False
bandaged: bool = False # a bandage (organic species) or sealant (synthetic) was applied -
# see resources/logic/medical.py::treat_bleeding - stops this part's contribution to
# apply_bleeding_tick, whether that's from being severed or from a bleeding ailment
@dataclass(frozen=True)
@ -155,6 +167,8 @@ class Character(Entity):
mental_stats: dict[str, float] | None = None,
bio: dict[str, int] | None = None,
ability_cooldowns: dict[str, float] | None = None,
ai: AIController | None = None,
factions: list[str] | None = None,
**kwargs,
):
super().__init__(*args, **kwargs)
@ -176,6 +190,27 @@ class Character(Entity):
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
self.ai: AIController | None = ai # the "AI slot" - None for the player and any inert
# NPC; see resources/logic/ai.py::run_ai_tick for what drives a filled slot each tick
self.factions: list[str] = factions or [] # open-ended tags (e.g. "player", "hostile")
# for other systems to categorize this character by, rather than by entity_id - see
# resources/logic/turrets.py::is_hostile for the first consumer
self.active_boosts: dict[str, float] = {} # stat id ("strength"/"speed") -> multiplier,
# e.g. a consumed stimulant (see resources/logic/abilities.py::cast_adrenablend) -
# applied in effective_strength/effective_speed, expired via boost_expirations
self.boost_expirations: dict[str, float] = {} # stat id -> game-clock time it wears off,
# cleared by resources/logic/health_ticks.py::apply_boost_expiry_tick
self.medical_log: list[str] = [] # recent injuries/treatments, newest last - see
# log_medical_event; shown in the character card's medical tab (engine/render/menu_draw.py)
def log_medical_event(self, message: str) -> None:
"""Appends a short human-readable entry to medical_log (e.g. "Sprained left_leg from a
fall"), trimming the oldest entries past MAX_MEDICAL_LOG_ENTRIES so it can't grow
unbounded over a long play session.
"""
self.medical_log.append(message)
if len(self.medical_log) > MAX_MEDICAL_LOG_ENTRIES:
del self.medical_log[: len(self.medical_log) - MAX_MEDICAL_LOG_ENTRIES]
def is_ability_ready(self, ability_id: str, now: float) -> bool:
return now >= self.ability_cooldowns.get(ability_id, 0.0)
@ -396,11 +431,24 @@ class Character(Entity):
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))
return base_speed * total_factor * (1.0 - self.organ_penalty("speed", registry)) * self.active_boosts.get(
"speed", 1.0
)
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))
"""Raw strength reduced by organ damage (e.g. a weakened heart saps physical power),
then scaled by any active stimulant boost (see active_boosts/resources/logic/
abilities.py::cast_adrenablend).
"""
return self.strength * (1.0 - self.organ_penalty("strength", registry)) * self.active_boosts.get("strength", 1.0)
def base_tech_skill(self) -> float:
"""The "tech" skill (see resources/logic/hacking.py) isn't bought/rolled/assigned
directly like the DND_SKILLS bio entries - it's derived, averaging intelligence and
perception the same way those two are themselves plain bio values. total_check_penalty
still applies on top of this the same way it would for any other skill's base value.
"""
return (self.bio.get("intelligence", 0) + self.bio.get("perception", 0)) / 2.0
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."""
@ -507,15 +555,34 @@ class Character(Entity):
return 0.0
return lost_weight / total_weight
def species_penalty(self, check_id: str, registry) -> float:
"""Fraction (0..1) of `check_id` permanently lost regardless of damage state - e.g.
motorbot's clumsy manipulators (see SpeciesDef.innate_check_penalties). Unlike
skill/organ/ailment_penalty this never heals; it's just an inherent trait of the species.
"""
if self.species_id is None:
return 0.0
return registry.get_species_def(self.species_id).innate_check_penalties.get(check_id, 0.0)
def total_check_penalty(self, check_id: str, registry) -> float:
"""Combined penalty from damaged body parts, damaged organs, and active ailments 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 nothing else affects
this check or everything else is healthy - existing skill-only checks are unaffected.
"""Combined penalty from damaged body parts, damaged organs, active ailments, and any
innate species trait 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 nothing else affects this check or everything else is healthy
- existing skill-only checks are unaffected.
"""
remaining = (
(1.0 - self.skill_penalty(check_id, registry))
* (1.0 - self.organ_penalty(check_id, registry))
* (1.0 - self.ailment_penalty(check_id, registry))
* (1.0 - self.species_penalty(check_id, registry))
)
return 1.0 - remaining
def blood_label(self, registry) -> str:
"""The species-appropriate display term for blood_percentage - e.g. hydraulibot calls
it "Synthmuscle-Fluid" (see SpeciesDef.blood_label) instead of "Blood".
"""
if self.species_id is None:
return "Blood"
return registry.get_species_def(self.species_id).blood_label

View File

@ -22,7 +22,18 @@ SKILL_MODES = (SKILL_MODE_POINT_BUY, SKILL_MODE_ROLL_ASSIGN, SKILL_MODE_MANUAL,
# Species a player can create a character as. "dog" is deliberately excluded - it's a
# companion/NPC-only species (see the companion_dog entity template), still fully valid to
# spawn as an NPC or load via CharacterLibrary, just not offered as a player choice here.
PLAYER_SELECTABLE_SPECIES: tuple[str, ...] = ("human", "reptilian_quad", "alien_tri", "bot")
PLAYER_SELECTABLE_SPECIES: tuple[str, ...] = ("human", "reptilian_quad", "alien_tri", "hydraulibot", "motorbot")
# Every species is fair game for an NPC, including "dog" - the one species intentionally left
# out of PLAYER_SELECTABLE_SPECIES since a player can't create a character as one (see above).
NPC_SELECTABLE_SPECIES: tuple[str, ...] = PLAYER_SELECTABLE_SPECIES + ("dog",)
# The "secret items password" field on the final creation step (see CharacterCreationFlow) -
# entering one of these exact strings pre-installs the mapped implant on the finished character,
# no other way to get it. Matched case-sensitively, deliberately not documented anywhere in-game.
SECRET_ITEM_PASSWORD_GRANTS: dict[str, str] = {
"The_miro": "cyberdeck_the_miro_model_3a",
}
@dataclass
@ -49,6 +60,7 @@ class CharacterCreator:
max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL
name: str = ""
secret_password: str = "" # optional; see SECRET_ITEM_PASSWORD_GRANTS
species_id: str | None = None
gender: str | None = None
body_type: str | None = None
@ -61,6 +73,9 @@ class CharacterCreator:
def set_name(self, name: str) -> None:
self.name = name.strip()
def set_secret_password(self, password: str) -> None:
self.secret_password = password
def available_species(self) -> list[str]:
"""Species ids a player can actually pick, for a UI to list - selectable_species
filtered against whatever's actually loaded in the registry.
@ -232,7 +247,7 @@ class CharacterCreator:
if not self.is_ready():
return None
assert self.species_id is not None
return Character(
character = Character(
name=self.name,
species_id=self.species_id,
gender=self.gender,
@ -242,6 +257,10 @@ class CharacterCreator:
default_clothing=[ClothingPiece(slot, item_def_id) for slot, item_def_id in self.starter_clothing.items()],
bio=dict(self.bio),
)
granted_implant_id = SECRET_ITEM_PASSWORD_GRANTS.get(self.secret_password)
if granted_implant_id is not None:
character.install_implant(granted_implant_id, self.registry)
return character
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) -

View File

@ -26,11 +26,14 @@ DEFAULT_KEYBINDINGS: dict[str, list[str]] = {
"select_ability_9": ["9"],
"select_ability_0": ["0"],
"activate_selected_ability": ["e"],
"pickup": ["f"],
"pause": ["Escape"],
"toggle_character_card": ["c"],
"cycle_character_tab": ["Tab"],
"toggle_dev_mode": ["F1"],
"dev_cycle_spawn": ["F2"],
"dev_spawn_object": ["F3"],
"dev_spawn_npc_menu": ["F4"],
}
# Human-friendly config names for keys that don't have a printable representation.

View File

@ -19,6 +19,13 @@ class EntityDef:
species_id: str | None = None
default_clothing: list[ClothingPiece] = field(default_factory=list)
inventory_size: tuple[int, int] | None = None
ai_behavior_id: str | None = None # default AI slot behavior for NPCs spawned from this
# template (see engine/ai.py::AIController, resources/logic/ai.py::AI_BEHAVIORS) - None for
# the player template and any NPC template meant to stay inert until scripted otherwise
turret_weapon_item_id: str | None = None # which weapon this turret variant fires - see
# resources/logic/turrets.py::spawn_turret/turret_ai_behavior; None for non-turret templates
turret_turn_rate_degrees_per_tick: float | None = None # this variant's turn speed - None
# means resources/logic/turrets.py's own default applies
class DefRegistry:
@ -58,6 +65,8 @@ class DefRegistry:
is_helm=fields.get("is_helm", False),
staircase_direction=fields.get("staircase_direction"),
speed_multiplier=fields.get("speed_multiplier", 1.0),
hack_difficulty=fields.get("hack_difficulty"),
hackable_unlocked_def_id=fields.get("hackable_unlocked_def_id"),
)
def _load_items(self, path: Path) -> None:
@ -102,6 +111,11 @@ class DefRegistry:
melee_knockback=fields.get("melee_knockback", 0.0),
grants_ability=fields.get("grants_ability"),
is_implant=fields.get("is_implant", False),
consumable=fields.get("consumable", False),
is_hacking_implant=fields.get("is_hacking_implant", False),
hack_bonus=fields.get("hack_bonus", 0.0),
hack_min_roll_percent=fields.get("hack_min_roll_percent"),
secret=fields.get("secret", False),
)
def _load_body_parts(self, path: Path) -> None:
@ -137,6 +151,8 @@ class DefRegistry:
id=def_id,
name=fields.get("name", def_id),
check_weights=dict(fields.get("check_weights", {})),
causes_bleeding=fields.get("causes_bleeding", False),
bleed_rate=fields.get("bleed_rate", 4.0),
)
def _load_species(self, path: Path) -> None:
@ -167,7 +183,9 @@ class DefRegistry:
abilities=abilities,
blood_danger_threshold=fields.get("blood_danger_threshold", 50.0),
blood_critical_threshold=fields.get("blood_critical_threshold", 25.0),
blood_label=fields.get("blood_label", "Blood"),
health_item_compatibility=list(fields.get("health_item_compatibility", [])),
innate_check_penalties=dict(fields.get("innate_check_penalties", {})),
)
def _load_entities(self, path: Path) -> None:
@ -184,6 +202,9 @@ class DefRegistry:
species_id=fields.get("species_id"),
default_clothing=default_clothing,
inventory_size=tuple(inventory_size) if inventory_size else None,
ai_behavior_id=fields.get("ai_behavior_id"),
turret_weapon_item_id=fields.get("turret_weapon_item_id"),
turret_turn_rate_degrees_per_tick=fields.get("turret_turn_rate_degrees_per_tick"),
)
def get_tile_layer_def(self, layer: str, def_id: str | None) -> TileLayerDef | None:

View File

@ -25,7 +25,10 @@ class DevSpawnMenu:
def __post_init__(self) -> None:
if not self.item_ids:
self.item_ids = sorted(self.registry.item_defs)
# secret items (see ItemDef.secret, e.g. a one-off vanity implant) are deliberately
# left out of the cycle - they still work fine if granted some other way, they just
# aren't discoverable through this menu.
self.item_ids = sorted(def_id for def_id, item_def in self.registry.item_defs.items() if not item_def.secret)
def cycle(self, delta: int) -> None:
if self.item_ids:

View File

@ -20,6 +20,7 @@ class Mode(Enum):
CONTROLS = "controls"
PAUSED = "paused"
PLAYING = "playing"
NPC_SPAWN = "npc_spawn" # dev-mode-only: reuses CharacterCreationFlow to build and place an NPC
@dataclass
@ -65,9 +66,11 @@ class PauseMenu:
@dataclass
class CharacterCreationFlow:
"""Thin step-sequencing wrapper around CharacterCreator (engine/character_creator.py) for
the launch-time "new game" flow: pick a species (left/right arrows), type a name (printable
keys + backspace), then confirm. CharacterCreator itself already validates every choice -
this only tracks which step is active and routes raw menu input to the right one.
the launch-time "new game" flow: pick a species (left/right arrows), type a name, then an
optional "secret items password" (see CharacterCreator.SECRET_ITEM_PASSWORD_GRANTS - typing
the right one pre-installs a hidden implant on the finished character), then confirm.
CharacterCreator itself already validates every choice - this only tracks which step is
active and routes raw menu input to the right one.
No account/CharacterLibrary here (see engine/character_library.py, engine/start_screen.py) -
this engine only persists one world/player at a time via WorldRepository (engine/db.py), so
@ -97,27 +100,41 @@ class CharacterCreationFlow:
self._lock_in_species()
def type_char(self, ch: str) -> None:
if self.step == "name" and ch.isprintable() and len(self.creator.name) < MAX_NAME_LENGTH:
if not ch.isprintable():
return
if self.step == "name" and len(self.creator.name) < MAX_NAME_LENGTH:
self.creator.set_name(self.creator.name + ch)
elif self.step == "password" and len(self.creator.secret_password) < MAX_NAME_LENGTH:
self.creator.set_secret_password(self.creator.secret_password + ch)
def backspace(self) -> None:
if self.step == "name":
self.creator.set_name(self.creator.name[:-1])
elif self.step == "password":
self.creator.set_secret_password(self.creator.secret_password[:-1])
def advance(self) -> bool:
"""Moves to the next step. Returns True once the flow is actually finished (final step
confirmed with a valid name) - main.py builds the Character only then, via
self.creator.build_character().
"""Moves to the next step. Returns True once the flow is actually finished (password
step confirmed - the password itself is optional, see CharacterCreator.build_character)
- main.py builds the Character only then, via self.creator.build_character().
"""
if self.step == "species":
self.step = "name"
return False
return bool(self.creator.name.strip())
if self.step == "name":
if not self.creator.name.strip():
return False
self.step = "password"
return False
return True
def go_back(self) -> bool:
"""Steps back one stage; returns False if already at the first step (caller should
leave character creation entirely, e.g. back to the main menu).
"""
if self.step == "password":
self.step = "name"
return True
if self.step == "name":
self.step = "species"
return True

View File

@ -46,6 +46,7 @@ DEFAULT_GAMEPAD_BINDINGS: dict[str, list[str]] = {
"activate_right_hand_2": ["gp_right_trigger"],
"activate_left_hand_2": ["gp_left_trigger"],
"activate_selected_ability": ["gp_b"],
"pickup": ["gp_left_thumb"],
}

View File

@ -20,8 +20,11 @@ HAND_ACTIVATION_ACTIONS: dict[str, str] = {
"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"}
# This project's installed rendercanvas glfw backend numbers buttons 1 = left, 2 = right (see
# its own _on_mouse_button button_map: glfw.MOUSE_BUTTON_1 -> 1, glfw.MOUSE_BUTTON_2 -> 2) -
# NOT the DOM convention (0 = left) an earlier version of this comment assumed, which silently
# made left-click a dead input (0 was never a key actually reported for any button).
MOUSE_BUTTON_KEYS: dict[int, str] = {1: "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.
@ -56,6 +59,7 @@ class InputState:
self.pending_hand_activation: str | None = None
self.pending_ability_select: int | None = None
self.pending_activate_ability_bar = False
self.pending_pickup = False
self.is_blocking = False
self.facing: tuple[int, int, int] = DEFAULT_FACING
self.pointer_pos: tuple[float, float] | None = None
@ -117,6 +121,8 @@ class InputState:
self.pending_ability_select = SELECT_ABILITY_ACTIONS[action]
elif action == "activate_selected_ability":
self.pending_activate_ability_bar = True
elif action == "pickup":
self.pending_pickup = True
def _apply_up(self, action: str) -> None:
if action in MOVE_ACTION_VECTORS:
@ -187,6 +193,10 @@ class InputState:
fired, self.pending_activate_ability_bar = self.pending_activate_ability_bar, False
return fired
def consume_pickup(self) -> bool:
fired, self.pending_pickup = self.pending_pickup, False
return fired
def clear_held_state(self) -> None:
"""Drops all held-key/blocking state - call whenever leaving PLAYING for a menu, since
key_up events (and gamepad polling - see main.py's poll_gamepad) stop reaching this
@ -205,4 +215,5 @@ class InputState:
self.pending_swim_down = False
self.pending_hand_activation = None
self.pending_activate_ability_bar = False
self.pending_pickup = False
self._prev_gamepad_buttons = set()

View File

@ -57,3 +57,16 @@ class Inventory:
def items(self) -> list[PlacedItem]:
return list(self._items.values())
def item_at(self, x: int, y: int) -> PlacedItem | None:
"""The PlacedItem occupying cell (x, y), if any - checks the item's whole footprint,
not just its top-left corner, so a 2x2 item is found from any of its 4 cells. Used for
UI hit-testing (see engine/render/menu_draw.py's inventory_cell_at) as well as anything
else that needs "what's under this specific cell".
"""
if not (0 <= x < self.width and 0 <= y < self.height):
return None
item_id = self._cells[y * self.width + x]
if item_id is None:
return None
return self._items.get(item_id)

View File

@ -45,6 +45,17 @@ class ItemDef:
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
consumable: bool = False # single-use: activate_hand unwields (and removes) it after one
# successful grants_ability cast instead of leaving it wielded, e.g. a stimulant syringe -
# as opposed to a reusable wand/rod, which just goes on cooldown and stays in hand
is_hacking_implant: bool = False # an installed implant of this kind both applies
# hack_bonus/hack_min_roll_percent to every tech check AND lets a bare hand attempt a hack
# with no wielded hacking_tool at all - see resources/logic/hacking.py::find_hacking_implant
hack_bonus: float = 0.0 # flat modifier added to a tech skill check's effective_skill
hack_min_roll_percent: float | None = None # floors the raw d20 roll at this fraction of
# SKILL_CHECK_DIE (e.g. 0.45 = "never worse than a rolled 9") - None = no floor
secret: bool = False # excluded from discovery UI (e.g. DevSpawnMenu's cycle list) - still
# fully functional if granted directly (by def id, save editing, etc.)
class ItemInstance:

View File

@ -47,12 +47,18 @@ def draw_pause_menu(buckets, menu: PauseMenu, viewport_w: float, viewport_h: flo
_draw_option_list(buckets, options, menu.selected_index, x + PADDING, y + PADDING + ROW_H * 1.5)
def draw_character_creation(buckets, flow: CharacterCreationFlow, viewport_w: float, viewport_h: float) -> None:
def draw_character_creation(
buckets, flow: CharacterCreationFlow, viewport_w: float, viewport_h: float, title: str = "New Character"
) -> None:
"""Species/name creation flow, shared by the launch-time "new game" screen and the dev-mode
NPC spawner (see main.py's handle_npc_spawn_key) - `title` is the only thing that ever
differs between the two, since both drive the exact same CharacterCreationFlow.
"""
panel_w, panel_h = 420.0, 220.0
x = (viewport_w - panel_w) / 2
y = (viewport_h - panel_h) / 2
draw_panel(buckets, "background", x, y, panel_w, panel_h)
draw_text(buckets, "New Character", x + PADDING, y + PADDING)
draw_text(buckets, title, x + PADDING, y + PADDING)
if flow.step == "species":
species = flow.species_list()
@ -60,10 +66,17 @@ def draw_character_creation(buckets, flow: CharacterCreationFlow, viewport_w: fl
draw_text(buckets, f"Species: < {current} >", x + PADDING, y + PADDING + ROW_H * 2)
draw_text(buckets, f"({flow.species_index + 1}/{max(len(species), 1)})", x + PADDING, y + PADDING + ROW_H * 3)
draw_text(buckets, "Left/Right to choose, Enter to continue", x + PADDING, y + panel_h - PADDING - ROW_H)
else:
elif flow.step == "name":
draw_text(buckets, f"Species: {flow.creator.species_id}", x + PADDING, y + PADDING + ROW_H * 2)
draw_text(buckets, f"Name: {flow.creator.name}_", x + PADDING, y + PADDING + ROW_H * 3)
draw_text(buckets, "Type a name, Enter to confirm, Esc to go back", x + PADDING, y + panel_h - PADDING - ROW_H)
else: # password
masked = "*" * len(flow.creator.secret_password)
draw_text(buckets, f"Name: {flow.creator.name}", x + PADDING, y + PADDING + ROW_H * 2)
draw_text(buckets, f"Secret Items Password: {masked}_", x + PADDING, y + PADDING + ROW_H * 3)
draw_text(
buckets, "(optional) Type a password, Enter to confirm, Esc to go back", x + PADDING, y + panel_h - PADDING - ROW_H
)
def draw_world_creation(buckets, menu: WorldCreationMenu, viewport_w: float, viewport_h: float) -> None:
@ -108,6 +121,148 @@ def draw_controls_menu(buckets, menu: ControlsMenu, viewport_w: float, viewport_
CHARACTER_CARD_W = 260.0
CHARACTER_CARD_H = 200.0
INVENTORY_CELL_SIZE = 14.0
INVENTORY_CELL_GAP = 1.0
def character_card_rect(viewport_h: float) -> tuple[float, float, float, float]:
"""(x, y, w, h) of the character card panel in screen pixels - shared by drawing and
main.py's click hit-testing (see point_in_rect), so the two can never drift out of sync.
"""
x = PADDING + ABILITY_BAR_W + PADDING
y = viewport_h - CHARACTER_CARD_H - PADDING
return x, y, CHARACTER_CARD_W, CHARACTER_CARD_H
def point_in_rect(px: float, py: float, rect: tuple[float, float, float, float]) -> bool:
x, y, w, h = rect
return x <= px <= x + w and y <= py <= y + h
def inventory_cell_at(inventory, origin: tuple[float, float], px: float, py: float) -> tuple[int, int] | None:
"""Which cell (gx, gy) of `inventory`'s grid - drawn with its top-left at `origin` - a
screen point falls on, or None if it's outside the grid. `inventory` is whichever
container's contents are currently on screen (see draw_backpack_popup); callers are
expected to already know it's not None (e.g. via CharacterMenu.resolve_open_inventory).
"""
grid_x, grid_y = origin
cell = INVENTORY_CELL_SIZE + INVENTORY_CELL_GAP
if px < grid_x or py < grid_y:
return None
gx = int((px - grid_x) // cell)
gy = int((py - grid_y) // cell)
if 0 <= gx < inventory.width and 0 <= gy < inventory.height:
return gx, gy
return None
def draw_inventory_grid(buckets, inventory, origin: tuple[float, float], dragging_item_id: str | None = None) -> None:
"""`inventory`'s cells, top-left at `origin` - each occupied cell highlighted, with a
2-letter abbreviation of the item def id at its top-left corner. The cell currently being
dragged (if any) is left blank - see draw_drag_ghost, which renders it following the cursor
instead (main.py wires the actual drag/drop via pointer_down/pointer_up - see
resources/logic/pickup.py::drop_item).
"""
grid_x, grid_y = origin
cell = INVENTORY_CELL_SIZE + INVENTORY_CELL_GAP
for gy in range(inventory.height):
for gx in range(inventory.width):
cx, cy = grid_x + gx * cell, grid_y + gy * cell
placed = inventory.item_at(gx, gy)
if placed is not None and placed.item.item_id == dragging_item_id:
draw_panel(buckets, "border", cx, cy, INVENTORY_CELL_SIZE, INVENTORY_CELL_SIZE)
continue
panel = "highlight" if placed is not None else "border"
draw_panel(buckets, panel, cx, cy, INVENTORY_CELL_SIZE, INVENTORY_CELL_SIZE)
if placed is not None and (placed.x, placed.y) == (gx, gy):
draw_text(buckets, placed.item.def_id[:2], cx + 1, cy, size=8.0, char_advance=6.0)
def draw_drag_ghost(buckets, def_id: str, px: float, py: float) -> None:
"""A small highlighted box following the cursor while an inventory item is being dragged -
see draw_inventory_grid (the origin cell is left blank while this is shown) and main.py's
pointer_down/pointer_up handling for the actual drag-state tracking.
"""
half = INVENTORY_CELL_SIZE / 2
draw_panel(buckets, "highlight", px - half, py - half, INVENTORY_CELL_SIZE, INVENTORY_CELL_SIZE)
draw_text(buckets, def_id[:2], px - half + 1, py - half, size=8.0, char_advance=6.0)
def _equipment_rows(character) -> list[str]:
"""Ordered row identifiers for the equipment tab: each worn clothing slot, then
CharacterMenu.POCKETS_SLOT if the character has a base inventory at all - shared by
draw_character_card's equipment tab and equipment_row_at so the two can't drift apart.
"""
worn = character.clothing or character.default_clothing
rows = [piece.slot for piece in worn]
if character.inventory is not None:
rows.append(CharacterMenu.POCKETS_SLOT)
return rows
def _equipment_row_label(character, slot: str) -> str:
if slot == CharacterMenu.POCKETS_SLOT:
return "Pockets"
piece = character.get_clothing(slot)
return f"{slot}: {piece.item_def_id}" if piece is not None else slot
def equipment_row_at(character, viewport_h: float, px: float, py: float) -> str | None:
"""Which equipment-tab row (a clothing slot, or CharacterMenu.POCKETS_SLOT) a screen point
falls on, or None. Callers should already know `px, py` landed inside the character card's
equipment tab (see point_in_rect/character_card_rect) - this only handles the row math.
"""
x, y, w, _h = character_card_rect(viewport_h)
if not (x <= px <= x + w):
return None
tab_y = y + 10 + ROW_H
body_y = tab_y + ROW_H * 1.5
if py < body_y:
return None
rows = _equipment_rows(character)
row = int((py - body_y) // ROW_H)
if 0 <= row < len(rows):
return rows[row]
return None
def backpack_popup_rect(inventory, viewport_h: float) -> tuple[float, float, float, float]:
"""(x, y, w, h) of the floating popup card showing a single container's contents - sized to
fit `inventory`'s own grid and anchored just above the character card (see
character_card_rect). Shared by drawing and main.py's click hit-testing.
"""
card_x, card_y, _w, _h = character_card_rect(viewport_h)
cell = INVENTORY_CELL_SIZE + INVENTORY_CELL_GAP
w = inventory.width * cell + 20.0
h = inventory.height * cell + 20.0 + ROW_H
y = card_y - h - 8.0
return card_x, y, w, h
def backpack_grid_origin(rect: tuple[float, float, float, float]) -> tuple[float, float]:
x, y, _w, _h = rect
return x + 10.0, y + 10.0 + ROW_H
def draw_backpack_popup(
buckets, character, menu: CharacterMenu, registry, viewport_h: float, dragging_item_id: str | None = None
) -> None:
"""The separate card for whichever container `menu.open_backpack_slot` names (a worn
backpack, or the character's own base inventory via POCKETS_SLOT) - opened by clicking that
row in the equipment tab (see equipment_row_at/CharacterMenu.toggle_backpack). Does nothing
if nothing's open, or the referenced container has no inventory (shouldn't normally happen -
toggle_backpack only opens containers that do).
"""
inventory = menu.resolve_open_inventory(character, registry)
if inventory is None:
return
rect = backpack_popup_rect(inventory, viewport_h)
x, y, w, h = rect
draw_panel(buckets, "background", x, y, w, h)
title = "Pockets" if menu.open_backpack_slot == CharacterMenu.POCKETS_SLOT else menu.open_backpack_slot
draw_text(buckets, title, x + 10, y + 10)
draw_inventory_grid(buckets, inventory, backpack_grid_origin(rect), dragging_item_id)
ABILITY_BAR_SLOT_W = 30.0
ABILITY_BAR_SLOT_H = 30.0
ABILITY_BAR_GAP = 3.0
@ -132,12 +287,16 @@ def draw_ability_bar(buckets, bar: AbilityBar, viewport_h: float) -> None:
draw_text(buckets, slot.ability_id[:5], x + 1, y + 16, size=8.0, char_advance=6.0)
MEDICAL_LOG_DISPLAY_COUNT = 5
def _medical_lines(character, registry) -> list[str]:
"""One line per body part (integrity, or REMOVED) plus an indented sub-line for each of its
ailments and organs - the data's all on Character.resolved_body_parts() (engine/character.py)
already, this just flattens it for the medical tab (see draw_character_card).
already, this just flattens it for the medical tab (see draw_character_card). Finishes with
the most recent entries from Character.medical_log (newest first), if there are any.
"""
lines = [f"Blood: {character.blood_percentage:.0f}%"]
lines = [f"{character.blood_label(registry)}: {character.blood_percentage:.0f}%"]
for slot, part in sorted(character.resolved_body_parts().items()):
if part is None:
continue
@ -152,17 +311,24 @@ def _medical_lines(character, registry) -> list[str]:
organ_name = organ_def.name if organ_def is not None else organ.organ_def_id
status = "removed" if organ.removed else f"{organ.integrity:.0f}%"
lines.append(f" {organ_name}: {status}")
if character.medical_log:
lines.append("Log:")
recent = list(reversed(character.medical_log[-MEDICAL_LOG_DISPLAY_COUNT:]))
lines.extend(f" {entry}" for entry in recent)
return lines
def draw_character_card(buckets, character, menu: CharacterMenu, registry, viewport_h: float) -> None:
"""Bio/equipment/medical card, next to the ability bar along the bottom edge (see
draw_ability_bar) - engine/ui.py::CharacterMenu had never been wired to any rendering
before, which is why it "didn't show" - see main.py's toggle_character_card.
before, which is why it "didn't show" - see main.py's toggle_character_card. The equipment
tab lists worn items plus a "Pockets" row for the character's own base inventory; clicking
a row that grants an inventory opens its own separate popup card (see draw_backpack_popup,
equipment_row_at, and main.py's pointer handling) rather than showing a grid inline here.
"""
x = PADDING + ABILITY_BAR_W + PADDING
y = viewport_h - CHARACTER_CARD_H - PADDING
draw_panel(buckets, "background", x, y, CHARACTER_CARD_W, CHARACTER_CARD_H)
x, y, w, h = character_card_rect(viewport_h)
draw_panel(buckets, "background", x, y, w, h)
name = character.name or "(unnamed)"
draw_text(buckets, name, x + 10, y + 10)
@ -175,12 +341,19 @@ def draw_character_card(buckets, character, menu: CharacterMenu, registry, viewp
body_y = tab_y + ROW_H * 1.5
if menu.active_tab == "bio":
draw_text(buckets, f"Species: {character.species_id or '-'}", x + 10, body_y)
draw_text(buckets, f"Blood: {character.blood_percentage:.0f}%", x + 10, body_y + ROW_H)
draw_text(buckets, f"{character.blood_label(registry)}: {character.blood_percentage:.0f}%", x + 10, body_y + ROW_H)
for i, (skill_id, value) in enumerate(sorted(character.bio.items())):
draw_text(buckets, f"{skill_id}: {value}", x + 10, body_y + ROW_H * (2 + i))
elif menu.active_tab == "equipment":
for i, piece in enumerate(character.clothing or character.default_clothing):
draw_text(buckets, f"{piece.slot}: {piece.item_def_id}", x + 10, body_y + ROW_H * i)
rows = _equipment_rows(character)
if not rows:
draw_text(buckets, "(nothing worn)", x + 10, body_y)
for i, slot in enumerate(rows):
row_y = body_y + ROW_H * i
label = _equipment_row_label(character, slot)
if slot == menu.open_backpack_slot:
draw_panel(buckets, "highlight", x + 10 - 4, row_y - 2, text_width(label) + 8, ROW_H)
draw_text(buckets, label, x + 10, row_y)
else:
for i, line in enumerate(_medical_lines(character, registry)):
draw_text(buckets, line, x + 10, body_y + ROW_H * i)

View File

@ -139,6 +139,7 @@ class Renderer:
if z is None:
continue
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
self._draw_ground_items(m, x, y, z, offset, rotation, world, buckets)
if depth > 0:
self._draw_depth_shadow(m, x, y, offset, rotation, depth, buckets)
else:
@ -146,6 +147,7 @@ class Renderer:
for y in range(m.height):
for x in range(m.width):
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
self._draw_ground_items(m, x, y, z, offset, rotation, world, buckets)
for entity in world.entities_on_map(m.map_id):
if isinstance(entity, Character):
@ -171,6 +173,35 @@ class Renderer:
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))
def _draw_ground_items(
self,
m: Map,
x: int,
y: int,
z: int,
offset: tuple[int, int, int],
rotation: int,
world: World,
buckets: dict[str, list[float]],
) -> None:
"""Draws the most-recently-dropped ground item at this tile as a small inset icon, so
there's something to actually see before picking it up (see resources/logic/pickup.py).
Only ever one icon per tile regardless of how many items are piled there - a known,
deliberate simplification, not a full stacked-loot visualization.
"""
items = world.ground_items_at(m.map_id, x, y, z)
if not items:
return
item_def = self.registry.get_item_def(items[-1].def_id)
if not item_def.texture:
return
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
inset = 0.2
size = 1.0 - inset * 2
buckets[item_def.texture].extend(
(float(offset[0] + rx + inset), float(offset[1] + ry + inset), size, size)
)
def _draw_depth_shadow(
self, m: Map, x: int, y: int, offset: tuple[int, int, int], rotation: int, depth: int, buckets: dict[str, list[float]]
) -> None:

View File

@ -9,6 +9,7 @@ DND_SKILLS = (
"deception",
"history",
"insight",
"intelligence",
"intimidation",
"investigation",
"medicine",
@ -25,3 +26,9 @@ DND_SKILLS = (
def default_bio() -> dict[str, int]:
return {skill: 0 for skill in DND_SKILLS}
# "tech" is deliberately NOT one of the DND_SKILLS above - it isn't bought/rolled/assigned
# directly like the others. It's derived from intelligence and perception (see
# Character.base_tech_skill), so a character's hacking ability moves automatically as those
# two skills do, with nothing extra to spend on it in the character creator.

View File

@ -29,10 +29,17 @@ class SpeciesDef:
# for character creation (e.g. "average"/"thin"/"fat"/"hulk") - NOT the same as body_type
# above, which is a fixed anatomical trait, not a player choice
hair_colors: list[str] = field(default_factory=list) # valid hair color choices for character
# creation; empty for species with no hair (e.g. reptilian, bot)
is_synthetic: bool = False # mechanical/electronic organs (currently just "bot") - lets game
# logic gate effects like a lightning-induced short circuit without hardcoding species ids
# creation; empty for species with no hair (e.g. reptilian, hydraulibot, motorbot)
is_synthetic: bool = False # mechanical/electronic organs (currently hydraulibot/motorbot) -
# lets game logic gate effects like a lightning-induced short circuit without hardcoding
# species ids
abilities: list[AbilityDef] = field(default_factory=list)
blood_danger_threshold: float = 50.0 # % blood remaining
blood_critical_threshold: float = 25.0
blood_label: str = "Blood" # display term for blood_percentage - e.g. hydraulibot calls it
# "Synthmuscle-Fluid" (see Character.blood_label); purely cosmetic, no mechanical difference
health_item_compatibility: list[str] = field(default_factory=list) # placeholder for future health-item tagging
innate_check_penalties: dict[str, float] = field(default_factory=dict) # permanent, always-on
# fraction lost for a named check regardless of damage state - e.g. motorbot's clumsy
# manipulators (sleight_of_hand) - applied in Character.total_check_penalty alongside
# skill/organ/ailment penalties, the one source that isn't tied to any damage/ailment

View File

@ -17,6 +17,11 @@ class TileLayerDef:
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
speed_multiplier: float = 1.0 # e.g. mud/water slow movement - see World.speed_multiplier_at
hack_difficulty: int | None = None # None = not hackable; otherwise the tech check DC (see
# resources/logic/hacking.py::try_hack_tile_layer) - e.g. a locked door
hackable_unlocked_def_id: str | None = None # this layer's own def to swap to on a
# successful hack (e.g. a locked door's def swaps to its open counterpart) - same
# "replace the layer's def_id in place" idiom construction.py's build/deconstruct use
extra: dict = field(default_factory=dict)

View File

@ -124,15 +124,25 @@ class CharacterMenu:
"""
TABS = ("bio", "equipment", "medical")
POCKETS_SLOT = "_pockets" # sentinel open_backpack_slot value for the character's own base
# inventory (Character.inventory) - it isn't worn, so it has no real clothing slot name.
active_tab: str = "bio"
open_backpack_slot: str | None = None # clothing slot whose granted-inventory popup is open
open_backpack_slot: str | None = None # clothing slot (or POCKETS_SLOT) whose 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 cycle_tab(self) -> None:
"""Advances to the next tab in TABS order, wrapping around - see main.py's
cycle_character_tab action (bound to Tab by default), which was the only way to ever
actually reach a tab other than the default "bio" one before this existed.
"""
index = self.TABS.index(self.active_tab)
self.select_tab(self.TABS[(index + 1) % len(self.TABS)])
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
@ -152,3 +162,29 @@ class CharacterMenu:
def close_backpack_popup(self) -> None:
self.open_backpack_slot = None
def resolve_open_inventory(self, character, registry):
"""The Inventory object `open_backpack_slot` currently refers to, or None if nothing's
open - POCKETS_SLOT resolves to the character's own base inventory, any other slot to
that worn item's granted inventory (see Character.get_equipped_inventory).
"""
if self.open_backpack_slot is None:
return None
if self.open_backpack_slot == self.POCKETS_SLOT:
return character.inventory
return character.get_equipped_inventory(self.open_backpack_slot, registry)
def toggle_backpack(self, slot: str, character, registry) -> bool:
"""Single entry point for "the player clicked this equipment-tab row" - handles both
the POCKETS_SLOT sentinel (toggled directly, since it isn't real clothing) and real
worn slots (delegates to right_click_equipment_slot's existing grants-inventory check).
"""
if slot == self.POCKETS_SLOT:
if character.inventory is None:
return False
if self.open_backpack_slot == slot:
self.open_backpack_slot = None
return False
self.open_backpack_slot = slot
return True
return self.right_click_equipment_slot(slot, character, registry)

View File

@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from engine.character import Character
from engine.entity import Entity, EntityPosition
from engine.item import ItemInstance
from engine.map import Map
LEAP_DISTANCE = 2
@ -46,6 +47,39 @@ class World:
self.entities: dict[str, Entity] = {}
self.player: Entity | None = None
self.time_warp_zones: list[TimeWarpZone] = []
self._pending_fall_distance: dict[str, int] = {}
self.ground_items: dict[tuple[str, int, int, int], list[ItemInstance]] = {} # (map_id,
# x, y, z) -> items dropped/lying there - see drop_item_at/ground_items_at/
# remove_ground_item and resources/logic/pickup.py for the actual pickup/drop logic.
# In-memory only for now: not persisted through WorldRepository save/load.
def consume_fall_distance(self, entity_id: str) -> int:
"""How many z-levels _apply_gravity just dropped this entity, if any - 0 otherwise.
One-shot, same "consume_*" idiom as engine/input.py's pending actions: main.py calls
this right after a move to decide whether to roll fall damage (see
resources/logic/falling.py), without World itself depending on that game-logic layer.
"""
return self._pending_fall_distance.pop(entity_id, 0)
def drop_item_at(self, map_id: str, x: float, y: float, z: float, item: ItemInstance) -> None:
key = (map_id, math.floor(x), math.floor(y), math.floor(z))
self.ground_items.setdefault(key, []).append(item)
def ground_items_at(self, map_id: str, x: float, y: float, z: float) -> list[ItemInstance]:
return list(self.ground_items.get((map_id, math.floor(x), math.floor(y), math.floor(z)), []))
def remove_ground_item(self, map_id: str, x: float, y: float, z: float, item_id: str) -> ItemInstance | None:
key = (map_id, math.floor(x), math.floor(y), math.floor(z))
items = self.ground_items.get(key)
if not items:
return None
for i, candidate in enumerate(items):
if candidate.item_id == item_id:
removed = items.pop(i)
if not items:
del self.ground_items[key]
return removed
return None
def speed_multiplier_at(self, entity_id: str, map_id: str, x: float, y: float, z: float, now: float) -> float:
"""Combined slow from the terrain underfoot (e.g. mud/water - see Tile.speed_multiplier)
@ -194,10 +228,12 @@ class World:
x, y, z = entity.position.x, entity.position.y, entity.position.z
if map_.get_tile(x, y, z).flooring is not None:
return
for nz in range(math.floor(z) - 1, -1, -1):
start_z = math.floor(z)
for nz in range(start_z - 1, -1, -1):
candidate = map_.get_tile(x, y, nz)
if candidate.flooring is not None and candidate.is_walkable(self.registry):
entity.position = EntityPosition(map_.map_id, x, y, nz)
self._pending_fall_distance[entity.entity_id] = start_z - nz
return
def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool:

155
main.py
View File

@ -7,16 +7,18 @@ from pathlib import Path
import glfw
from engine.ai import AIController
from engine.aim import snap_to_8way
from engine.camera import OrthoCamera
from engine.character import Character
from engine.character_creator import CharacterCreator
from engine.character_creator import NPC_SELECTABLE_SPECIES, CharacterCreator
from engine.config import KeyBindings
from engine.controls_menu import ControlsMenu
from engine.db import WorldRepository
from engine.defs import DefRegistry
from engine.dev_config import DevConfig
from engine.dev_tools import DevSpawnMenu
from engine.entity import EntityPosition
from engine.gamepad import GamepadBindings, GamepadButtonEdgeTracker, GamepadState
from engine.game_modes import CharacterCreationFlow, MainMenu, Mode, PauseMenu
from engine.input import InputState
@ -26,13 +28,21 @@ from engine.map_loader import MapLoader
from engine.placeholder_textures import generate_depth_shadow_texture, generate_placeholder_textures
from engine.render.device import configure_context, request_device
from engine.render.menu_draw import (
backpack_grid_origin,
backpack_popup_rect,
character_card_rect,
draw_ability_bar,
draw_backpack_popup,
draw_character_card,
draw_character_creation,
draw_controls_menu,
draw_drag_ghost,
draw_main_menu,
draw_pause_menu,
draw_world_creation,
equipment_row_at,
inventory_cell_at,
point_in_rect,
)
from engine.render.pipeline import QuadPipeline
from engine.render.renderer import Renderer
@ -45,8 +55,12 @@ from engine.ui_assets import generate_ui_textures
from engine.world import World
from engine.world_creation import WorldCreationMenu, build_world_from_template, find_spawn_position
from resources.logic.actions import activate_ability_bar_slot, activate_hand
from resources.logic.health_ticks import apply_bleeding_tick
from resources.logic.ai import run_ai_tick
from resources.logic.contagion import apply_flu_contagion_tick
from resources.logic.falling import apply_fall_damage
from resources.logic.health_ticks import apply_bleeding_tick, apply_boost_expiry_tick
from resources.logic.organs import apply_organ_interactions_tick
from resources.logic.pickup import drop_item, try_pickup
from resources.logic.steering import advance_all_ship_rotations, at_helm, try_steer_ship
from resources.logic.swimming import in_water, try_swim_vertical
@ -121,12 +135,14 @@ class AppState:
self.main_menu = MainMenu(has_existing_save=world is not None)
self.char_flow: CharacterCreationFlow | None = None
self.pending_character: Character | None = None # built, awaiting a world to spawn into
self.npc_spawn_flow: CharacterCreationFlow | None = None # dev-mode NPC spawner (see Mode.NPC_SPAWN)
self.world_creation_menu: WorldCreationMenu | None = None
self.controls_menu: ControlsMenu | None = None
self.pause_menu: PauseMenu | None = None
self.return_mode: Mode = Mode.MAIN_MENU # where Escape from the controls menu goes back to
self.character_card = CharacterMenu()
self.character_card_visible = False
self.dragging_item: tuple[str, str] | None = None # (item_id, def_id) picked up off the inventory grid
self.ability_bar = AbilityBar()
self.dev_spawn_menu = DevSpawnMenu(registry)
@ -233,6 +249,47 @@ def main() -> None:
elif len(key) == 1:
flow.type_char(key)
def handle_npc_spawn_key(key: str) -> None:
"""Dev-mode-only NPC spawner: the exact same species/name CharacterCreationFlow as "new
game" (see handle_character_create_key above), just pooled over every species instead of
only the player-selectable ones (see NPC_SELECTABLE_SPECIES), and confirming spawns the
built Character into the current world instead of handing off to world creation.
"""
flow = state.npc_spawn_flow
assert flow is not None
if flow.step == "species":
if key == "ArrowLeft":
flow.cycle_species(-1)
elif key == "ArrowRight":
flow.cycle_species(1)
elif key == "Enter":
flow.advance()
elif key == "Escape":
state.npc_spawn_flow = None
enter_playing()
return
if key == "Enter":
if flow.advance():
npc = flow.creator.build_character()
assert npc is not None
player = state.world.player if state.world is not None else None
if isinstance(player, Character) and player.position is not None and state.world is not None:
dx, dy, dz = current_aim_direction(player)
npc.position = EntityPosition(
player.position.map_id, player.position.x + dx, player.position.y + dy, player.position.z + dz
)
npc.ai = AIController(behavior_id="wander")
state.world.add_entity(npc)
state.npc_spawn_flow = None
enter_playing()
elif key == "Escape":
flow.go_back()
elif key == "Backspace":
flow.backspace()
elif len(key) == 1:
flow.type_char(key)
def handle_world_create_key(key: str) -> None:
menu = state.world_creation_menu
assert menu is not None
@ -315,6 +372,8 @@ def main() -> None:
handle_controls_key(key)
elif state.mode == Mode.PAUSED:
handle_pause_key(key)
elif state.mode == Mode.NPC_SPAWN:
handle_npc_spawn_key(key)
def handle_playing_key_down(event: dict) -> None:
key = event.get("key", "")
@ -324,6 +383,13 @@ def main() -> None:
return
if action == "toggle_character_card":
state.character_card_visible = not state.character_card_visible
if not state.character_card_visible:
state.dragging_item = None
state.character_card.close_backpack_popup()
return
if action == "cycle_character_tab":
if state.character_card_visible:
state.character_card.cycle_tab()
return
if action == "toggle_dev_mode":
dev_config.toggle()
@ -337,8 +403,65 @@ def main() -> None:
if isinstance(player, Character):
state.dev_spawn_menu.spawn_into(player)
return
if dev_config.enabled and action == "dev_spawn_npc_menu":
leave_playing()
state.npc_spawn_flow = CharacterCreationFlow(CharacterCreator(registry, selectable_species=NPC_SELECTABLE_SPECIES))
state.mode = Mode.NPC_SPAWN
return
input_state.handle_event(event)
def handle_character_card_pointer_event(event_type: str, event: dict) -> bool:
"""Hit-tests a pointer_down/pointer_up against the open character card and (if one is
open) its backpack popup, driving two gestures: clicking an equipment-tab row toggles
that container's popup open/closed (see CharacterMenu.toggle_backpack), and dragging a
cell out of an open popup and releasing outside both the card and the popup drops it on
the ground (see resources/logic/pickup.py::drop_item). Returns whether the event was
consumed by the card/popup (so on_event knows not to also feed it to gameplay's
InputState - e.g. a click landing on the card shouldn't also register as a hand
activation in the world behind it).
"""
player = state.world.player if state.world is not None else None
if not isinstance(player, Character):
return False
menu = state.character_card
_viewport_w, viewport_h = window.canvas.get_physical_size()
px, py = event.get("x", 0.0), event.get("y", 0.0)
open_inventory = menu.resolve_open_inventory(player, registry)
if event_type == "pointer_down":
if open_inventory is not None:
popup_rect = backpack_popup_rect(open_inventory, viewport_h)
if point_in_rect(px, py, popup_rect):
cell = inventory_cell_at(open_inventory, backpack_grid_origin(popup_rect), px, py)
if cell is not None:
placed = open_inventory.item_at(*cell)
if placed is not None:
state.dragging_item = (placed.item.item_id, placed.item.def_id)
return True
if not point_in_rect(px, py, character_card_rect(viewport_h)):
return False
if menu.active_tab == "equipment":
slot = equipment_row_at(player, viewport_h, px, py)
if slot is not None:
menu.toggle_backpack(slot, player, registry)
return True
if event_type == "pointer_up":
if state.dragging_item is None:
return False
item_id, def_id = state.dragging_item
state.dragging_item = None
on_card = point_in_rect(px, py, character_card_rect(viewport_h))
on_popup = open_inventory is not None and point_in_rect(
px, py, backpack_popup_rect(open_inventory, viewport_h)
)
if not on_card and not on_popup:
drop_item(player, state.world, item_id, def_id, open_inventory, registry)
return True
return False
def on_event(event: dict) -> None:
event_type = event.get("event_type")
@ -353,12 +476,18 @@ def main() -> None:
handle_menu_key(event.get("key", ""))
return
if state.character_card_visible and event_type in ("pointer_down", "pointer_up"):
if handle_character_card_pointer_event(event_type, event):
return
if event_type == "key_down":
handle_playing_key_down(event)
else:
input_state.handle_event(event)
window.canvas.add_event_handler(on_event, "key_down", "key_up", "pointer_down", "pointer_move", "close")
window.canvas.add_event_handler(
on_event, "key_down", "key_up", "pointer_down", "pointer_up", "pointer_move", "close"
)
def current_aim_direction(player: Character) -> tuple[int, int, int]:
assert player.position is not None
@ -403,6 +532,9 @@ def main() -> None:
trigger_pause()
elif "gp_back" in gamepad_menu_edges:
state.character_card_visible = not state.character_card_visible
if not state.character_card_visible:
state.dragging_item = None
state.character_card.close_backpack_popup()
else:
for button in gamepad_menu_edges:
key = GAMEPAD_MENU_KEY_MAP.get(button)
@ -422,6 +554,9 @@ def main() -> None:
if isinstance(entity, Character):
apply_bleeding_tick(entity, registry, ticks)
apply_organ_interactions_tick(entity, registry, ticks)
apply_boost_expiry_tick(entity, clock.total_time)
run_ai_tick(entity, world, registry, clock.total_time, rng)
apply_flu_contagion_tick(world, ticks, rng)
player = world.player
if isinstance(player, Character) and player.position is not None:
@ -457,6 +592,13 @@ def main() -> None:
if input_state.consume_swim_down():
try_swim_vertical(player, world, -1)
fallen = world.consume_fall_distance(player.entity_id)
if fallen > 0:
apply_fall_damage(player, registry, fallen, rng)
if input_state.consume_pickup():
try_pickup(player, world, registry)
hand = input_state.consume_hand_activation()
if hand is not None:
activate_hand(
@ -483,6 +625,10 @@ def main() -> None:
draw_ability_bar(ui_buckets, state.ability_bar, viewport_h)
if state.character_card_visible:
draw_character_card(ui_buckets, player, state.character_card, registry, viewport_h)
dragging_id = state.dragging_item[0] if state.dragging_item is not None else None
draw_backpack_popup(ui_buckets, player, state.character_card, registry, viewport_h, dragging_id)
if state.dragging_item is not None and input_state.pointer_pos is not None:
draw_drag_ghost(ui_buckets, state.dragging_item[1], *input_state.pointer_pos)
if dev_config.enabled:
draw_text(ui_buckets, "DEV MODE", viewport_w - 130, 10)
draw_text(ui_buckets, f"spawn: {state.dev_spawn_menu.selected_item_id()}", viewport_w - 260, 30)
@ -505,6 +651,9 @@ def main() -> None:
elif state.mode == Mode.PAUSED:
assert state.pause_menu is not None
draw_pause_menu(ui_buckets, state.pause_menu, viewport_w, viewport_h)
elif state.mode == Mode.NPC_SPAWN:
assert state.npc_spawn_flow is not None
draw_character_creation(ui_buckets, state.npc_spawn_flow, viewport_w, viewport_h, title="Spawn NPC")
renderer.render_frame(context, backdrop, camera, ui_buckets)
window.canvas.request_draw(draw_frame)

View File

@ -22,5 +22,40 @@
"shortcircuit": {
"name": "Short Circuit",
"check_weights": { "insight": 0.7, "perception": 0.6, "sleight_of_hand": 0.5 }
},
"sprained_ankle": {
"name": "Sprained Ankle",
"check_weights": { "acrobatics": 0.3, "athletics": 0.2 }
},
"ripped_tendon": {
"name": "Ripped Tendon",
"check_weights": { "acrobatics": 0.4, "athletics": 0.35, "stealth": 0.2 }
},
"broken_bone": {
"name": "Broken Bone",
"check_weights": { "acrobatics": 0.5, "athletics": 0.5, "strength": 0.2 }
},
"cracked_foot_joint": {
"name": "Cracked Foot Joint",
"check_weights": { "acrobatics": 0.3, "athletics": 0.2 }
},
"popped_fluid_hose": {
"name": "Popped Fluid Hose",
"check_weights": { "acrobatics": 0.4, "athletics": 0.35, "stealth": 0.2 },
"causes_bleeding": true
},
"broken_frame_piece": {
"name": "Broken Structural Frame Piece",
"check_weights": { "acrobatics": 0.5, "athletics": 0.5, "strength": 0.2 }
},
"flu": {
"name": "Flu",
"check_weights": { "strength": 0.3, "speed": 0.3 }
},
"brain_fog": {
"name": "Brain Fog",
"check_weights": { "insight": 0.5, "perception": 0.3 }
}
}

View File

@ -185,51 +185,102 @@
"speed_factor": 0.55
},
"bot_head": {
"hydraulibot_head": {
"slot": "head",
"texture": "characters/bot_head.png",
"texture": "characters/hydraulibot_head.png",
"color": [150, 150, 160],
"bleeding_weight": 0.10,
"bleeding_speed": 4.0,
"skill_weights": { "perception": 0.6, "insight": 0.4 }
},
"hydraulibot_torso": {
"slot": "torso",
"texture": "characters/hydraulibot_torso.png",
"color": [110, 110, 120],
"bleeding_weight": 0.35,
"bleeding_speed": 6.0,
"skill_weights": { "athletics": 0.5 }
},
"hydraulibot_left_arm": {
"slot": "left_arm",
"texture": "characters/hydraulibot_left_arm.png",
"color": [150, 150, 160],
"bleeding_weight": 0.10,
"bleeding_speed": 2.0,
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
},
"hydraulibot_right_arm": {
"slot": "right_arm",
"texture": "characters/hydraulibot_right_arm.png",
"color": [150, 150, 160],
"bleeding_weight": 0.10,
"bleeding_speed": 2.0,
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
},
"hydraulibot_left_leg": {
"slot": "left_leg",
"texture": "characters/hydraulibot_left_leg.png",
"color": [90, 90, 100],
"bleeding_weight": 0.125,
"bleeding_speed": 3.0,
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
"speed_factor": 0.5
},
"hydraulibot_right_leg": {
"slot": "right_leg",
"texture": "characters/hydraulibot_right_leg.png",
"color": [90, 90, 100],
"bleeding_weight": 0.125,
"bleeding_speed": 3.0,
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
"speed_factor": 0.5
},
"motorbot_head": {
"slot": "head",
"texture": "characters/motorbot_head.png",
"color": [95, 95, 105],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "perception": 0.6, "insight": 0.4 }
},
"bot_torso": {
"motorbot_torso": {
"slot": "torso",
"texture": "characters/bot_torso.png",
"color": [110, 110, 120],
"texture": "characters/motorbot_torso.png",
"color": [70, 70, 80],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "athletics": 0.5 }
},
"bot_left_arm": {
"motorbot_left_arm": {
"slot": "left_arm",
"texture": "characters/bot_left_arm.png",
"color": [150, 150, 160],
"texture": "characters/motorbot_left_arm.png",
"color": [95, 95, 105],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
},
"bot_right_arm": {
"motorbot_right_arm": {
"slot": "right_arm",
"texture": "characters/bot_right_arm.png",
"color": [150, 150, 160],
"texture": "characters/motorbot_right_arm.png",
"color": [95, 95, 105],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
},
"bot_left_leg": {
"motorbot_left_leg": {
"slot": "left_leg",
"texture": "characters/bot_left_leg.png",
"color": [90, 90, 100],
"texture": "characters/motorbot_left_leg.png",
"color": [55, 55, 65],
"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": {
"motorbot_right_leg": {
"slot": "right_leg",
"texture": "characters/bot_right_leg.png",
"color": [90, 90, 100],
"texture": "characters/motorbot_right_leg.png",
"color": [55, 55, 65],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
@ -287,5 +338,43 @@
"bleeding_speed": 1.5,
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
"speed_factor": 0.25
},
"turret_psu": {
"slot": "psu",
"texture": "characters/turret_psu.png",
"color": [90, 90, 100],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0
},
"turret_cpu": {
"slot": "cpu",
"texture": "characters/turret_cpu.png",
"color": [70, 160, 90],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "tech": 0.6, "perception": 0.2 }
},
"turret_motors": {
"slot": "motors",
"texture": "characters/turret_motors.png",
"color": [110, 100, 60],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0
},
"turret_gun": {
"slot": "gun",
"texture": "characters/turret_gun.png",
"color": [130, 60, 60],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "sleight_of_hand": 0.5 }
},
"turret_camera": {
"slot": "camera",
"texture": "characters/turret_camera.png",
"color": [60, 120, 200],
"bleeding_weight": 0.0,
"bleeding_speed": 0.0,
"skill_weights": { "perception": 0.6 }
}
}

View File

@ -9,7 +9,8 @@
"name": "Reptilian",
"species_id": "reptilian_quad",
"default_clothing": [],
"inventory_size": [6, 5]
"inventory_size": [6, 5],
"ai_behavior_id": "wander"
},
"npc_alien": {
"name": "Zenari",
@ -17,9 +18,15 @@
"default_clothing": [],
"inventory_size": [6, 5]
},
"npc_bot": {
"name": "Bot",
"species_id": "bot",
"npc_hydraulibot": {
"name": "Hydraulibot",
"species_id": "hydraulibot",
"default_clothing": [],
"inventory_size": [6, 5]
},
"npc_motorbot": {
"name": "Motorbot",
"species_id": "motorbot",
"default_clothing": [],
"inventory_size": [6, 5]
},
@ -34,5 +41,21 @@
"species_id": "dog",
"default_clothing": ["dog_harness", "dog_saddlebag"],
"inventory_size": null
},
"turret_pistol": {
"name": "Pistol Turret",
"species_id": "turret",
"default_clothing": [],
"inventory_size": null,
"turret_weapon_item_id": "pistol_basic",
"turret_turn_rate_degrees_per_tick": 30
},
"turret_autocannon": {
"name": "Autocannon Turret",
"species_id": "turret",
"default_clothing": [],
"inventory_size": null,
"turret_weapon_item_id": "turret_autocannon",
"turret_turn_rate_degrees_per_tick": 12
}
}

View File

@ -101,6 +101,15 @@
"tool_kind": "constructor",
"hands_required": 2
},
"hacking_device": {
"name": "Hacking Device",
"width": 1,
"height": 1,
"texture": "items/hacking_device.png",
"color": [60, 200, 200],
"tool_kind": "hacking_tool",
"hands_required": 1
},
"pistol_basic": {
"name": "Basic Pistol",
"width": 1,
@ -113,6 +122,17 @@
"projectile_type": "bullet",
"hands_required": 1
},
"turret_autocannon": {
"name": "Turret Autocannon",
"width": 1,
"height": 1,
"texture": "items/turret_autocannon.png",
"color": [90, 90, 60],
"weapon_skill": "sleight_of_hand",
"weapon_damage": 14.0,
"weapon_range": 9,
"projectile_type": "bullet"
},
"bow_basic": {
"name": "Basic Bow",
"width": 1,
@ -289,5 +309,99 @@
"color": [90, 160, 220],
"hands_required": 1,
"grants_ability": "launch_lightning"
},
"bandage": {
"name": "Bandage",
"width": 1,
"height": 1,
"texture": "items/bandage.png",
"color": [230, 230, 220],
"stackable": true,
"max_stack": 6
},
"sealant": {
"name": "Sealant",
"width": 1,
"height": 1,
"texture": "items/sealant.png",
"color": [200, 210, 60],
"stackable": true,
"max_stack": 6
},
"adrenablend": {
"name": "Adrenablend",
"width": 1,
"height": 1,
"texture": "items/adrenablend.png",
"color": [220, 60, 60],
"stackable": true,
"max_stack": 3,
"hands_required": 1,
"grants_ability": "adrenablend",
"consumable": true
},
"cyberdeck_biomechanics_mk1": {
"name": "Biomechanics Model 1 Cyberdeck",
"width": 1,
"height": 1,
"texture": "items/cyberdeck_biomechanics_mk1.png",
"color": [80, 140, 220],
"is_implant": true,
"is_hacking_implant": true,
"hack_bonus": 1
},
"cyberdeck_biomechanics_mk2": {
"name": "Biomechanics Model 2 Cyberdeck",
"width": 1,
"height": 1,
"texture": "items/cyberdeck_biomechanics_mk2.png",
"color": [80, 170, 230],
"is_implant": true,
"is_hacking_implant": true,
"hack_bonus": 2
},
"cyberdeck_infoinc_mk1": {
"name": "InfoInc Cyberdeck MK1",
"width": 1,
"height": 1,
"texture": "items/cyberdeck_infoinc_mk1.png",
"color": [200, 90, 60],
"is_implant": true,
"is_hacking_implant": true,
"hack_bonus": -3,
"hack_min_roll_percent": 0.45
},
"cyberdeck_infoinc_mk2": {
"name": "InfoInc Cyberdeck MK2",
"width": 1,
"height": 1,
"texture": "items/cyberdeck_infoinc_mk2.png",
"color": [210, 130, 60],
"is_implant": true,
"is_hacking_implant": true,
"hack_bonus": 0
},
"cyberdeck_infoinc_mk3": {
"name": "InfoInc Cyberdeck MK3",
"width": 1,
"height": 1,
"texture": "items/cyberdeck_infoinc_mk3.png",
"color": [220, 170, 60],
"is_implant": true,
"is_hacking_implant": true,
"hack_bonus": 2,
"hack_min_roll_percent": 0.6
},
"cyberdeck_the_miro_model_3a": {
"name": "The_miro's Cyberdeck Model 3A",
"width": 1,
"height": 1,
"texture": "items/cyberdeck_the_miro_model_3a.png",
"color": [230, 60, 220],
"is_implant": true,
"is_hacking_implant": true,
"hack_bonus": 5,
"hack_min_roll_percent": 0.75,
"secret": true
}
}

View File

@ -26,6 +26,18 @@
"host_slot": "head",
"check_weights": { "insight": 0.5, "perception": 0.3 }
},
"human_eyes": {
"name": "Eyes",
"host_slot": "head",
"check_weights": { "perception": 0.5 },
"role": "eyes"
},
"human_ears": {
"name": "Ears",
"host_slot": "head",
"check_weights": { "perception": 0.3, "insight": 0.1 },
"role": "ears"
},
"reptilian_heart": {
"name": "Heart",
@ -54,6 +66,18 @@
"host_slot": "head",
"check_weights": { "insight": 0.5, "perception": 0.3 }
},
"reptilian_eyes": {
"name": "Eyes",
"host_slot": "head",
"check_weights": { "perception": 0.5 },
"role": "eyes"
},
"reptilian_ears": {
"name": "Ears",
"host_slot": "head",
"check_weights": { "perception": 0.3, "insight": 0.1 },
"role": "ears"
},
"zenari_cardial_node": {
"name": "Cardial Node",
@ -82,35 +106,103 @@
"host_slot": "head",
"check_weights": { "insight": 0.5, "perception": 0.3 }
},
"zenari_eyes": {
"name": "Eyes",
"host_slot": "head",
"check_weights": { "perception": 0.5 },
"role": "eyes"
},
"zenari_ears": {
"name": "Ears",
"host_slot": "head",
"check_weights": { "perception": 0.3, "insight": 0.1 },
"role": "ears"
},
"bot_cpu": {
"hydraulibot_cpu": {
"name": "CPU",
"host_slot": "head",
"check_weights": { "insight": 0.5, "perception": 0.3 }
},
"bot_npu": {
"hydraulibot_npu": {
"name": "NPU",
"host_slot": "head",
"check_weights": { "sleight_of_hand": 0.4, "perception": 0.2 }
},
"bot_power_cell": {
"hydraulibot_camera_module": {
"name": "Camera Module",
"host_slot": "head",
"check_weights": { "perception": 0.5 },
"role": "eyes"
},
"hydraulibot_mic": {
"name": "Mic",
"host_slot": "head",
"check_weights": { "perception": 0.3, "insight": 0.1 },
"role": "ears"
},
"hydraulibot_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 },
"affects_organs": {
"hydraulibot_cpu": 0.05,
"hydraulibot_npu": 0.05,
"hydraulibot_hydraulic_fluid_bladder": 0.03
},
"role": "heart"
},
"bot_hydraulic_fluid_bladder": {
"hydraulibot_hydraulic_fluid_bladder": {
"name": "Hydraulic Fluid Bladder",
"host_slot": "torso",
"check_weights": { "strength": 0.3, "athletics": 0.3 }
},
"bot_cooling_fan": {
"hydraulibot_cooling_fan": {
"name": "Cooling Fan",
"host_slot": "torso",
"check_weights": { "speed": 0.3, "athletics": 0.3 }
},
"motorbot_cpu": {
"name": "CPU",
"host_slot": "head",
"check_weights": { "insight": 0.5, "perception": 0.3 }
},
"motorbot_npu": {
"name": "NPU",
"host_slot": "head",
"check_weights": { "sleight_of_hand": 0.4, "perception": 0.2 }
},
"motorbot_camera_lenses": {
"name": "Camera Lenses",
"host_slot": "head",
"check_weights": { "perception": 0.5 },
"role": "eyes"
},
"motorbot_mic": {
"name": "Mic",
"host_slot": "head",
"check_weights": { "perception": 0.3, "insight": 0.1 },
"role": "ears"
},
"motorbot_battery": {
"name": "Battery",
"host_slot": "torso",
"check_weights": { "strength": 0.2, "speed": 0.2 },
"affects_organs": { "motorbot_main_motor": 0.18 }
},
"motorbot_mechanical_transmission": {
"name": "Mechanical Transmission",
"host_slot": "torso",
"check_weights": { "speed": 0.3, "athletics": 0.3 }
},
"motorbot_main_motor": {
"name": "Main Motor",
"host_slot": "torso",
"check_weights": { "strength": 0.6, "speed": 0.5 },
"role": "heart"
},
"dog_heart": {
"name": "Heart",
"host_slot": "torso",
@ -137,5 +229,17 @@
"name": "Brain",
"host_slot": "head",
"check_weights": { "insight": 0.5, "perception": 0.3 }
},
"dog_eyes": {
"name": "Eyes",
"host_slot": "head",
"check_weights": { "perception": 0.5 },
"role": "eyes"
},
"dog_ears": {
"name": "Ears",
"host_slot": "head",
"check_weights": { "perception": 0.3, "insight": 0.1 },
"role": "ears"
}
}

View File

@ -10,7 +10,15 @@
"human_left_leg",
"human_right_leg"
],
"default_organs": ["human_heart", "human_lungs", "human_liver", "human_kidneys", "human_brain"],
"default_organs": [
"human_heart",
"human_lungs",
"human_liver",
"human_kidneys",
"human_brain",
"human_eyes",
"human_ears"
],
"body_type": "biped",
"body_type_options": ["average", "thin", "fat", "hulk"],
"hair_colors": ["black", "brown", "blonde", "red", "gray", "white"],
@ -22,7 +30,7 @@
],
"blood_danger_threshold": 50.0,
"blood_critical_threshold": 25.0,
"health_item_compatibility": []
"health_item_compatibility": ["bandage"]
},
"reptilian_quad": {
"name": "Reptilian",
@ -42,7 +50,9 @@
"reptilian_lung",
"reptilian_liver",
"reptilian_venom_gland",
"reptilian_brain"
"reptilian_brain",
"reptilian_eyes",
"reptilian_ears"
],
"body_type": "biped",
"body_type_options": ["average", "stocky", "slender"],
@ -54,7 +64,7 @@
],
"blood_danger_threshold": 45.0,
"blood_critical_threshold": 20.0,
"health_item_compatibility": []
"health_item_compatibility": ["bandage"]
},
"alien_tri": {
"name": "Zenari",
@ -72,7 +82,9 @@
"zenari_respirator",
"zenari_metabolizer",
"zenari_filtration_organ",
"zenari_cortex"
"zenari_cortex",
"zenari_eyes",
"zenari_ears"
],
"body_type": "biped",
"body_type_options": ["average", "slender", "willowy"],
@ -85,25 +97,61 @@
],
"blood_danger_threshold": 55.0,
"blood_critical_threshold": 30.0,
"health_item_compatibility": []
"health_item_compatibility": ["bandage"]
},
"bot": {
"name": "Bot",
"hydraulibot": {
"name": "Hydraulibot",
"genders": ["none"],
"default_body_parts": [
"bot_head",
"bot_torso",
"bot_left_arm",
"bot_right_arm",
"bot_left_leg",
"bot_right_leg"
"hydraulibot_head",
"hydraulibot_torso",
"hydraulibot_left_arm",
"hydraulibot_right_arm",
"hydraulibot_left_leg",
"hydraulibot_right_leg"
],
"default_organs": [
"bot_cpu",
"bot_npu",
"bot_power_cell",
"bot_hydraulic_fluid_bladder",
"bot_cooling_fan"
"hydraulibot_cpu",
"hydraulibot_npu",
"hydraulibot_camera_module",
"hydraulibot_mic",
"hydraulibot_power_cell",
"hydraulibot_hydraulic_fluid_bladder",
"hydraulibot_cooling_fan"
],
"body_type": "biped",
"body_type_options": ["compact", "standard", "heavy"],
"hair_colors": [],
"is_synthetic": true,
"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": 50.0,
"blood_critical_threshold": 25.0,
"blood_label": "Synthmuscle-Fluid",
"health_item_compatibility": ["sealant"]
},
"motorbot": {
"name": "Motorbot",
"genders": ["none"],
"default_body_parts": [
"motorbot_head",
"motorbot_torso",
"motorbot_left_arm",
"motorbot_right_arm",
"motorbot_left_leg",
"motorbot_right_leg"
],
"default_organs": [
"motorbot_cpu",
"motorbot_npu",
"motorbot_camera_lenses",
"motorbot_mic",
"motorbot_battery",
"motorbot_mechanical_transmission",
"motorbot_main_motor"
],
"body_type": "biped",
"body_type_options": ["compact", "standard", "heavy"],
@ -116,7 +164,8 @@
],
"blood_danger_threshold": 0.0,
"blood_critical_threshold": 0.0,
"health_item_compatibility": []
"health_item_compatibility": ["sealant"],
"innate_check_penalties": { "sleight_of_hand": 0.4 }
},
"dog": {
"name": "Dog",
@ -129,7 +178,7 @@
"dog_back_left_leg",
"dog_back_right_leg"
],
"default_organs": ["dog_heart", "dog_lungs", "dog_liver", "dog_kidneys", "dog_brain"],
"default_organs": ["dog_heart", "dog_lungs", "dog_liver", "dog_kidneys", "dog_brain", "dog_eyes", "dog_ears"],
"body_type": "quadruped",
"body_type_options": ["small", "medium", "large"],
"hair_colors": ["black", "brown", "golden", "white", "gray"],
@ -143,6 +192,22 @@
],
"blood_danger_threshold": 50.0,
"blood_critical_threshold": 25.0,
"health_item_compatibility": []
"health_item_compatibility": ["bandage"]
},
"turret": {
"name": "Turret",
"genders": ["none"],
"default_body_parts": ["turret_psu", "turret_cpu", "turret_motors", "turret_gun", "turret_camera"],
"default_organs": [],
"body_type": "turret",
"body_type_options": [],
"hair_colors": [],
"is_synthetic": true,
"abilities": [],
"blood_danger_threshold": 0.0,
"blood_critical_threshold": 0.0,
"blood_label": "Coolant",
"health_item_compatibility": ["sealant"],
"innate_check_penalties": {}
}
}

View File

@ -20,7 +20,16 @@
"ladder": { "color": [130, 110, 70], "walkable": true, "staircase_direction": "up" },
"solid_rock": { "color": [80, 78, 76], "walkable": false, "blocks_los": true, "texture": "tiles/room_solid_rock.png" },
"slope_up": { "color": [140, 118, 90], "walkable": true, "staircase_direction": "up", "texture": "tiles/room_slope_up.png" },
"slope_down": { "color": [120, 100, 76], "walkable": true, "staircase_direction": "down", "texture": "tiles/room_slope_down.png" }
"slope_down": { "color": [120, 100, 76], "walkable": true, "staircase_direction": "down", "texture": "tiles/room_slope_down.png" },
"door_locked": {
"color": [90, 90, 160],
"walkable": false,
"blocks_los": true,
"texture": "tiles/room_door_locked.png",
"hack_difficulty": 12,
"hackable_unlocked_def_id": "door_open"
},
"door_open": { "color": [160, 160, 210], "walkable": true, "texture": "tiles/room_door_open.png" }
},
"roof": {
"shingles": { "color": [140, 60, 50], "walkable": true },

View File

@ -10,6 +10,13 @@ DEFAULT_RADIUS = 3.0
DEFAULT_SPEED_FACTOR = 0.3 # 30% normal speed inside the sphere
DEFAULT_DURATION = 5.0 # seconds
ADRENABLEND_STRENGTH_MULTIPLIER = 1.6
ADRENABLEND_SPEED_MULTIPLIER = 1.6
ADRENABLEND_DURATION = 20.0 # seconds
ADRENABLEND_BRAIN_FOG_SEVERITY = 0.7 # same ailment/severity a flu infection causes (see
# resources/logic/contagion.py) - reused directly rather than a near-identical duplicate, since
# mechanically "your head's not all there" is the same regardless of cause
def cast_time_warp_sphere(
caster: Character,
@ -50,6 +57,38 @@ def cast_time_warp_sphere(
return zone
def cast_adrenablend(
caster: Character,
world: World,
now: float,
aim_direction: tuple[int, int, int] | None = None,
duration: float = ADRENABLEND_DURATION,
) -> Character:
"""A shot of stimulant: greatly boosts strength and speed (see Character.active_boosts,
consumed by effective_strength/effective_speed) for `duration` seconds, at the cost of the
same brain_fog ailment a flu infection causes - reused directly (see
ADRENABLEND_BRAIN_FOG_SEVERITY above). Meant for a consumable item (ItemDef.consumable) via
resources/logic/actions.py::activate_hand, which unwields it after this succeeds - unlike
time_warp_sphere/launch_lightning this isn't a reusable wand-like ability, just item
plumbing reused for the "activate to trigger an effect" part. `aim_direction` is accepted
(and ignored) only to match every other ABILITY_CASTERS entry's calling convention.
"""
caster.active_boosts["strength"] = ADRENABLEND_STRENGTH_MULTIPLIER
caster.active_boosts["speed"] = ADRENABLEND_SPEED_MULTIPLIER
caster.boost_expirations["strength"] = now + duration
caster.boost_expirations["speed"] = now + duration
head = caster.get_or_create_body_part_override("head")
if head is not None:
existing = next((a for a in head.ailments if a.id == "brain_fog"), None)
if existing is not None:
existing.severity = max(existing.severity, ADRENABLEND_BRAIN_FOG_SEVERITY)
else:
head.ailments.append(Ailment(id="brain_fog", name="Brain Fog", severity=ADRENABLEND_BRAIN_FOG_SEVERITY))
caster.log_medical_event("Took Adrenablend: strength/speed boosted, brain fog")
return caster
DEFAULT_LAUNCH_LIGHTNING_RANGE = 8 # tiles
LIGHTNING_BOLT_BURN_FRACTION = 0.5 # roughly half the body parts, same flavor as the weather strike
@ -84,7 +123,7 @@ def cast_launch_lightning(
OrganDef.affects_organs) degrades too over subsequent organ-interaction ticks, a real
physiological cascade rather than a flat stat penalty. A species with no heart-equivalent
organ still gets burned, just skips the organ-damage half. On a synthetic target (see
SpeciesDef.is_synthetic, currently just bots) it also short-circuits - see
SpeciesDef.is_synthetic, currently hydraulibot/motorbot) it also short-circuits - see
_apply_shortcircuit - on top of everything else, since electronics are especially
vulnerable to a lightning strike.
"""
@ -135,15 +174,16 @@ def _apply_heart_palpitations(character: Character, registry) -> None:
def _apply_shortcircuit(character: Character, registry, rng: random.Random) -> None:
"""Bot-specific (see SpeciesDef.is_synthetic - a no-op for anyone else, including a
"""Synthetic-only (see SpeciesDef.is_synthetic - a no-op for anyone else, including a
speciesless character): splits SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE randomly across every organ
the target has - a short circuit doesn't discriminate which components it fries - then
layers a shortcircuit ailment onto every body part that hosts at least one of them. The
ailment alone already weighs heavily on insight/perception/sleight_of_hand (see
resources/defs/ailments.json), and since bot_cpu/bot_npu (the organs those same checks
depend on, see organs.json) are themselves among the randomly-damaged organs, the "huge
debuff to intellect" falls straight out of the existing organ_penalty math - no separate
intellect-specific mechanic needed.
resources/defs/ailments.json), and since every synthetic species' own CPU/NPU-equivalent
organs (whichever checks those depend on, see organs.json) are themselves among the
randomly-damaged organs, the "huge debuff to intellect" falls straight out of the existing
organ_penalty math - no separate intellect-specific mechanic needed, and no per-species
organ-id branching either.
"""
if character.species_id is None:
return

View File

@ -4,15 +4,22 @@ 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_launch_lightning, cast_time_warp_sphere
from resources.logic.abilities import cast_adrenablend, cast_launch_lightning, 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.hacking import find_hacking_implant, try_hack_tile_layer
from resources.logic.knockback import resolve_melee_knockback
from resources.logic.ranged_combat import fire_held_weapon
# Which tile layer a bare interaction (a wielded tool, or a bare-handed hacking implant) targets
# when more than one is present at a tile - first match wins, e.g. a locked door on "room" is
# reached before the "flooring" underneath it.
TILE_LAYER_SCAN_ORDER = ("room", "flooring", "subfloor", "roof")
ABILITY_CASTERS = {
"time_warp_sphere": cast_time_warp_sphere,
"launch_lightning": cast_launch_lightning,
"adrenablend": cast_adrenablend,
}
ABILITY_COOLDOWNS = {
@ -78,12 +85,26 @@ def activate_hand(
result = perform_attack(character, target, registry, weapon_def=None, rng=rng)
resolve_melee_knockback(world, character, target, None, result.hit, registry)
return result
if find_hacking_implant(character, registry) is not None:
map_ = world.maps[character.position.map_id]
if not map_.in_bounds(*ahead):
return None
tile = map_.get_tile(*ahead)
layer = next((layer for layer in TILE_LAYER_SCAN_ORDER if tile.get_layer(layer)), None)
if layer is None:
return None
return try_hack_tile_layer(
character, world, registry, character.position.map_id, ahead[0], ahead[1], ahead[2], layer, rng=rng
)
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, aim_direction=aim_direction)
result = _cast_ability(character, world, item_def.grants_ability, now, aim_direction=aim_direction)
if item_def.consumable and result is not None:
character.unwield(hand_slot)
return result
if item_def.tool_kind is not None:
map_ = world.maps[character.position.map_id]
@ -95,9 +116,13 @@ def activate_hand(
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)
layer = next((layer for layer in TILE_LAYER_SCAN_ORDER if tile.get_layer(layer)), None)
if layer is None:
return None
if item_def.tool_kind == "hacking_tool":
return try_hack_tile_layer(
character, world, registry, character.position.map_id, ahead[0], ahead[1], ahead[2], layer, rng=rng
)
return interact_with_tile(character, world, character.position.map_id, ahead[0], ahead[1], ahead[2], layer, button)
if item_def.magazine_size > 0:

50
resources/logic/ai.py Normal file
View File

@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Callable
from engine.character import Character
from engine.defs import DefRegistry
from engine.world import World
from resources.logic.combat import DiceRoller
from resources.logic.turrets import TURRET_BEHAVIOR_ID, turret_ai_behavior
# Seconds between wander steps, randomized so a room full of NPCs doesn't lock-step.
WANDER_INTERVAL = (2.0, 5.0)
_WANDER_DIRECTIONS: list[tuple[int, int]] = [(-1, 0), (1, 0), (0, -1), (0, 1)]
def _wander(character: Character, world: World, registry: DefRegistry, now: float, rng: DiceRoller) -> None:
"""The simplest useful example behavior: every so often, take one random step in a
cardinal direction (a no-op if that step is blocked - see World.try_move). This exists to
prove the ai-slot framework actually reaches into the world, not as a real patrol/pathing
AI - most NPCs will want a purpose-built behavior of their own registered alongside it.
"""
assert character.ai is not None
if now < character.ai.state.get("next_move_at", 0.0):
return
dx, dy = rng.choice(_WANDER_DIRECTIONS)
world.try_move(character, dx, dy, 0)
character.ai.state["next_move_at"] = now + rng.uniform(*WANDER_INTERVAL)
# behavior_id (see EntityDef.ai_behavior_id / AIController.behavior_id) -> tick function. Add a
# new NPC behavior by writing its function and registering it here - run_ai_tick needs no
# changes to pick it up.
AI_BEHAVIORS: dict[str, Callable[[Character, World, DefRegistry, float, DiceRoller], None]] = {
"wander": _wander,
TURRET_BEHAVIOR_ID: turret_ai_behavior,
}
def run_ai_tick(character: Character, world: World, registry: DefRegistry, now: float, rng: DiceRoller) -> None:
"""Ticks whichever behavior `character.ai.behavior_id` names, if any. A no-op for a
player-controlled or otherwise inert Character (`ai is None`) and for an unrecognized
behavior_id, so a bad/typo'd def can never crash the tick loop. Meant to be called once per
Character per elapsed game tick (see main.py's PLAYING per-tick block).
"""
if character.ai is None:
return
behavior = AI_BEHAVIORS.get(character.ai.behavior_id)
if behavior is None:
return
behavior(character, world, registry, now, rng)

View File

@ -0,0 +1,85 @@
from __future__ import annotations
import math
import random
from engine.character import Ailment, Character
from engine.world import World
# Only these species catch it at all - synthetics (see SpeciesDef.is_synthetic) are inherently
# immune to organic ailments like a viral infection, and the other organic species (zenari,
# dog) simply aren't susceptible to *this particular* virus.
FLU_SUSCEPTIBLE_SPECIES = ("human", "reptilian_quad")
FLU_CONTAGION_RADIUS = 3.0
FLU_CONTAGION_CHANCE_PER_TICK = 0.05
# "Symptoms": a barely-noticeable weakened heart, and a much more pronounced brain fog - two
# separate ailments (each check-weighted toward its own organ's own checks) since one Ailment
# instance can only carry one severity, and these two are meant to feel very different.
FLU_HEART_SEVERITY = 0.12
FLU_BRAIN_FOG_SEVERITY = 0.7
def is_flu_susceptible(character: Character) -> bool:
return character.species_id in FLU_SUSCEPTIBLE_SPECIES
def has_ailment(character: Character, ailment_id: str) -> bool:
return any(
any(a.id == ailment_id for a in part.ailments) for part in character.resolved_body_parts().values() if part is not None
)
def contract_flu(character: Character) -> bool:
"""Gives `character` both flu symptoms (torso: flu, head: brain_fog) - a no-op (returns
False) for a non-susceptible species or someone already infected, so callers (see
apply_flu_contagion_tick) don't need to guard against either first.
"""
if not is_flu_susceptible(character) or has_ailment(character, "flu"):
return False
torso = character.get_or_create_body_part_override("torso")
if torso is not None:
torso.ailments.append(Ailment(id="flu", name="Flu", severity=FLU_HEART_SEVERITY))
head = character.get_or_create_body_part_override("head")
if head is not None:
existing = next((a for a in head.ailments if a.id == "brain_fog"), None)
if existing is not None:
existing.severity = max(existing.severity, FLU_BRAIN_FOG_SEVERITY)
else:
head.ailments.append(Ailment(id="brain_fog", name="Brain Fog", severity=FLU_BRAIN_FOG_SEVERITY))
character.log_medical_event("Caught the flu")
return True
def _same_spot(a: Character, b: Character) -> bool:
assert a.position is not None and b.position is not None
if a.position.map_id != b.position.map_id or math.floor(a.position.z) != math.floor(b.position.z):
return False
return math.hypot(a.position.x - b.position.x, a.position.y - b.position.y) <= FLU_CONTAGION_RADIUS
def apply_flu_contagion_tick(world: World, ticks: int, rng: random.Random | None = None) -> None:
"""Each elapsed tick, every susceptible, not-yet-infected character standing within
FLU_CONTAGION_RADIUS of anyone already infected has a flat chance to catch it too - a
simple proximity spread, rolled once per tick (not per nearby pair) to keep it cheap.
"""
if ticks <= 0:
return
rng = rng or random.Random()
characters = [e for e in world.entities.values() if isinstance(e, Character) and e.position is not None]
infected = [c for c in characters if has_ailment(c, "flu")]
if not infected:
return
for candidate in characters:
if candidate in infected or not is_flu_susceptible(candidate):
continue
if not any(_same_spot(candidate, sick) for sick in infected):
continue
for _ in range(ticks):
if rng.random() < FLU_CONTAGION_CHANCE_PER_TICK:
contract_flu(candidate)
break

144
resources/logic/falling.py Normal file
View File

@ -0,0 +1,144 @@
from __future__ import annotations
import random
from engine.character import Ailment, Character
from engine.character_creator import DEFAULT_MAX_SKILL_LEVEL
from engine.defs import DefRegistry
from resources.logic.combat import DiceRoller, roll_skill_check
# Every leg-ish slot across every species this engine defines (see resources/defs/body_parts.json)
# - a character only ever has a subset of these, checked via Character.get_body_part.
LEG_SLOTS = ("left_leg", "right_leg", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg")
# Heights 1-2: no acrobatics check at all, just a small flat chance of the mildest ailment -
# "rare chances on any height". Never anything worse than a sprain this low.
LOW_FALL_SPRAIN_CHANCE = 0.08
# Heights 3 up to (but not including) the severe threshold: an actual acrobatics check, tiered
# by roll + effective skill (see resources/logic/combat.py::roll_skill_check) - a good enough
# landing avoids injury entirely, a bad one breaks something.
ACROBATICS_CHECK_MIN_FALL = 3
CHECK_CLEAN_LANDING_DC = 20
CHECK_SPRAIN_DC = 14
CHECK_TENDON_DC = 8
# below CHECK_TENDON_DC -> broken bone
# Height 5+ (6+ for a skilled acrobat) skips the check entirely: you're definitely hurt, just a
# coin-weighted roll for which of the two worst injuries.
SEVERE_FALL_HEIGHT = 5
SEVERE_FALL_HEIGHT_SKILLED = 6
SKILLED_ACROBATICS_THRESHOLD = 0.6 * DEFAULT_MAX_SKILL_LEVEL # "60% of max" - 3.0 today
SEVERE_BROKEN_BONE_CHANCE = 0.9 # the other 10% is a ripped tendon instead
# Least to most damage. Severity is fixed per ailment id regardless of which path triggered it -
# same "one canonical severity per ailment id" convention as combat.py's COMBAT_AILMENT_SEVERITY.
FALL_AILMENT_SEVERITY = {
"sprained_ankle": 0.3,
"ripped_tendon": 0.6,
"broken_bone": 0.9,
}
FALL_AILMENT_NAMES = {
"sprained_ankle": "Sprained Ankle",
"ripped_tendon": "Ripped Tendon",
"broken_bone": "Broken Bone",
}
# Synthetics (see SpeciesDef.is_synthetic) get the same injuries under mechanical names, not a
# different mechanic - popped_fluid_hose additionally leaks (see AilmentDef.causes_bleeding /
# resources/logic/health_ticks.py::apply_bleeding_tick), a synthetic-only quirk plain organic
# ripped tendons don't have.
SYNTHETIC_AILMENT_IDS = {
"sprained_ankle": "cracked_foot_joint",
"ripped_tendon": "popped_fluid_hose",
"broken_bone": "broken_frame_piece",
}
SYNTHETIC_AILMENT_NAMES = {
"cracked_foot_joint": "Cracked Foot Joint",
"popped_fluid_hose": "Popped Fluid Hose",
"broken_frame_piece": "Broken Structural Frame Piece",
}
def _is_synthetic(character: Character, registry: DefRegistry) -> bool:
if character.species_id is None:
return False
return registry.get_species_def(character.species_id).is_synthetic
def _ailment_id_for(base_id: str, is_synthetic: bool) -> str:
return SYNTHETIC_AILMENT_IDS[base_id] if is_synthetic else base_id
def _ailment_name_for(ailment_id: str) -> str:
return SYNTHETIC_AILMENT_NAMES.get(ailment_id) or FALL_AILMENT_NAMES[ailment_id]
def _random_leg_slot(character: Character, rng: DiceRoller) -> str | None:
present = [slot for slot in LEG_SLOTS if character.get_body_part(slot) is not None]
if not present:
return None
return rng.choice(present)
def _severe_fall_threshold(character: Character) -> int:
if character.bio.get("acrobatics", 0) >= SKILLED_ACROBATICS_THRESHOLD:
return SEVERE_FALL_HEIGHT_SKILLED
return SEVERE_FALL_HEIGHT
def _base_ailment_for_fall(character: Character, registry: DefRegistry, fallen_levels: int, rng: DiceRoller) -> str | None:
"""The organic ailment id (before any synthetic rename) this fall causes, or None for a
clean landing. See module docstring-equivalent constants above for the three height bands.
"""
if fallen_levels >= _severe_fall_threshold(character):
return "broken_bone" if rng.uniform(0.0, 1.0) < SEVERE_BROKEN_BONE_CHANCE else "ripped_tendon"
if fallen_levels >= ACROBATICS_CHECK_MIN_FALL:
roll, effective_skill = roll_skill_check(character, "acrobatics", registry, rng)
total = roll + effective_skill
if total >= CHECK_CLEAN_LANDING_DC:
return None
if total >= CHECK_SPRAIN_DC:
return "sprained_ankle"
if total >= CHECK_TENDON_DC:
return "ripped_tendon"
return "broken_bone"
if rng.uniform(0.0, 1.0) < LOW_FALL_SPRAIN_CHANCE:
return "sprained_ankle"
return None
def apply_fall_damage(
character: Character, registry: DefRegistry, fallen_levels: int, rng: DiceRoller | None = None
) -> str | None:
"""Rolls and applies a leg injury appropriate to how far `character` just fell (see
engine/world.py::World.consume_fall_distance, fed by World._apply_gravity - the only
current source of falls). Returns the ailment id actually applied, or None if they landed
clean or have no leg to hurt in the first place (e.g. a legless prop entity).
"""
if fallen_levels <= 0:
return None
rng = rng or random.Random()
slot = _random_leg_slot(character, rng)
if slot is None:
return None
base_id = _base_ailment_for_fall(character, registry, fallen_levels, rng)
if base_id is None:
return None
ailment_id = _ailment_id_for(base_id, _is_synthetic(character, registry))
part = character.get_or_create_body_part_override(slot)
if part is None:
return None
existing = next((a for a in part.ailments if a.id == ailment_id), None)
severity = FALL_AILMENT_SEVERITY[base_id]
if existing is not None:
existing.severity = max(existing.severity, severity)
else:
part.ailments.append(Ailment(id=ailment_id, name=_ailment_name_for(ailment_id), severity=severity))
character.log_medical_event(f"Fell {fallen_levels} level(s): {_ailment_name_for(ailment_id)} ({slot})")
return ailment_id

View File

@ -0,0 +1,83 @@
from __future__ import annotations
import random
from engine.character import Character
from engine.defs import DefRegistry
from engine.item import ItemDef
from engine.tile import TileLayerInstance
from engine.world import World
from resources.logic.combat import SKILL_CHECK_DIE, DiceRoller
TECH_SKILL_ID = "tech"
def find_hacking_implant(character: Character, registry: DefRegistry) -> ItemDef | None:
"""The first installed implant that assists hacking (a cyberdeck - see
ItemDef.is_hacking_implant), if any. Having one both applies its hack_bonus/
hack_min_roll_percent to every tech check (see roll_tech_check) and lets a bare hand
attempt a hack with no wielded hacking_tool at all (see resources/logic/actions.py::
activate_hand) - a character is only ever expected to have one installed at a time.
"""
for item_id in character.implants:
item_def = registry.get_item_def(item_id)
if item_def.is_hacking_implant:
return item_def
return None
def roll_tech_check(character: Character, registry: DefRegistry, rng: DiceRoller) -> tuple[int, float]:
"""Same shape as combat.py::roll_skill_check (1d20 + effective skill, reduced by
total_check_penalty), but tech's base skill is derived (see Character.base_tech_skill)
rather than a directly-bought bio value. An installed hacking implant (see
find_hacking_implant) then adds its own flat hack_bonus and, if it guarantees a minimum
roll quality (hack_min_roll_percent), floors the raw d20 roll at that fraction of
SKILL_CHECK_DIE before it's returned.
"""
base_skill = character.base_tech_skill()
effective_skill = base_skill * (1.0 - character.total_check_penalty(TECH_SKILL_ID, registry))
roll = rng.randint(1, SKILL_CHECK_DIE)
implant = find_hacking_implant(character, registry)
if implant is not None:
effective_skill += implant.hack_bonus
if implant.hack_min_roll_percent is not None:
roll = max(roll, round(implant.hack_min_roll_percent * SKILL_CHECK_DIE))
return roll, effective_skill
def try_hack_tile_layer(
character: Character,
world: World,
registry: DefRegistry,
map_id: str,
x: int,
y: int,
z: int,
layer: str,
rng: DiceRoller | None = None,
) -> bool:
"""A tech skill check against a hackable tile layer (e.g. a locked door - see
TileLayerDef.hack_difficulty/hackable_unlocked_def_id). On success, swaps the layer to its
unlocked def in place - the same "replace the layer's def_id" idiom
resources/logic/construction.py's build/deconstruct actions already use, so every other
system that reads tile layers (walkability, LOS, rendering) understands the new state with
no changes of its own. Returns whether it succeeded - False for a tile with nothing
hackable at that layer, or a failed check (rollable again any time, no cooldown/lockout).
"""
tile = world.maps[map_id].get_tile(x, y, z)
instance = tile.get_layer(layer)
if instance is None:
return False
layer_def = registry.get_tile_layer_def(layer, instance.def_id)
if layer_def is None or layer_def.hack_difficulty is None or layer_def.hackable_unlocked_def_id is None:
return False
rng = rng or random.Random()
roll, effective_skill = roll_tech_check(character, registry, rng)
if roll + effective_skill < layer_def.hack_difficulty:
return False
tile.set_layer(layer, TileLayerInstance(layer_def.hackable_unlocked_def_id))
return True

View File

@ -5,14 +5,35 @@ 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."""
"""Every body part that's been lost (removed=True) bleeds at its def's bleeding_speed per
tick, and so does one with a causes_bleeding ailment (e.g. a synthetic's popped_fluid_hose -
see AilmentDef.causes_bleeding), scaled by the ailment's own severity. Either source is
silenced by bandaging the part (see BodyPart.bandaged / resources/logic/medical.py::
treat_bleeding) - a bandaged severed limb doesn't bleed, and neither does a bandaged wound.
"""
if ticks <= 0:
return
total_loss = 0.0
for part in character.body_parts:
if not part.removed:
if part.bandaged:
continue
part_def = registry.get_body_part_def(part.part_def_id)
if part.removed:
total_loss += part_def.bleeding_speed * ticks
continue
for ailment in part.ailments:
ailment_def = registry.get_ailment_def(ailment.id)
if ailment_def is not None and ailment_def.causes_bleeding:
total_loss += ailment_def.bleed_rate * ailment.severity * ticks
if total_loss:
character.blood_percentage = max(0.0, character.blood_percentage - total_loss)
def apply_boost_expiry_tick(character: Character, now: float) -> None:
"""Clears any Character.active_boosts (e.g. a consumed stimulant - see resources/logic/
abilities.py::cast_adrenablend) whose boost_expirations time has passed.
"""
expired = [stat for stat, expires_at in character.boost_expirations.items() if now >= expires_at]
for stat in expired:
character.active_boosts.pop(stat, None)
character.boost_expirations.pop(stat, None)

View File

@ -0,0 +1,39 @@
from __future__ import annotations
from engine.character import Character
from engine.defs import DefRegistry
def _is_bleeding(part, registry: DefRegistry) -> bool:
if part.removed:
return True
for ailment in part.ailments:
ailment_def = registry.get_ailment_def(ailment.id)
if ailment_def is not None and ailment_def.causes_bleeding:
return True
return False
def treat_bleeding(character: Character, slot: str, item_def_id: str, registry: DefRegistry) -> bool:
"""Applies a bandage (organic species) or sealant (synthetic) to stop a body part's
bleeding - whether from being severed or from a causes_bleeding ailment (see
resources/logic/health_ticks.py::apply_bleeding_tick). Returns whether it actually helped:
False (no-op) if `item_def_id` isn't a treatment item this character's species can use (see
SpeciesDef.health_item_compatibility), the part isn't bleeding at all, or it's already
bandaged.
"""
if character.species_id is None:
return False
species = registry.get_species_def(character.species_id)
if item_def_id not in species.health_item_compatibility:
return False
part = character.get_or_create_body_part_override(slot)
if part is None or part.bandaged:
return False
if not _is_bleeding(part, registry):
return False
part.bandaged = True
character.log_medical_event(f"Treated bleeding on {slot} with {item_def_id}")
return True

46
resources/logic/pickup.py Normal file
View File

@ -0,0 +1,46 @@
from __future__ import annotations
from engine.character import Character
from engine.defs import DefRegistry
from engine.world import World
def try_pickup(character: Character, world: World, registry: DefRegistry) -> int:
"""Picks up every ground item at `character`'s current tile that fits somewhere in their
inventory, leaving behind whatever doesn't (no free slot, or no inventory at all). Returns
how many items were actually picked up.
"""
if character.position is None or character.inventory is None:
return 0
pos = character.position
picked = 0
for item in world.ground_items_at(pos.map_id, pos.x, pos.y, pos.z):
item_def = registry.get_item_def(item.def_id)
slot = character.inventory.find_first_fit(item_def)
if slot is None:
continue
if character.inventory.place(item, item_def, *slot):
world.remove_ground_item(pos.map_id, pos.x, pos.y, pos.z, item.item_id)
picked += 1
return picked
def drop_item(
character: Character, world: World, item_id: str, def_id: str, inventory, registry: DefRegistry
) -> bool:
"""Removes `item_id` from `inventory` (the character's own base inventory, or a worn
container's granted inventory - see CharacterMenu.resolve_open_inventory) and places it on
the ground at `character`'s current position - the logic half of "drag it out of an open
inventory card to drop" (see engine/render/menu_draw.py for the drag hit-testing, main.py
for wiring the release event). Returns whether it actually happened (False if there's no
such item in `inventory`, or no inventory/position to work with at all).
"""
if character.position is None or inventory is None:
return False
item_def = registry.get_item_def(def_id)
removed = inventory.remove(item_id, item_def)
if removed is None:
return False
pos = character.position
world.drop_item_at(pos.map_id, pos.x, pos.y, pos.z, removed)
return True

View File

@ -0,0 +1,30 @@
from __future__ import annotations
from engine.ai import AIController
from engine.character import Character
from engine.defs import DefRegistry
from engine.entity import EntityPosition
from engine.inventory import Inventory
def spawn_npc(registry: DefRegistry, entity_def_id: str, position: EntityPosition) -> Character:
"""Builds an NPC Character from an entities.json template - the non-player counterpart to
main.py's equip_starting_loadout/spawn_new_character_into_world (which only ever build the
player). Fills the AI slot from the template's own ai_behavior_id if it names one (see
engine/ai.py::AIController, resources/logic/ai.py::run_ai_tick); a template with none leaves
the NPC inert (ai=None), same as the player. Does not add the result to a World - callers
still need world.add_entity(...) themselves, same as the player spawn path.
"""
entity_def = registry.get_entity_def(entity_def_id)
npc = Character(
name=entity_def.name,
def_id=entity_def.id,
position=position,
species_id=entity_def.species_id,
default_body_parts=registry.new_default_body_parts(entity_def.species_id),
default_clothing=registry.new_default_clothing(entity_def.id),
ai=AIController(entity_def.ai_behavior_id) if entity_def.ai_behavior_id is not None else None,
)
if entity_def.inventory_size is not None:
npc.inventory = Inventory(*entity_def.inventory_size)
return npc

156
resources/logic/turrets.py Normal file
View File

@ -0,0 +1,156 @@
from __future__ import annotations
import math
from engine.ai import AIController
from engine.aim import snap_to_8way
from engine.character import Character
from engine.defs import DefRegistry
from engine.entity import EntityPosition
from engine.world import World
from resources.logic.combat import DiceRoller, perform_shoot
TURRET_BEHAVIOR_ID = "turret"
DEFAULT_TURN_RATE_DEGREES_PER_TICK = 20.0
# The turret's own body parts (see resources/defs/body_parts.json's turret_* entries) - which
# ones gate which function. A missing/destroyed part (Character.get_body_part returns None,
# whether it was never there or was shot off) disables exactly that capability, nothing more:
# psu is the one part that gates everything else (no power, no turret at all).
def is_hostile(turret: Character, target: Character) -> bool:
"""Whether `target` is something `turret` should engage, per its targeting config (see
spawn_turret) - individual entity_id overrides win over faction membership, which wins over
the default of "not a target" (an untagged/unlisted character is left alone).
"""
assert turret.ai is not None
state = turret.ai.state
if target.entity_id in state.get("allied_entity_ids", ()):
return False
if target.entity_id in state.get("hostile_entity_ids", ()):
return True
target_factions = set(target.factions)
if target_factions & set(state.get("allied_factions", ())):
return False
if target_factions & set(state.get("hostile_factions", ())):
return True
return False
def find_closest_hostile(turret: Character, world: World, registry: DefRegistry) -> Character | None:
"""The nearest hostile Character on the turret's own map, within its weapon's range - "just
tries to follow the closest enemy" starts with picking which one that is. None if nothing
hostile is in range (or the turret has no weapon assigned at all).
"""
assert turret.position is not None and turret.ai is not None
weapon_item_id = turret.ai.state.get("weapon_item_id")
if weapon_item_id is None:
return None
max_range = registry.get_item_def(weapon_item_id).weapon_range
closest: Character | None = None
closest_distance = math.inf
for entity in world.entities_on_map(turret.position.map_id):
if not isinstance(entity, Character) or entity is turret or entity.position is None:
continue
if entity.position.z != turret.position.z:
continue
if not is_hostile(turret, entity):
continue
distance = math.hypot(entity.position.x - turret.position.x, entity.position.y - turret.position.y)
if distance > max_range:
continue
if distance < closest_distance:
closest, closest_distance = entity, distance
return closest
def rotate_toward(current_degrees: float, target_degrees: float, max_turn_degrees: float) -> float:
"""Turns `current_degrees` toward `target_degrees` by at most `max_turn_degrees`, via the
shorter direction around the circle - the turn-rate limit every turret is bound by.
"""
diff = (target_degrees - current_degrees + 180.0) % 360.0 - 180.0
if abs(diff) <= max_turn_degrees:
return target_degrees % 360.0
return (current_degrees + math.copysign(max_turn_degrees, diff)) % 360.0
def turret_ai_behavior(turret: Character, world: World, registry: DefRegistry, now: float, rng: DiceRoller) -> None:
"""A simple sentry AI: every tick, track whichever hostile is currently closest, turning to
face it at no more than the turret's own turn rate, and fire once a weapon and gun are both
available. Each of the turret's 5 body parts gates a specific capability - see the
module docstring-equivalent comment above - so shooting one off degrades the turret exactly
the way it looks like it should (shoot the camera, it goes blind; the motors, it freezes
facing whichever way it was last pointed; the gun, it keeps tracking but never fires again;
the psu, it goes completely dead).
"""
assert turret.ai is not None and turret.position is not None
state = turret.ai.state
if turret.get_body_part("psu") is None:
return
camera_ok = turret.get_body_part("camera") is not None
cpu_ok = turret.get_body_part("cpu") is not None
motors_ok = turret.get_body_part("motors") is not None
gun_ok = turret.get_body_part("gun") is not None
target = find_closest_hostile(turret, world, registry) if (camera_ok and cpu_ok) else None
state["target_entity_id"] = target.entity_id if target is not None else None
facing = state.get("facing_degrees", 0.0)
if target is not None and motors_ok:
assert target.position is not None
dx = target.position.x - turret.position.x
dy = target.position.y - turret.position.y
target_degrees = math.degrees(math.atan2(dy, dx)) % 360.0
turn_rate = state.get("turn_rate_degrees_per_tick", DEFAULT_TURN_RATE_DEGREES_PER_TICK)
facing = rotate_toward(facing, target_degrees, turn_rate)
state["facing_degrees"] = facing
if target is not None and gun_ok:
weapon_item_id = state.get("weapon_item_id")
if weapon_item_id is not None:
weapon_def = registry.get_item_def(weapon_item_id)
aim_dx, aim_dy = snap_to_8way(math.cos(math.radians(facing)), math.sin(math.radians(facing)))
perform_shoot(turret, world, registry, weapon_def, (aim_dx, aim_dy, 0), rng=rng)
def spawn_turret(
registry: DefRegistry,
entity_def_id: str,
position: EntityPosition,
hostile_factions: tuple[str, ...] = (),
allied_factions: tuple[str, ...] = (),
hostile_entity_ids: tuple[str, ...] = (),
allied_entity_ids: tuple[str, ...] = (),
) -> Character:
"""Builds a stationary turret from a turret entity template (see entities.json's
turret_pistol/turret_autocannon) at `position`, with its AI slot (see engine/ai.py::
AIController) preloaded to run turret_ai_behavior and its targeting rules set from the
given faction/individual lists (see is_hostile) - callers still need world.add_entity(...)
themselves, same as every other spawn helper.
"""
entity_def = registry.get_entity_def(entity_def_id)
turn_rate = entity_def.turret_turn_rate_degrees_per_tick
return Character(
name=entity_def.name,
def_id=entity_def.id,
position=position,
species_id=entity_def.species_id,
default_body_parts=registry.new_default_body_parts(entity_def.species_id),
ai=AIController(
behavior_id=TURRET_BEHAVIOR_ID,
state={
"weapon_item_id": entity_def.turret_weapon_item_id,
"turn_rate_degrees_per_tick": turn_rate if turn_rate is not None else DEFAULT_TURN_RATE_DEGREES_PER_TICK,
"facing_degrees": 0.0,
"target_entity_id": None,
"hostile_factions": list(hostile_factions),
"allied_factions": list(allied_factions),
"hostile_entity_ids": list(hostile_entity_ids),
"allied_entity_ids": list(allied_entity_ids),
},
),
)

View File

@ -139,3 +139,54 @@ def test_deconstructor_out_of_bounds_target_is_a_no_op(registry):
world.add_entity(character)
assert activate_hand(character, world, registry, "right_hand", (-1, 0, 0)) is None
def test_wielded_hacking_device_hacks_a_locked_door_ahead(registry):
world, field = make_world(registry)
field.get_tile(6, 5, 0).room = TileLayerInstance("door_locked")
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
character.wield_item("right_hand", "hacking_device", registry)
character.bio["intelligence"] = 10
character.bio["perception"] = 10
world.add_entity(character)
result = activate_hand(character, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=10))
assert result is True
assert field.get_tile(6, 5, 0).room.def_id == "door_open"
def test_wielded_hacking_device_fails_a_low_roll_and_leaves_the_door_locked(registry):
world, field = make_world(registry)
field.get_tile(6, 5, 0).room = TileLayerInstance("door_locked")
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
character.wield_item("right_hand", "hacking_device", registry)
world.add_entity(character)
result = activate_hand(character, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=1))
assert result is False
assert field.get_tile(6, 5, 0).room.def_id == "door_locked"
def test_bare_hand_hacks_a_door_when_a_hacking_implant_is_installed(registry):
world, field = make_world(registry)
field.get_tile(6, 5, 0).room = TileLayerInstance("door_locked")
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
character.install_implant("cyberdeck_the_miro_model_3a", registry)
world.add_entity(character)
result = activate_hand(character, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=1))
assert result is True
assert field.get_tile(6, 5, 0).room.def_id == "door_open"
def test_bare_hand_without_a_hacking_implant_does_not_touch_a_locked_door(registry):
world, field = make_world(registry)
field.get_tile(6, 5, 0).room = TileLayerInstance("door_locked")
character = make_human(registry, position=EntityPosition("field", 5, 5, 0))
world.add_entity(character)
assert activate_hand(character, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=20)) is None
assert field.get_tile(6, 5, 0).room.def_id == "door_locked"

107
tests/test_adrenablend.py Normal file
View File

@ -0,0 +1,107 @@
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.map import Map
from engine.tile import Tile, TileLayerInstance
from engine.world import World
from resources.logic.abilities import (
ADRENABLEND_BRAIN_FOG_SEVERITY,
ADRENABLEND_SPEED_MULTIPLIER,
ADRENABLEND_STRENGTH_MULTIPLIER,
cast_adrenablend,
)
from resources.logic.actions import activate_hand
from resources.logic.contagion import has_ailment
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", 5, 5, 1)
for x in range(5):
for y in range(5):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
world = World(registry=registry)
world.add_map(field)
return world
def make_human(registry, **kwargs) -> Character:
return Character(
species_id="human", position=EntityPosition("field", 2, 2, 0),
default_body_parts=registry.new_default_body_parts("human"), **kwargs
)
# --- cast_adrenablend --------------------------------------------------------------------------
def test_cast_adrenablend_boosts_strength_and_speed(registry):
human = make_human(registry)
cast_adrenablend(human, World(registry=registry), now=10.0)
assert human.active_boosts["strength"] == ADRENABLEND_STRENGTH_MULTIPLIER
assert human.active_boosts["speed"] == ADRENABLEND_SPEED_MULTIPLIER
assert human.effective_strength(registry) > 10.0 * (1.0) # baseline strength=10, boosted
def test_cast_adrenablend_sets_an_expiration_after_now(registry):
human = make_human(registry)
cast_adrenablend(human, World(registry=registry), now=10.0, duration=20.0)
assert human.boost_expirations["strength"] == 30.0
assert human.boost_expirations["speed"] == 30.0
def test_cast_adrenablend_causes_brain_fog(registry):
human = make_human(registry)
cast_adrenablend(human, World(registry=registry), now=0.0)
assert has_ailment(human, "brain_fog")
head = human.get_body_part("head")
fog = next(a for a in head.ailments if a.id == "brain_fog")
assert fog.severity == ADRENABLEND_BRAIN_FOG_SEVERITY
def test_repeated_casts_refresh_rather_than_stack_brain_fog(registry):
human = make_human(registry)
cast_adrenablend(human, World(registry=registry), now=0.0)
cast_adrenablend(human, World(registry=registry), now=1.0)
head = human.get_body_part("head")
matching = [a for a in head.ailments if a.id == "brain_fog"]
assert len(matching) == 1
# --- consumable item wiring via activate_hand --------------------------------------------------
def test_activating_a_consumable_ability_item_unwields_it(registry):
world = make_world(registry)
human = make_human(registry)
human.inventory = Inventory(4, 4)
human.wield_item("right_hand", "adrenablend", registry)
world.add_entity(human)
result = activate_hand(human, world, registry, "right_hand", (0, 1, 0), now=5.0)
assert result is human
assert human.get_held_item("right_hand") is None
assert human.active_boosts["strength"] == ADRENABLEND_STRENGTH_MULTIPLIER
def test_activating_a_non_consumable_ability_item_stays_wielded(registry):
world = make_world(registry)
human = make_human(registry)
human.wield_item("right_hand", "chronowand", registry)
world.add_entity(human)
activate_hand(human, world, registry, "right_hand", (0, 1, 0), now=5.0)
assert human.get_held_item("right_hand") is not None

122
tests/test_ai.py Normal file
View File

@ -0,0 +1,122 @@
from pathlib import Path
import pytest
from engine.ai import AIController
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.ai import run_ai_tick
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", 5, 5, 1)
for x in range(5):
for y in range(5):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
world = World(registry=registry)
world.add_map(field)
return world
def make_human(registry, **kwargs) -> Character:
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
class FixedRng:
"""A random.Random look-alike that always picks the same direction/interval."""
def __init__(self, choice_index: int = 0, uniform_value: float = 0.5):
self.choice_index = choice_index
self.uniform_value = uniform_value
def randint(self, a, b):
return a
def choice(self, seq):
return seq[self.choice_index]
def uniform(self, a, b):
return a + (b - a) * self.uniform_value
def test_aicontroller_defaults_to_empty_scratch_state():
controller = AIController(behavior_id="wander")
assert controller.state == {}
def test_two_aicontrollers_have_independent_state_dicts():
a = AIController(behavior_id="wander")
b = AIController(behavior_id="wander")
a.state["next_move_at"] = 5.0
assert b.state == {}
def test_run_ai_tick_is_a_noop_without_an_ai_slot(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
world.add_entity(human)
run_ai_tick(human, world, registry, now=0.0, rng=FixedRng())
assert human.ai is None
assert (human.position.x, human.position.y) == (2, 2)
def test_run_ai_tick_is_a_noop_for_an_unregistered_behavior_id(registry):
world = make_world(registry)
human = make_human(
registry, position=EntityPosition("field", 2, 2, 0), ai=AIController(behavior_id="does_not_exist")
)
world.add_entity(human)
run_ai_tick(human, world, registry, now=0.0, rng=FixedRng())
assert (human.position.x, human.position.y) == (2, 2)
def test_wander_moves_the_character_on_its_first_tick(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0), ai=AIController(behavior_id="wander"))
world.add_entity(human)
run_ai_tick(human, world, registry, now=0.0, rng=FixedRng(choice_index=1)) # (1, 0)
assert (human.position.x, human.position.y) == (3, 2)
assert human.ai.state["next_move_at"] > 0.0
def test_wander_does_nothing_before_its_cooldown_elapses(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0), ai=AIController(behavior_id="wander"))
world.add_entity(human)
run_ai_tick(human, world, registry, now=0.0, rng=FixedRng(choice_index=1))
moved_to = (human.position.x, human.position.y)
run_ai_tick(human, world, registry, now=0.1, rng=FixedRng(choice_index=0)) # still on cooldown
assert (human.position.x, human.position.y) == moved_to
def test_wander_moves_again_once_its_cooldown_expires(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0), ai=AIController(behavior_id="wander"))
world.add_entity(human)
run_ai_tick(human, world, registry, now=0.0, rng=FixedRng(choice_index=1)) # -> (3, 2)
next_move_at = human.ai.state["next_move_at"]
run_ai_tick(human, world, registry, now=next_move_at, rng=FixedRng(choice_index=3)) # (0, 1)
assert (human.position.x, human.position.y) == (3, 3)

View File

@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from engine.character_creator import PLAYER_SELECTABLE_SPECIES, CharacterCreator
from engine.character_creator import NPC_SELECTABLE_SPECIES, PLAYER_SELECTABLE_SPECIES, CharacterCreator
from engine.character_library import CharacterLibrary
from engine.defs import DefRegistry
@ -46,7 +46,7 @@ def test_dog_is_not_a_player_selectable_species(registry):
def test_available_species_excludes_dog(registry):
creator = CharacterCreator(registry)
assert "dog" not in creator.available_species()
assert set(creator.available_species()) == {"human", "reptilian_quad", "alien_tri", "bot"}
assert set(creator.available_species()) == {"human", "reptilian_quad", "alien_tri", "hydraulibot", "motorbot"}
def test_selectable_species_is_configurable(registry):
@ -55,6 +55,12 @@ def test_selectable_species_is_configurable(registry):
assert creator.set_species("human") is False
def test_npc_selectable_species_includes_dog(registry):
creator = CharacterCreator(registry, selectable_species=NPC_SELECTABLE_SPECIES)
assert creator.set_species("dog") is True
assert set(creator.available_species()) == set(PLAYER_SELECTABLE_SPECIES) | {"dog"}
def test_set_gender_accepts_a_valid_option(registry):
creator = CharacterCreator(registry)
creator.set_species("human")
@ -79,7 +85,7 @@ 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
creator.set_species("hydraulibot") # monogender, "none" - "nonbinary" isn't valid here
assert creator.gender == "none"
@ -131,7 +137,7 @@ def test_set_hair_color_accepts_a_valid_option(registry):
def test_set_hair_color_rejects_invalid_option_for_species_with_no_hair(registry):
creator = CharacterCreator(registry)
creator.set_species("bot")
creator.set_species("hydraulibot")
assert creator.set_hair_color("black") is False
assert creator.hair_color is None
@ -146,8 +152,8 @@ def test_switching_species_resets_body_type_and_hair_color_if_no_longer_valid(re
creator.set_species("human")
creator.set_body_type("hulk")
creator.set_hair_color("red")
creator.set_species("bot") # no body_type_options overlap, no hair at all
assert creator.body_type == "compact" # bot's first body_type option
creator.set_species("hydraulibot") # no body_type_options overlap, no hair at all
assert creator.body_type == "compact" # hydraulibot's first body_type option
assert creator.hair_color is None
@ -378,6 +384,19 @@ def test_random_mode_is_deterministic_given_the_same_rng_seed(registry):
assert a.bio == b.bio
def test_intelligence_is_a_directly_buyable_bio_skill(registry):
creator = CharacterCreator(registry)
assert "intelligence" in creator.bio
assert creator.increase_skill("intelligence") is True
assert creator.bio["intelligence"] == 1
def test_tech_is_not_in_the_bio_skill_list(registry):
creator = CharacterCreator(registry)
assert "tech" not in creator.bio
assert creator.increase_skill("tech") is False
# --- body part preview ------------------------------------------------------------------------
@ -442,6 +461,56 @@ def test_build_character_includes_starter_clothing(registry):
assert character.get_clothing("back").item_def_id == "dog_saddlebag"
# --- secret items password -------------------------------------------------------------------
def test_the_miro_password_grants_the_secret_cyberdeck(registry):
creator = CharacterCreator(registry)
creator.set_species("human")
creator.set_name("Vex")
creator.set_secret_password("The_miro")
character = creator.build_character()
assert character is not None
assert "cyberdeck_the_miro_model_3a" in character.implants
def test_password_match_is_case_sensitive(registry):
creator = CharacterCreator(registry)
creator.set_species("human")
creator.set_name("Vex")
creator.set_secret_password("the_miro")
character = creator.build_character()
assert character is not None
assert character.implants == []
def test_wrong_password_grants_nothing(registry):
creator = CharacterCreator(registry)
creator.set_species("human")
creator.set_name("Vex")
creator.set_secret_password("hunter2")
character = creator.build_character()
assert character is not None
assert character.implants == []
def test_empty_password_grants_nothing(registry):
creator = CharacterCreator(registry)
creator.set_species("human")
creator.set_name("Vex")
character = creator.build_character()
assert character is not None
assert character.implants == []
# --- confirm: saves into the library -------------------------------------------------------------

View File

@ -22,6 +22,55 @@ def make_human(registry, **kwargs) -> Character:
)
def test_blood_label_defaults_to_blood(registry):
human = make_human(registry)
assert human.blood_label(registry) == "Blood"
def test_factions_default_to_an_empty_list(registry):
human = make_human(registry)
assert human.factions == []
def test_factions_can_be_set_at_construction(registry):
human = make_human(registry, factions=["player", "crew"])
assert human.factions == ["player", "crew"]
def test_blood_label_is_blood_with_no_species(registry):
assert Character().blood_label(registry) == "Blood"
def test_species_penalty_zero_by_default(registry):
human = make_human(registry)
assert human.species_penalty("sleight_of_hand", registry) == 0.0
def test_species_penalty_zero_with_no_species(registry):
assert Character().species_penalty("sleight_of_hand", registry) == 0.0
def test_hydraulibot_blood_label_is_synthmuscle_fluid(registry):
hydraulibot = Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"))
assert hydraulibot.blood_label(registry) == "Synthmuscle-Fluid"
def test_motorbot_has_an_innate_sleight_of_hand_penalty(registry):
motorbot = Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"))
assert motorbot.species_penalty("sleight_of_hand", registry) == pytest.approx(0.4)
def test_motorbot_innate_penalty_reduces_total_check_penalty_even_when_healthy(registry):
motorbot = Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"))
# fully healthy - only the innate species penalty should show up
assert motorbot.total_check_penalty("sleight_of_hand", registry) == pytest.approx(0.4)
def test_motorbot_has_no_innate_penalty_for_an_unrelated_check(registry):
motorbot = Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"))
assert motorbot.species_penalty("perception", registry) == 0.0
def test_blood_status_thresholds(registry):
species = registry.get_species_def("human")
character = make_human(registry, blood_percentage=100.0)

View File

@ -87,7 +87,7 @@ def test_roundtrip_preserves_body_parts_ailments_and_organs(registry, library):
# 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"}
assert {o.organ_def_id for o in head.organs} == {"reptilian_brain", "reptilian_eyes", "reptilian_ears"}
def test_roundtrip_preserves_clothing_and_its_granted_inventory(registry, library):

View File

@ -35,6 +35,25 @@ def test_select_tab_ignores_unknown_tab_names():
assert menu.active_tab == "bio"
def test_cycle_tab_advances_through_every_tab_in_order():
menu = CharacterMenu()
seen = [menu.active_tab]
for _ in range(len(CharacterMenu.TABS)):
menu.cycle_tab()
seen.append(menu.active_tab)
assert seen == ["bio", "equipment", "medical", "bio"]
def test_cycle_tab_closes_any_open_backpack_popup(registry):
menu = CharacterMenu()
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
menu.select_tab("equipment")
menu.right_click_equipment_slot("back", character, registry)
assert menu.open_backpack_slot == "back"
menu.cycle_tab()
assert menu.open_backpack_slot is None
def test_switching_tabs_closes_any_open_backpack_popup(registry):
menu = CharacterMenu()
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
@ -92,3 +111,56 @@ def test_close_backpack_popup_clears_it():
menu = CharacterMenu(open_backpack_slot="back")
menu.close_backpack_popup()
assert menu.open_backpack_slot is None
# --- resolve_open_inventory / toggle_backpack (POCKETS_SLOT + real containers) ----------------
def test_resolve_open_inventory_is_none_when_nothing_is_open(registry):
menu = CharacterMenu()
character = make_human(registry)
assert menu.resolve_open_inventory(character, registry) is None
def test_resolve_open_inventory_returns_the_characters_own_inventory_for_pockets(registry):
from engine.inventory import Inventory
menu = CharacterMenu(open_backpack_slot=CharacterMenu.POCKETS_SLOT)
character = make_human(registry)
character.inventory = Inventory(4, 4)
assert menu.resolve_open_inventory(character, registry) is character.inventory
def test_resolve_open_inventory_returns_a_worn_containers_inventory(registry):
menu = CharacterMenu(open_backpack_slot="back")
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
resolved = menu.resolve_open_inventory(character, registry)
assert resolved is character.get_equipped_inventory("back", registry)
def test_toggle_backpack_opens_and_closes_pockets(registry):
from engine.inventory import Inventory
menu = CharacterMenu()
character = make_human(registry)
character.inventory = Inventory(4, 4)
assert menu.toggle_backpack(CharacterMenu.POCKETS_SLOT, character, registry) is True
assert menu.open_backpack_slot == CharacterMenu.POCKETS_SLOT
assert menu.toggle_backpack(CharacterMenu.POCKETS_SLOT, character, registry) is False
assert menu.open_backpack_slot is None
def test_toggle_backpack_is_a_no_op_for_pockets_without_an_inventory(registry):
menu = CharacterMenu()
character = make_human(registry)
assert menu.toggle_backpack(CharacterMenu.POCKETS_SLOT, character, registry) is False
assert menu.open_backpack_slot is None
def test_toggle_backpack_delegates_to_right_click_for_real_slots(registry):
menu = CharacterMenu()
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
assert menu.toggle_backpack("back", character, registry) is True
assert menu.open_backpack_slot == "back"

155
tests/test_contagion.py Normal file
View File

@ -0,0 +1,155 @@
import random
from pathlib import Path
import pytest
from engine.character import Character
from engine.defs import DefRegistry
from engine.entity import EntityPosition
from engine.map import Map
from engine.tile import Tile, TileLayerInstance
from engine.world import World
from resources.logic.contagion import apply_flu_contagion_tick, contract_flu, has_ailment, is_flu_susceptible
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
@pytest.fixture()
def registry():
return DefRegistry.load(DEFS_DIR)
def make_character(registry, species_id, **kwargs) -> Character:
return Character(species_id=species_id, default_body_parts=registry.new_default_body_parts(species_id), **kwargs)
def make_world(registry):
field = Map("field", 20, 20, 1)
for x in range(20):
for y in range(20):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
world = World(registry=registry)
world.add_map(field)
return world
class AlwaysRng(random.Random):
def random(self):
return 0.0
class NeverRng(random.Random):
def random(self):
return 1.0
# --- species restriction --------------------------------------------------------------------
def test_human_is_susceptible(registry):
assert is_flu_susceptible(make_character(registry, "human")) is True
def test_reptilian_is_susceptible(registry):
assert is_flu_susceptible(make_character(registry, "reptilian_quad")) is True
def test_hydraulibot_is_immune(registry):
assert is_flu_susceptible(make_character(registry, "hydraulibot")) is False
def test_motorbot_is_immune(registry):
assert is_flu_susceptible(make_character(registry, "motorbot")) is False
def test_zenari_is_not_susceptible(registry):
assert is_flu_susceptible(make_character(registry, "alien_tri")) is False
def test_dog_is_not_susceptible(registry):
assert is_flu_susceptible(make_character(registry, "dog")) is False
# --- contract_flu applies both symptoms -------------------------------------------------------
def test_contract_flu_gives_both_symptoms(registry):
human = make_character(registry, "human")
assert contract_flu(human) is True
assert has_ailment(human, "flu")
assert has_ailment(human, "brain_fog")
def test_contract_flu_is_a_no_op_for_an_immune_species(registry):
hydraulibot = make_character(registry, "hydraulibot")
assert contract_flu(hydraulibot) is False
assert not has_ailment(hydraulibot, "flu")
def test_contract_flu_is_a_no_op_if_already_infected(registry):
human = make_character(registry, "human")
contract_flu(human)
assert contract_flu(human) is False
# --- proximity-based tick spread ---------------------------------------------------------------
def test_nearby_susceptible_character_can_catch_it(registry):
world = make_world(registry)
sick = make_character(registry, "human", position=EntityPosition("field", 5, 5, 0))
contract_flu(sick)
world.add_entity(sick)
healthy = make_character(registry, "human", position=EntityPosition("field", 6, 5, 0))
world.add_entity(healthy)
apply_flu_contagion_tick(world, ticks=1, rng=AlwaysRng())
assert has_ailment(healthy, "flu")
def test_far_away_character_does_not_catch_it(registry):
world = make_world(registry)
sick = make_character(registry, "human", position=EntityPosition("field", 0, 0, 0))
contract_flu(sick)
world.add_entity(sick)
healthy = make_character(registry, "human", position=EntityPosition("field", 19, 19, 0))
world.add_entity(healthy)
apply_flu_contagion_tick(world, ticks=1, rng=AlwaysRng())
assert not has_ailment(healthy, "flu")
def test_low_roll_chance_means_no_guaranteed_contraction(registry):
world = make_world(registry)
sick = make_character(registry, "human", position=EntityPosition("field", 5, 5, 0))
contract_flu(sick)
world.add_entity(sick)
healthy = make_character(registry, "human", position=EntityPosition("field", 6, 5, 0))
world.add_entity(healthy)
apply_flu_contagion_tick(world, ticks=1, rng=NeverRng())
assert not has_ailment(healthy, "flu")
def test_hydraulibot_never_catches_it_even_next_to_a_sick_human(registry):
world = make_world(registry)
sick = make_character(registry, "human", position=EntityPosition("field", 5, 5, 0))
contract_flu(sick)
world.add_entity(sick)
hydraulibot = make_character(registry, "hydraulibot", position=EntityPosition("field", 6, 5, 0))
world.add_entity(hydraulibot)
apply_flu_contagion_tick(world, ticks=1, rng=AlwaysRng())
assert not has_ailment(hydraulibot, "flu")
def test_no_infected_characters_is_a_no_op(registry):
world = make_world(registry)
healthy = make_character(registry, "human", position=EntityPosition("field", 5, 5, 0))
world.add_entity(healthy)
apply_flu_contagion_tick(world, ticks=1, rng=AlwaysRng())
assert not has_ailment(healthy, "flu")

View File

@ -66,6 +66,59 @@ def test_entity_def_references_species_and_inventory_size(registry):
assert player.default_clothing[0].slot == "back"
def test_door_locked_tile_layer_def_parses_hack_fields(registry):
door = registry.get_tile_layer_def("room", "door_locked")
assert door.hack_difficulty == 12
assert door.hackable_unlocked_def_id == "door_open"
assert door.walkable is False
def test_wood_wall_tile_layer_def_is_not_hackable_by_default(registry):
wall = registry.get_tile_layer_def("room", "wood_wall")
assert wall.hack_difficulty is None
assert wall.hackable_unlocked_def_id is None
def test_turret_species_has_no_blood_and_synthetic_flag(registry):
turret = registry.get_species_def("turret")
assert turret.is_synthetic is True
assert turret.blood_label == "Coolant"
assert turret.blood_danger_threshold == 0.0
def test_turret_species_default_body_parts_are_the_five_turret_slots(registry):
turret = registry.get_species_def("turret")
slots = {bp.slot for bp in turret.default_body_parts}
assert slots == {"psu", "cpu", "motors", "gun", "camera"}
def test_turret_pistol_entity_def_parses_turret_fields(registry):
entity_def = registry.get_entity_def("turret_pistol")
assert entity_def.species_id == "turret"
assert entity_def.turret_weapon_item_id == "pistol_basic"
assert entity_def.turret_turn_rate_degrees_per_tick == 30
def test_turret_autocannon_entity_def_parses_turret_fields(registry):
entity_def = registry.get_entity_def("turret_autocannon")
assert entity_def.turret_weapon_item_id == "turret_autocannon"
assert entity_def.turret_turn_rate_degrees_per_tick == 12
def test_non_turret_entity_def_has_no_turret_fields(registry):
player = registry.get_entity_def("player")
assert player.turret_weapon_item_id is None
assert player.turret_turn_rate_degrees_per_tick is None
def test_entity_def_parses_its_ai_behavior_id(registry):
assert registry.get_entity_def("npc_reptilian").ai_behavior_id == "wander"
def test_entity_def_ai_behavior_id_defaults_to_none(registry):
assert registry.get_entity_def("player").ai_behavior_id is None
def test_item_def_clothing_fields(registry):
backpack = registry.get_item_def("backpack_basic")
assert backpack.clothing_slot == "back"

View File

@ -23,12 +23,18 @@ def make_human(registry, **kwargs) -> Character:
# --- DevSpawnMenu -------------------------------------------------------------------------------
def test_menu_defaults_to_every_loaded_item_def_sorted(registry):
def test_menu_defaults_to_every_loaded_non_secret_item_def_sorted(registry):
menu = DevSpawnMenu(registry)
assert menu.item_ids == sorted(registry.item_defs)
expected = sorted(def_id for def_id, item_def in registry.item_defs.items() if not item_def.secret)
assert menu.item_ids == expected
assert menu.selected_item_id() == menu.item_ids[0]
def test_menu_excludes_secret_items(registry):
menu = DevSpawnMenu(registry)
assert "cyberdeck_the_miro_model_3a" not in menu.item_ids
def test_cycle_wraps_around(registry):
menu = DevSpawnMenu(registry)
menu.cycle(-1)

210
tests/test_falling.py Normal file
View File

@ -0,0 +1,210 @@
from pathlib import Path
import pytest
from engine.character import Character
from engine.defs import DefRegistry
from resources.logic.falling import (
ACROBATICS_CHECK_MIN_FALL,
CHECK_CLEAN_LANDING_DC,
CHECK_TENDON_DC,
LEG_SLOTS,
LOW_FALL_SPRAIN_CHANCE,
SEVERE_BROKEN_BONE_CHANCE,
SEVERE_FALL_HEIGHT,
SEVERE_FALL_HEIGHT_SKILLED,
apply_fall_damage,
)
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_hydraulibot(registry, **kwargs) -> Character:
return Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"), **kwargs)
def make_motorbot(registry, **kwargs) -> Character:
return Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"), **kwargs)
class FixedRng:
"""A random.Random look-alike with independently-fixed outputs for every method
apply_fall_damage/roll_skill_check might call.
"""
def __init__(self, randint_value: int = 10, choice_index: int = 0, uniform_value: float = 0.5):
self.randint_value = randint_value
self.choice_index = choice_index
self.uniform_value = uniform_value
def randint(self, a, b):
return self.randint_value
def choice(self, seq):
return seq[self.choice_index]
def uniform(self, a, b):
return a + (b - a) * self.uniform_value
def any_leg_has(character: Character, ailment_id: str) -> bool:
return any(
any(a.id == ailment_id for a in part.ailments)
for slot in LEG_SLOTS
if (part := character.get_body_part(slot)) is not None
)
# --- height 0: never fires -----------------------------------------------------------------
def test_no_fall_no_ailment(registry):
human = make_human(registry)
assert apply_fall_damage(human, registry, 0, FixedRng()) is None
# --- heights 1-2: flat rare chance, sprain only --------------------------------------------
def test_low_fall_below_chance_threshold_causes_a_sprain(registry):
human = make_human(registry)
rng = FixedRng(uniform_value=(LOW_FALL_SPRAIN_CHANCE / 2) / 1.0) # well under the chance
result = apply_fall_damage(human, registry, 1, rng)
assert result == "sprained_ankle"
assert any_leg_has(human, "sprained_ankle")
def test_low_fall_above_chance_threshold_causes_nothing(registry):
human = make_human(registry)
rng = FixedRng(uniform_value=0.99) # well over the chance
assert apply_fall_damage(human, registry, 2, rng) is None
# --- heights 3+ (below severe threshold): acrobatics check tiers ----------------------------
def test_acrobatics_check_clean_landing(registry):
human = make_human(registry)
human.bio["acrobatics"] = 0
rng = FixedRng(randint_value=CHECK_CLEAN_LANDING_DC)
assert apply_fall_damage(human, registry, ACROBATICS_CHECK_MIN_FALL, rng) is None
def test_acrobatics_check_mid_roll_causes_ripped_tendon(registry):
human = make_human(registry)
human.bio["acrobatics"] = 0
rng = FixedRng(randint_value=CHECK_TENDON_DC + 1) # lands in the tendon band
result = apply_fall_damage(human, registry, ACROBATICS_CHECK_MIN_FALL, rng)
assert result == "ripped_tendon"
def test_acrobatics_check_low_roll_causes_broken_bone(registry):
human = make_human(registry)
human.bio["acrobatics"] = 0
rng = FixedRng(randint_value=1)
result = apply_fall_damage(human, registry, ACROBATICS_CHECK_MIN_FALL, rng)
assert result == "broken_bone"
# --- severe heights: no check, flat 90/10 roll ----------------------------------------------
def test_severe_fall_mostly_breaks_a_bone(registry):
human = make_human(registry)
human.bio["acrobatics"] = 0
rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2))
result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng)
assert result == "broken_bone"
def test_severe_fall_sometimes_rips_a_tendon_instead(registry):
human = make_human(registry)
human.bio["acrobatics"] = 0
rng = FixedRng(uniform_value=0.99) # above SEVERE_BROKEN_BONE_CHANCE
result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng)
assert result == "ripped_tendon"
def test_severe_fall_never_results_in_just_a_sprain(registry):
human = make_human(registry)
human.bio["acrobatics"] = 0
for uniform_value in (0.0, 0.5, 0.99):
result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, FixedRng(uniform_value=uniform_value))
assert result in ("broken_bone", "ripped_tendon")
def test_skilled_acrobat_gets_the_extended_threshold(registry):
# a skilled acrobat isn't subject to the flat severe roll until height 6, not 5 - height 5
# should still go through the tiered check instead
human = make_human(registry)
human.bio["acrobatics"] = 3 # 60% of DEFAULT_MAX_SKILL_LEVEL (5)
rng = FixedRng(randint_value=CHECK_CLEAN_LANDING_DC)
assert apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng) is None
assert SEVERE_FALL_HEIGHT < SEVERE_FALL_HEIGHT_SKILLED
def test_skilled_acrobat_still_hits_the_severe_roll_one_level_higher(registry):
human = make_human(registry)
human.bio["acrobatics"] = 3
rng = FixedRng(uniform_value=0.0)
result = apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT_SKILLED, rng)
assert result == "broken_bone"
# --- synthetic renames -----------------------------------------------------------------------
def test_hydraulibot_gets_renamed_ailments(registry):
hydraulibot = make_hydraulibot(registry)
rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2))
result = apply_fall_damage(hydraulibot, registry, SEVERE_FALL_HEIGHT, rng)
assert result == "broken_frame_piece"
assert any_leg_has(hydraulibot, "broken_frame_piece")
assert not any_leg_has(hydraulibot, "broken_bone")
def test_hydraulibot_ripped_tendon_becomes_popped_fluid_hose(registry):
hydraulibot = make_hydraulibot(registry)
rng = FixedRng(uniform_value=0.99)
result = apply_fall_damage(hydraulibot, registry, SEVERE_FALL_HEIGHT, rng)
assert result == "popped_fluid_hose"
def test_motorbot_also_gets_renamed_ailments(registry):
motorbot = make_motorbot(registry)
rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2))
result = apply_fall_damage(motorbot, registry, SEVERE_FALL_HEIGHT, rng)
assert result == "broken_frame_piece"
# --- no legs: no-op ---------------------------------------------------------------------------
def test_no_legs_no_ailment(registry):
legless = Character(species_id="human", default_body_parts=[])
rng = FixedRng(uniform_value=0.0)
assert apply_fall_damage(legless, registry, SEVERE_FALL_HEIGHT, rng) is None
# --- refreshing an existing ailment doesn't duplicate it --------------------------------------
def test_repeated_falls_refresh_rather_than_duplicate(registry):
human = make_human(registry)
rng = FixedRng(uniform_value=(SEVERE_BROKEN_BONE_CHANCE / 2))
apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng)
apply_fall_damage(human, registry, SEVERE_FALL_HEIGHT, rng)
matching_parts = [
part for slot in LEG_SLOTS if (part := human.get_body_part(slot)) is not None
for a in part.ailments if a.id == "broken_bone"
]
assert len(matching_parts) == 1

View File

@ -100,8 +100,10 @@ def test_advance_on_name_step_requires_a_non_empty_name(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
flow.advance()
assert flow.advance() is False
assert flow.step == "name"
flow.type_char("V")
assert flow.advance() is True
assert flow.advance() is False # moves on to the password step, not finished yet
assert flow.step == "password"
def test_typing_is_capped_at_max_name_length(registry):
@ -122,3 +124,56 @@ def test_go_back_from_name_returns_to_species(registry):
def test_go_back_from_species_reports_theres_nowhere_further_back(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
assert flow.go_back() is False
def _advance_to_password_step(flow: CharacterCreationFlow, name: str = "Vex") -> None:
flow.advance() # species -> name
for ch in name:
flow.type_char(ch)
flow.advance() # name -> password
def test_advance_from_name_moves_to_password_step_without_finishing(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
_advance_to_password_step(flow)
assert flow.step == "password"
def test_advance_on_password_step_finishes_even_with_no_password_typed(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
_advance_to_password_step(flow)
assert flow.advance() is True
def test_typing_during_password_step_updates_the_creator(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
_advance_to_password_step(flow)
flow.type_char("T")
flow.type_char("h")
assert flow.creator.secret_password == "Th"
assert flow.creator.name == "Vex" # unaffected by typing during the password step
def test_backspace_during_password_step_only_affects_the_password(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
_advance_to_password_step(flow)
flow.type_char("T")
flow.type_char("h")
flow.backspace()
assert flow.creator.secret_password == "T"
assert flow.creator.name == "Vex"
def test_typing_during_password_step_is_capped_at_max_name_length(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
_advance_to_password_step(flow)
for ch in "x" * 30:
flow.type_char(ch)
assert len(flow.creator.secret_password) == 20
def test_go_back_from_password_returns_to_name(registry):
flow = CharacterCreationFlow(CharacterCreator(registry))
_advance_to_password_step(flow)
assert flow.go_back() is True
assert flow.step == "name"

View File

@ -74,3 +74,23 @@ def test_gravity_does_not_fire_on_a_single_level_map(registry):
walker = Entity(position=EntityPosition("flat", 0, 0, 0))
assert world.try_move(walker, 1, 0, 0) is True
assert (walker.position.x, walker.position.z) == (1, 0) # stays put, no lower floor exists
def test_consume_fall_distance_reports_how_far_and_then_resets(registry):
world = make_cliff_world(registry)
walker = Entity(position=EntityPosition("cliff", 0, 0, 2))
assert world.consume_fall_distance(walker.entity_id) == 0 # nothing yet
world.try_move(walker, 1, 0, 0)
assert world.consume_fall_distance(walker.entity_id) == 2 # dropped from z=2 to z=0
assert world.consume_fall_distance(walker.entity_id) == 0 # one-shot, already consumed
def test_consume_fall_distance_is_zero_when_no_fall_happens(registry):
m = Map("level", 2, 1, 1)
m.set_tile(0, 0, 0, Tile(flooring=TileLayerInstance("dirt")))
m.set_tile(1, 0, 0, Tile(flooring=TileLayerInstance("dirt")))
world = World(registry=registry)
world.add_map(m)
walker = Entity(position=EntityPosition("level", 0, 0, 0))
world.try_move(walker, 1, 0, 0)
assert world.consume_fall_distance(walker.entity_id) == 0

View File

@ -0,0 +1,67 @@
from engine.item import ItemInstance
from engine.world import World
def make_world():
return World()
def test_ground_items_at_is_empty_by_default():
world = make_world()
assert world.ground_items_at("field", 3, 4, 0) == []
def test_drop_item_at_makes_it_visible_at_that_tile():
world = make_world()
item = ItemInstance("item-1", "bandage")
world.drop_item_at("field", 3, 4, 0, item)
assert world.ground_items_at("field", 3, 4, 0) == [item]
def test_drop_item_at_floors_fractional_coordinates():
world = make_world()
item = ItemInstance("item-1", "bandage")
world.drop_item_at("field", 3.9, 4.2, 0.0, item)
assert world.ground_items_at("field", 3, 4, 0) == [item]
def test_multiple_items_can_stack_on_the_same_tile():
world = make_world()
a = ItemInstance("item-1", "bandage")
b = ItemInstance("item-2", "sealant")
world.drop_item_at("field", 3, 4, 0, a)
world.drop_item_at("field", 3, 4, 0, b)
assert world.ground_items_at("field", 3, 4, 0) == [a, b]
def test_different_tiles_are_independent():
world = make_world()
item = ItemInstance("item-1", "bandage")
world.drop_item_at("field", 3, 4, 0, item)
assert world.ground_items_at("field", 3, 5, 0) == []
assert world.ground_items_at("other_map", 3, 4, 0) == []
def test_remove_ground_item_removes_and_returns_the_matching_item():
world = make_world()
a = ItemInstance("item-1", "bandage")
b = ItemInstance("item-2", "sealant")
world.drop_item_at("field", 3, 4, 0, a)
world.drop_item_at("field", 3, 4, 0, b)
removed = world.remove_ground_item("field", 3, 4, 0, "item-1")
assert removed is a
assert world.ground_items_at("field", 3, 4, 0) == [b]
def test_remove_ground_item_returns_none_when_not_found():
world = make_world()
world.drop_item_at("field", 3, 4, 0, ItemInstance("item-1", "bandage"))
assert world.remove_ground_item("field", 3, 4, 0, "no-such-id") is None
def test_remove_last_item_cleans_up_the_empty_tile_entry():
world = make_world()
world.drop_item_at("field", 3, 4, 0, ItemInstance("item-1", "bandage"))
world.remove_ground_item("field", 3, 4, 0, "item-1")
assert ("field", 3, 4, 0) not in world.ground_items

220
tests/test_hacking.py Normal file
View File

@ -0,0 +1,220 @@
from pathlib import Path
import pytest
from engine.character import Character
from engine.defs import DefRegistry
from engine.map import Map
from engine.tile import Tile, TileLayerInstance
from engine.world import World
from resources.logic.hacking import find_hacking_implant, roll_tech_check, try_hack_tile_layer
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
@pytest.fixture()
def registry():
return DefRegistry.load(DEFS_DIR)
class FixedRng:
"""Deterministic stand-in for random.Random: fixed die roll."""
def __init__(self, roll: int):
self._roll = roll
def randint(self, _lo, _hi):
return self._roll
def choice(self, seq):
return seq[0]
def uniform(self, a, b):
return a
def make_human(registry, **kwargs) -> Character:
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
def make_world_with_locked_door(registry) -> World:
field = Map("field", 5, 5, 1)
for x in range(5):
for y in range(5):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
field.set_tile(2, 2, 0, Tile(flooring=TileLayerInstance("grass"), room=TileLayerInstance("door_locked")))
world = World(registry=registry)
world.add_map(field)
return world
# --- base_tech_skill / roll_tech_check -------------------------------------------------------
def test_base_tech_skill_averages_intelligence_and_perception(registry):
human = make_human(registry)
human.bio["intelligence"] = 4
human.bio["perception"] = 2
assert human.base_tech_skill() == 3.0
def test_base_tech_skill_is_zero_by_default(registry):
human = make_human(registry)
assert human.base_tech_skill() == 0.0
def test_tech_is_not_a_directly_buyable_skill(registry):
human = make_human(registry)
assert "tech" not in human.bio
def test_roll_tech_check_matches_the_derived_base_skill_with_no_penalties(registry):
human = make_human(registry)
human.bio["intelligence"] = 4
human.bio["perception"] = 2
roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=11))
assert roll == 11
assert effective_skill == 3.0
# --- try_hack_tile_layer -----------------------------------------------------------------------
def test_hack_succeeds_and_swaps_the_layer_to_its_unlocked_def(registry):
world = make_world_with_locked_door(registry)
human = make_human(registry)
human.bio["intelligence"] = 10
human.bio["perception"] = 10 # base_tech_skill = 10, easily clears the door's DC 12
assert try_hack_tile_layer(human, world, registry, "field", 2, 2, 0, "room", rng=FixedRng(roll=10)) is True
tile = world.maps["field"].get_tile(2, 2, 0)
assert tile.room.def_id == "door_open"
assert tile.is_walkable(registry) is True
def test_hack_fails_a_roll_too_low_and_leaves_the_door_locked(registry):
world = make_world_with_locked_door(registry)
human = make_human(registry) # base_tech_skill = 0
assert try_hack_tile_layer(human, world, registry, "field", 2, 2, 0, "room", rng=FixedRng(roll=1)) is False
tile = world.maps["field"].get_tile(2, 2, 0)
assert tile.room.def_id == "door_locked"
assert tile.is_walkable(registry) is False
def test_hack_is_a_no_op_on_a_non_hackable_layer(registry):
world = make_world_with_locked_door(registry)
world.maps["field"].get_tile(2, 2, 0).room = TileLayerInstance("wood_wall")
human = make_human(registry)
human.bio["intelligence"] = 10
human.bio["perception"] = 10
assert try_hack_tile_layer(human, world, registry, "field", 2, 2, 0, "room", rng=FixedRng(roll=20)) is False
tile = world.maps["field"].get_tile(2, 2, 0)
assert tile.room.def_id == "wood_wall"
def test_hack_is_a_no_op_on_an_empty_layer(registry):
world = make_world_with_locked_door(registry)
human = make_human(registry)
assert try_hack_tile_layer(human, world, registry, "field", 0, 0, 0, "room", rng=FixedRng(roll=20)) is False
# --- hacking implants (cyberdecks) --------------------------------------------------------------
def test_find_hacking_implant_returns_none_without_one(registry):
human = make_human(registry)
assert find_hacking_implant(human, registry) is None
def test_find_hacking_implant_returns_the_installed_cyberdeck(registry):
human = make_human(registry)
human.install_implant("cyberdeck_biomechanics_mk1", registry)
implant = find_hacking_implant(human, registry)
assert implant is not None
assert implant.id == "cyberdeck_biomechanics_mk1"
def test_biomechanics_mk1_adds_a_plus_one_bonus_with_no_roll_floor(registry):
human = make_human(registry)
human.install_implant("cyberdeck_biomechanics_mk1", registry)
roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=1))
assert roll == 1 # no floor - a bad roll stays bad
assert effective_skill == 1.0 # 0 base + 1 bonus
def test_biomechanics_mk2_adds_a_plus_two_bonus(registry):
human = make_human(registry)
human.install_implant("cyberdeck_biomechanics_mk2", registry)
_roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=1))
assert effective_skill == 2.0
def test_infoinc_mk1_applies_penalty_but_floors_the_roll_at_45_percent(registry):
human = make_human(registry)
human.install_implant("cyberdeck_infoinc_mk1", registry)
roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=1))
assert roll == 9 # round(0.45 * 20)
assert effective_skill == -3.0
def test_infoinc_mk1_does_not_lower_an_already_better_roll(registry):
human = make_human(registry)
human.install_implant("cyberdeck_infoinc_mk1", registry)
roll, _effective_skill = roll_tech_check(human, registry, FixedRng(roll=15))
assert roll == 15 # already above the 9-roll floor - left alone
def test_infoinc_mk2_has_no_bonus_and_no_roll_floor(registry):
human = make_human(registry)
human.install_implant("cyberdeck_infoinc_mk2", registry)
roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=1))
assert roll == 1
assert effective_skill == 0.0
def test_infoinc_mk3_adds_a_plus_two_bonus_and_floors_the_roll_at_60_percent(registry):
human = make_human(registry)
human.install_implant("cyberdeck_infoinc_mk3", registry)
roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=1))
assert roll == 12 # round(0.6 * 20)
assert effective_skill == 2.0
def test_the_miro_secret_cyberdeck_adds_a_plus_five_bonus_and_floors_the_roll_at_75_percent(registry):
human = make_human(registry)
human.install_implant("cyberdeck_the_miro_model_3a", registry)
roll, effective_skill = roll_tech_check(human, registry, FixedRng(roll=1))
assert roll == 15 # round(0.75 * 20)
assert effective_skill == 5.0
def test_the_miro_secret_cyberdeck_is_marked_secret(registry):
assert registry.get_item_def("cyberdeck_the_miro_model_3a").secret is True
def test_other_cyberdecks_are_not_marked_secret(registry):
for def_id in (
"cyberdeck_biomechanics_mk1",
"cyberdeck_biomechanics_mk2",
"cyberdeck_infoinc_mk1",
"cyberdeck_infoinc_mk2",
"cyberdeck_infoinc_mk3",
):
assert registry.get_item_def(def_id).secret is False
def test_hack_succeeds_via_implant_bonus_alone_when_base_skill_would_otherwise_fail(registry):
world = make_world_with_locked_door(registry)
human = make_human(registry) # base_tech_skill = 0, would never clear DC 12 unaided
human.install_implant("cyberdeck_the_miro_model_3a", registry)
assert try_hack_tile_layer(human, world, registry, "field", 2, 2, 0, "room", rng=FixedRng(roll=1)) is True
tile = world.maps["field"].get_tile(2, 2, 0)
assert tile.room.def_id == "door_open"

View File

@ -74,6 +74,14 @@ def test_activate_selected_ability_key_sets_a_one_shot_flag():
assert input_state.consume_activate_ability_bar() is False # consumed
def test_pickup_key_sets_a_one_shot_flag():
input_state = InputState()
assert input_state.consume_pickup() is False
input_state.handle_event({"event_type": "key_down", "key": "f"})
assert input_state.consume_pickup() is True
assert input_state.consume_pickup() is False # consumed
def test_clear_held_state_lets_a_continuously_held_gamepad_button_refire():
from engine.gamepad import DEFAULT_GAMEPAD_BINDINGS, GamepadBindings, GamepadState
@ -125,7 +133,7 @@ def test_block_key_is_a_held_state_not_a_one_shot():
def test_mouse_left_click_activates_right_hand():
input_state = InputState()
input_state.handle_event({"event_type": "pointer_down", "button": 0, "modifiers": ()})
input_state.handle_event({"event_type": "pointer_down", "button": 1, "modifiers": ()})
assert input_state.consume_hand_activation() == "right_hand"
assert input_state.consume_hand_activation() is None
@ -138,10 +146,18 @@ def test_mouse_right_click_activates_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",)})
input_state.handle_event({"event_type": "pointer_down", "button": 1, "modifiers": ("Shift",)})
assert input_state.consume_hand_activation() == "right_hand_2"
def test_button_zero_is_not_a_recognized_mouse_button():
# regression guard: button 0 isn't actually reported by this project's windowing backend
# for any real click (left is 1, right is 2) - see MOUSE_BUTTON_KEYS's own comment.
input_state = InputState()
input_state.handle_event({"event_type": "pointer_down", "button": 0, "modifiers": ()})
assert input_state.consume_hand_activation() is None
def test_pointer_move_tracks_position():
input_state = InputState()
assert input_state.pointer_pos is None

View File

@ -47,3 +47,35 @@ def test_find_first_fit_returns_leftmost_topmost_slot():
slot = inv.find_first_fit(SANDWICH)
assert slot == (1, 0)
def test_item_at_finds_item_from_its_top_left_corner():
inv = Inventory(6, 5)
staff = ItemInstance("item-1", "staff_oak")
inv.place(staff, STAFF, 0, 0)
placed = inv.item_at(0, 0)
assert placed is not None
assert placed.item is staff
def test_item_at_finds_item_from_anywhere_in_its_footprint():
inv = Inventory(6, 5)
sandwich = ItemInstance("item-2", "sandwich")
inv.place(sandwich, SANDWICH, 1, 0) # occupies (1,0),(2,0),(1,1),(2,1)
for cx, cy in [(1, 0), (2, 0), (1, 1), (2, 1)]:
placed = inv.item_at(cx, cy)
assert placed is not None
assert placed.item is sandwich
def test_item_at_returns_none_for_an_empty_cell():
inv = Inventory(6, 5)
inv.place(ItemInstance("item-1", "staff_oak"), STAFF, 0, 0)
assert inv.item_at(5, 4) is None
def test_item_at_returns_none_out_of_bounds():
inv = Inventory(6, 5)
assert inv.item_at(-1, 0) is None
assert inv.item_at(6, 0) is None
assert inv.item_at(0, 5) is None

View File

@ -257,11 +257,11 @@ def test_time_warp_sphere_via_implant_still_works_after_aim_direction_plumbing(r
assert len(world.time_warp_zones) == 1
# --- shortcircuit: bot-specific, randomized organ damage spread -------------------------------
# --- shortcircuit: synthetic-specific, randomized organ damage spread -------------------------
def make_bot(registry, **kwargs) -> Character:
return Character(species_id="bot", default_body_parts=registry.new_default_body_parts("bot"), **kwargs)
def make_hydraulibot(registry, **kwargs) -> Character:
return Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"), **kwargs)
def test_shortcircuit_only_applies_to_synthetic_species(registry):
@ -275,10 +275,10 @@ def test_shortcircuit_only_applies_to_synthetic_species(registry):
assert not any(a.id == "shortcircuit" for p in target.body_parts for a in p.ailments)
def test_shortcircuit_hits_a_bot(registry):
def test_shortcircuit_hits_a_hydraulibot(registry):
world = make_world(registry)
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
target = make_bot(registry, position=EntityPosition("field", 5, 6, 0))
target = make_hydraulibot(registry, position=EntityPosition("field", 5, 6, 0))
world.add_entity(caster)
world.add_entity(target)
@ -286,16 +286,19 @@ def test_shortcircuit_hits_a_bot(registry):
assert any(a.id == "shortcircuit" for p in target.body_parts for a in p.ailments)
def test_shortcircuit_damages_every_organ_the_bot_has(registry):
def test_shortcircuit_damages_every_organ_the_hydraulibot_has(registry):
world = make_world(registry)
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
target = make_bot(registry, position=EntityPosition("field", 5, 6, 0))
target = make_hydraulibot(registry, position=EntityPosition("field", 5, 6, 0))
world.add_entity(caster)
world.add_entity(target)
cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(7))
all_organ_ids = {"bot_cpu", "bot_npu", "bot_power_cell", "bot_hydraulic_fluid_bladder", "bot_cooling_fan"}
all_organ_ids = {
"hydraulibot_cpu", "hydraulibot_npu", "hydraulibot_camera_module", "hydraulibot_mic",
"hydraulibot_power_cell", "hydraulibot_hydraulic_fluid_bladder", "hydraulibot_cooling_fan",
}
damaged_ids = set()
total_damage = 0.0
for part in target.body_parts:
@ -304,15 +307,15 @@ def test_shortcircuit_damages_every_organ_the_bot_has(registry):
damaged_ids.add(organ.organ_def_id)
total_damage += 100.0 - organ.integrity
assert damaged_ids == all_organ_ids # every organ took at least some damage
# shortcircuit's 120 spread across all 5 organs, plus heart_palpitations' own separate hit
# to bot_power_cell (the bot's heart-equivalent organ) on top of its shortcircuit share
# shortcircuit's 120 spread across all 7 organs, plus heart_palpitations' own separate hit
# to hydraulibot_power_cell (the hydraulibot's heart-equivalent organ) on top of its shortcircuit share
assert total_damage <= SHORTCIRCUIT_TOTAL_ORGAN_DAMAGE + HEART_ORGAN_DAMAGE + 1e-6
def test_shortcircuit_ailment_lands_on_every_body_part_hosting_a_damaged_organ(registry):
world = make_world(registry)
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
target = make_bot(registry, position=EntityPosition("field", 5, 6, 0))
target = make_hydraulibot(registry, position=EntityPosition("field", 5, 6, 0))
world.add_entity(caster)
world.add_entity(target)
@ -326,7 +329,7 @@ def test_shortcircuit_ailment_lands_on_every_body_part_hosting_a_damaged_organ(r
def test_shortcircuit_gives_a_huge_intellect_debuff(registry):
world = make_world(registry)
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
target = make_bot(registry, position=EntityPosition("field", 5, 6, 0))
target = make_hydraulibot(registry, position=EntityPosition("field", 5, 6, 0))
world.add_entity(caster)
world.add_entity(target)
@ -340,7 +343,7 @@ def test_shortcircuit_gives_a_huge_intellect_debuff(registry):
def test_shortcircuit_recast_refreshes_rather_than_stacks(registry):
world = make_world(registry)
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
target = make_bot(registry, position=EntityPosition("field", 5, 6, 0))
target = make_hydraulibot(registry, position=EntityPosition("field", 5, 6, 0))
world.add_entity(caster)
world.add_entity(target)

161
tests/test_medical.py Normal file
View File

@ -0,0 +1,161 @@
from pathlib import Path
import pytest
from engine.character import Ailment, Character
from engine.defs import DefRegistry
from resources.logic.health_ticks import apply_bleeding_tick, apply_boost_expiry_tick
from resources.logic.medical import treat_bleeding
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_hydraulibot(registry, **kwargs) -> Character:
return Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"), **kwargs)
def make_motorbot(registry, **kwargs) -> Character:
return Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"), **kwargs)
# --- treat_bleeding: species/item compatibility -----------------------------------------------
def test_bandage_treats_a_severed_limb_on_a_human(registry):
human = make_human(registry)
part = human.get_or_create_body_part_override("left_arm")
part.removed = True
assert treat_bleeding(human, "left_arm", "bandage", registry) is True
assert part.bandaged is True
def test_sealant_does_not_work_on_a_human(registry):
human = make_human(registry)
part = human.get_or_create_body_part_override("left_arm")
part.removed = True
assert treat_bleeding(human, "left_arm", "sealant", registry) is False
assert part.bandaged is False
def test_bandage_does_not_work_on_a_hydraulibot(registry):
hydraulibot = make_hydraulibot(registry)
part = hydraulibot.get_or_create_body_part_override("left_arm")
part.removed = True
assert treat_bleeding(hydraulibot, "left_arm", "bandage", registry) is False
def test_sealant_treats_a_severed_limb_on_a_hydraulibot(registry):
hydraulibot = make_hydraulibot(registry)
part = hydraulibot.get_or_create_body_part_override("left_arm")
part.removed = True
assert treat_bleeding(hydraulibot, "left_arm", "sealant", registry) is True
assert part.bandaged is True
def test_sealant_treats_a_popped_fluid_hose_on_a_hydraulibot(registry):
hydraulibot = make_hydraulibot(registry)
part = hydraulibot.get_or_create_body_part_override("left_leg")
part.ailments.append(Ailment(id="popped_fluid_hose", name="Popped Fluid Hose", severity=0.6))
assert treat_bleeding(hydraulibot, "left_leg", "sealant", registry) is True
assert part.bandaged is True
def test_treat_bleeding_is_a_no_op_when_the_part_is_not_bleeding(registry):
human = make_human(registry)
human.get_or_create_body_part_override("left_arm") # healthy, not removed
assert treat_bleeding(human, "left_arm", "bandage", registry) is False
def test_treat_bleeding_is_a_no_op_when_already_bandaged(registry):
human = make_human(registry)
part = human.get_or_create_body_part_override("left_arm")
part.removed = True
part.bandaged = True
assert treat_bleeding(human, "left_arm", "bandage", registry) is False
# --- apply_bleeding_tick: bandaging actually stops the loss ------------------------------------
def test_bandaged_severed_limb_does_not_bleed(registry):
human = make_human(registry)
part = human.get_or_create_body_part_override("left_arm")
part.removed = True
part.bandaged = True
apply_bleeding_tick(human, registry, ticks=5)
assert human.blood_percentage == 100.0
def test_unbandaged_severed_limb_still_bleeds(registry):
human = make_human(registry)
part = human.get_or_create_body_part_override("left_arm")
part.removed = True
apply_bleeding_tick(human, registry, ticks=5)
assert human.blood_percentage < 100.0
def test_popped_fluid_hose_causes_bleeding_until_bandaged(registry):
hydraulibot = make_hydraulibot(registry)
part = hydraulibot.get_or_create_body_part_override("left_leg")
part.ailments.append(Ailment(id="popped_fluid_hose", name="Popped Fluid Hose", severity=1.0))
apply_bleeding_tick(hydraulibot, registry, ticks=5)
assert hydraulibot.blood_percentage < 100.0
hydraulibot.blood_percentage = 100.0
part.bandaged = True
apply_bleeding_tick(hydraulibot, registry, ticks=5)
assert hydraulibot.blood_percentage == 100.0
def test_ripped_tendon_alone_does_not_cause_bleeding(registry):
human = make_human(registry)
part = human.get_or_create_body_part_override("left_leg")
part.ailments.append(Ailment(id="ripped_tendon", name="Ripped Tendon", severity=1.0))
apply_bleeding_tick(human, registry, ticks=5)
assert human.blood_percentage == 100.0
def test_motorbot_never_bleeds_even_when_dismembered(registry):
motorbot = make_motorbot(registry)
part = motorbot.get_or_create_body_part_override("left_arm")
part.removed = True
apply_bleeding_tick(motorbot, registry, ticks=10)
assert motorbot.blood_percentage == 100.0
def test_motorbot_broken_frame_piece_does_not_cause_bleeding(registry):
# unlike popped_fluid_hose, broken_frame_piece isn't a causes_bleeding ailment
motorbot = make_motorbot(registry)
part = motorbot.get_or_create_body_part_override("left_leg")
part.ailments.append(Ailment(id="broken_frame_piece", name="Broken Structural Frame Piece", severity=1.0))
apply_bleeding_tick(motorbot, registry, ticks=10)
assert motorbot.blood_percentage == 100.0
# --- apply_boost_expiry_tick --------------------------------------------------------------------
def test_boost_expiry_tick_clears_an_expired_boost(registry):
human = make_human(registry)
human.active_boosts["strength"] = 1.6
human.boost_expirations["strength"] = 10.0
apply_boost_expiry_tick(human, now=10.0)
assert "strength" not in human.active_boosts
assert "strength" not in human.boost_expirations
def test_boost_expiry_tick_leaves_a_still_active_boost(registry):
human = make_human(registry)
human.active_boosts["speed"] = 1.6
human.boost_expirations["speed"] = 10.0
apply_boost_expiry_tick(human, now=5.0)
assert human.active_boosts["speed"] == 1.6

27
tests/test_medical_log.py Normal file
View File

@ -0,0 +1,27 @@
from engine.character import MAX_MEDICAL_LOG_ENTRIES, Character
def test_medical_log_starts_empty():
assert Character().medical_log == []
def test_log_medical_event_appends():
character = Character()
character.log_medical_event("Sprained left_leg")
assert character.medical_log == ["Sprained left_leg"]
def test_log_medical_event_keeps_insertion_order():
character = Character()
character.log_medical_event("first")
character.log_medical_event("second")
assert character.medical_log == ["first", "second"]
def test_log_medical_event_trims_oldest_past_the_cap():
character = Character()
for i in range(MAX_MEDICAL_LOG_ENTRIES + 5):
character.log_medical_event(f"event {i}")
assert len(character.medical_log) == MAX_MEDICAL_LOG_ENTRIES
assert character.medical_log[0] == "event 5"
assert character.medical_log[-1] == f"event {MAX_MEDICAL_LOG_ENTRIES + 4}"

View File

@ -8,6 +8,8 @@ from engine.character_creator import CharacterCreator
from engine.controls_menu import ControlsMenu
from engine.defs import DefRegistry
from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu
from engine.inventory import Inventory
from engine.item import ItemInstance
from engine.render import menu_draw
from engine.ui import AbilityBar, AbilityBarSlot, CharacterMenu
from engine.world_creation import WorldCreationMenu
@ -53,6 +55,41 @@ def test_draw_character_creation_name_step(registry):
assert buckets["ui/panel/background.png"]
def test_draw_character_creation_password_step_masks_the_typed_password(registry):
from engine.render.ui_draw import glyph_texture_path
buckets = new_buckets()
flow = CharacterCreationFlow(CharacterCreator(registry))
flow.advance() # species -> name
flow.type_char("V")
flow.advance() # name -> password
flow.type_char("q") # "q" appears in none of this screen's own labels/footer text
menu_draw.draw_character_creation(buckets, flow, 800, 600)
assert buckets["ui/panel/background.png"]
assert glyph_texture_path("*") in buckets # the typed password renders masked
assert glyph_texture_path("q") not in buckets # the raw character never appears
def test_draw_character_creation_defaults_to_new_character_title(registry):
from engine.render.ui_draw import glyph_texture_path
buckets = new_buckets()
flow = CharacterCreationFlow(CharacterCreator(registry))
menu_draw.draw_character_creation(buckets, flow, 800, 600)
assert glyph_texture_path("P") not in buckets # "P" appears in no default-screen text
def test_draw_character_creation_accepts_a_custom_title(registry):
from engine.render.ui_draw import glyph_texture_path
buckets = new_buckets()
flow = CharacterCreationFlow(CharacterCreator(registry))
menu_draw.draw_character_creation(buckets, flow, 800, 600, title="Spawn NPC")
assert buckets[glyph_texture_path("P")] # from "NPC" - only present with the custom title
def test_draw_world_creation_lists_templates_and_highlights_selection():
buckets = new_buckets()
menu = WorldCreationMenu()
@ -129,6 +166,34 @@ def test_draw_character_card_medical_tab_lists_body_parts_and_organs(registry):
assert any(k.startswith("ui/font/") for k in buckets)
def test_medical_lines_omits_log_section_when_empty(registry):
character = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"))
lines = menu_draw._medical_lines(character, registry)
assert "Log:" not in lines
def test_medical_lines_includes_recent_log_entries_newest_first(registry):
character = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"))
character.log_medical_event("first event")
character.log_medical_event("second event")
lines = menu_draw._medical_lines(character, registry)
assert "Log:" in lines
log_index = lines.index("Log:")
assert lines[log_index + 1] == " second event"
assert lines[log_index + 2] == " first event"
def test_medical_lines_caps_log_display_count(registry):
character = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"))
for i in range(10):
character.log_medical_event(f"event {i}")
lines = menu_draw._medical_lines(character, registry)
log_index = lines.index("Log:")
log_lines = lines[log_index + 1 :]
assert len(log_lines) == menu_draw.MEDICAL_LOG_DISPLAY_COUNT
assert log_lines[0] == " event 9"
def test_draw_character_card_medical_tab_shows_removed_parts_and_ailments(registry):
from engine.character import Ailment
@ -145,3 +210,146 @@ def test_draw_character_card_medical_tab_shows_removed_parts_and_ailments(regist
card_menu.select_tab("medical")
menu_draw.draw_character_card(buckets, character, card_menu, registry, 600)
assert buckets["ui/panel/background.png"]
# --- character card hit-testing + inventory grid ------------------------------------------------
def test_character_card_rect_matches_ability_bar_offset():
x, y, w, h = menu_draw.character_card_rect(600)
assert x == menu_draw.PADDING + menu_draw.ABILITY_BAR_W + menu_draw.PADDING
assert y == 600 - menu_draw.CHARACTER_CARD_H - menu_draw.PADDING
assert (w, h) == (menu_draw.CHARACTER_CARD_W, menu_draw.CHARACTER_CARD_H)
def test_point_in_rect_true_inside_false_outside():
rect = (10.0, 20.0, 100.0, 50.0)
assert menu_draw.point_in_rect(50, 40, rect) is True
assert menu_draw.point_in_rect(5, 40, rect) is False
assert menu_draw.point_in_rect(50, 100, rect) is False
def test_inventory_cell_at_finds_the_top_left_cell():
inventory = Inventory(6, 5)
origin = (100.0, 200.0)
cell = menu_draw.inventory_cell_at(inventory, origin, origin[0] + 1, origin[1] + 1)
assert cell == (0, 0)
def test_inventory_cell_at_none_outside_the_grid():
inventory = Inventory(6, 5)
assert menu_draw.inventory_cell_at(inventory, (100.0, 200.0), 0, 0) is None
def test_draw_inventory_grid_highlights_occupied_cells(registry):
buckets = new_buckets()
inventory = Inventory(2, 1)
inventory.place(ItemInstance("item-1", "bandage"), registry.get_item_def("bandage"), 0, 0)
menu_draw.draw_inventory_grid(buckets, inventory, (10.0, 10.0))
assert buckets["ui/panel/highlight.png"]
assert buckets["ui/panel/border.png"]
assert any(k.startswith("ui/font/") for k in buckets)
def test_draw_inventory_grid_blanks_the_dragged_cell(registry):
buckets = new_buckets()
inventory = Inventory(1, 1)
inventory.place(ItemInstance("item-1", "bandage"), registry.get_item_def("bandage"), 0, 0)
menu_draw.draw_inventory_grid(buckets, inventory, (10.0, 10.0), dragging_item_id="item-1")
assert "ui/panel/highlight.png" not in buckets # dragged cell drawn as blank "border" instead
assert buckets["ui/panel/border.png"]
def test_draw_drag_ghost_emits_a_panel_and_label():
buckets = new_buckets()
menu_draw.draw_drag_ghost(buckets, "bandage", 100.0, 100.0)
assert buckets["ui/panel/highlight.png"]
assert any(k.startswith("ui/font/") for k in buckets)
# --- equipment tab rows + backpack popup card -------------------------------------------------
def test_equipment_row_at_finds_a_worn_slot_row(registry):
from engine.character import ClothingPiece
character = Character(
species_id="human",
default_body_parts=registry.new_default_body_parts("human"),
clothing=[ClothingPiece("back", "backpack_basic")],
)
x, y, _w, _h = menu_draw.character_card_rect(600)
body_y = y + 10 + menu_draw.ROW_H + menu_draw.ROW_H * 1.5
assert menu_draw.equipment_row_at(character, 600, x + 20, body_y + 1) == "back"
def test_equipment_row_at_finds_the_pockets_row_after_worn_items(registry):
from engine.character import ClothingPiece
character = Character(
species_id="human",
default_body_parts=registry.new_default_body_parts("human"),
clothing=[ClothingPiece("back", "backpack_basic")],
)
character.inventory = Inventory(4, 4)
x, y, _w, _h = menu_draw.character_card_rect(600)
body_y = y + 10 + menu_draw.ROW_H + menu_draw.ROW_H * 1.5
pockets_row_y = body_y + menu_draw.ROW_H # second row, after "back"
from engine.ui import CharacterMenu
assert menu_draw.equipment_row_at(character, 600, x + 20, pockets_row_y + 1) == CharacterMenu.POCKETS_SLOT
def test_equipment_row_at_none_below_the_last_row(registry):
from engine.character import ClothingPiece
character = Character(
species_id="human",
default_body_parts=registry.new_default_body_parts("human"),
clothing=[ClothingPiece("back", "backpack_basic")],
)
x, y, _w, _h = menu_draw.character_card_rect(600)
body_y = y + 10 + menu_draw.ROW_H + menu_draw.ROW_H * 1.5
below_the_single_row = body_y + menu_draw.ROW_H * 5
assert menu_draw.equipment_row_at(character, 600, x + 20, below_the_single_row) is None
def test_backpack_popup_rect_sized_to_the_inventory_and_anchored_above_the_card():
inventory = Inventory(3, 2)
card_x, card_y, _w, _h = menu_draw.character_card_rect(600)
rect = menu_draw.backpack_popup_rect(inventory, 600)
x, y, w, h = rect
assert x == card_x
assert y < card_y # sits above the character card, not overlapping it
cell = menu_draw.INVENTORY_CELL_SIZE + menu_draw.INVENTORY_CELL_GAP
assert w == inventory.width * cell + 20.0
assert h == inventory.height * cell + 20.0 + menu_draw.ROW_H
def test_draw_backpack_popup_does_nothing_when_closed(registry):
from engine.ui import CharacterMenu
buckets = new_buckets()
character = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"))
menu_draw.draw_backpack_popup(buckets, character, CharacterMenu(), registry, 600)
assert buckets == {}
def test_draw_backpack_popup_renders_the_open_pockets_inventory(registry):
from engine.ui import CharacterMenu
buckets = new_buckets()
character = Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"))
character.inventory = Inventory(2, 1)
menu = CharacterMenu()
menu.toggle_backpack(CharacterMenu.POCKETS_SLOT, character, registry)
menu_draw.draw_backpack_popup(buckets, character, menu, registry, 600)
assert buckets["ui/panel/background.png"]
assert buckets["ui/panel/border.png"]
assert any(k.startswith("ui/font/") for k in buckets)

View File

@ -35,7 +35,7 @@ def test_new_default_body_parts_auto_attaches_organs_by_host_slot(registry):
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"}
assert {o.organ_def_id for o in head.organs} == {"human_brain", "human_eyes", "human_ears"}
arm = next(p for p in parts if p.slot == "left_arm")
assert arm.organs == []
@ -215,11 +215,19 @@ def test_find_organ_by_role_finds_the_heart(registry):
def test_find_organ_by_role_works_across_bespoke_species_organs(registry):
bot = Character(species_id="bot", default_body_parts=registry.new_default_body_parts("bot"))
found = bot.find_organ_by_role("heart", registry)
hydraulibot = Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"))
found = hydraulibot.find_organ_by_role("heart", registry)
assert found is not None
_body_part, organ = found
assert organ.organ_def_id == "bot_power_cell" # the robot's functional heart-equivalent
assert organ.organ_def_id == "hydraulibot_power_cell" # the robot's functional heart-equivalent
def test_find_organ_by_role_works_for_motorbots_main_motor(registry):
motorbot = Character(species_id="motorbot", default_body_parts=registry.new_default_body_parts("motorbot"))
found = motorbot.find_organ_by_role("heart", registry)
assert found is not None
_body_part, organ = found
assert organ.organ_def_id == "motorbot_main_motor"
def test_find_organ_by_role_none_when_no_organs_at_all(registry):

138
tests/test_pickup.py Normal file
View File

@ -0,0 +1,138 @@
from pathlib import Path
import pytest
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.pickup import drop_item, try_pickup
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", 5, 5, 1)
for x in range(5):
for y in range(5):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
world = World(registry=registry)
world.add_map(field)
return world
def make_human(registry, **kwargs):
from engine.character import Character
return Character(
species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs
)
# --- try_pickup -----------------------------------------------------------------------------
def test_pickup_moves_a_ground_item_into_inventory(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
human.inventory = Inventory(4, 4)
world.add_entity(human)
world.drop_item_at("field", 2, 2, 0, ItemInstance("item-1", "bandage"))
picked = try_pickup(human, world, registry)
assert picked == 1
assert {p.item.item_id for p in human.inventory.items()} == {"item-1"}
assert world.ground_items_at("field", 2, 2, 0) == []
def test_pickup_picks_up_everything_that_fits(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
human.inventory = Inventory(4, 4)
world.add_entity(human)
world.drop_item_at("field", 2, 2, 0, ItemInstance("item-1", "bandage"))
world.drop_item_at("field", 2, 2, 0, ItemInstance("item-2", "sealant"))
picked = try_pickup(human, world, registry)
assert picked == 2
assert world.ground_items_at("field", 2, 2, 0) == []
def test_pickup_leaves_items_that_do_not_fit(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
human.inventory = Inventory(1, 1) # room for exactly one 1x1 item
world.add_entity(human)
world.drop_item_at("field", 2, 2, 0, ItemInstance("item-1", "bandage"))
world.drop_item_at("field", 2, 2, 0, ItemInstance("item-2", "sealant"))
picked = try_pickup(human, world, registry)
assert picked == 1
remaining = world.ground_items_at("field", 2, 2, 0)
assert len(remaining) == 1
def test_pickup_only_affects_the_characters_own_tile(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
human.inventory = Inventory(4, 4)
world.add_entity(human)
world.drop_item_at("field", 3, 3, 0, ItemInstance("item-1", "bandage"))
assert try_pickup(human, world, registry) == 0
assert world.ground_items_at("field", 3, 3, 0) != []
def test_pickup_is_a_no_op_without_an_inventory(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
world.add_entity(human)
world.drop_item_at("field", 2, 2, 0, ItemInstance("item-1", "bandage"))
assert try_pickup(human, world, registry) == 0
assert world.ground_items_at("field", 2, 2, 0) != []
# --- drop_item -------------------------------------------------------------------------------
def test_drop_item_moves_it_from_inventory_to_the_ground(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
human.inventory = Inventory(4, 4)
item = ItemInstance("item-1", "bandage")
human.inventory.place(item, registry.get_item_def("bandage"), 0, 0)
world.add_entity(human)
assert drop_item(human, world, "item-1", "bandage", human.inventory, registry) is True
assert human.inventory.items() == []
assert world.ground_items_at("field", 2, 2, 0) == [item]
def test_drop_item_returns_false_if_not_actually_in_inventory(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
human.inventory = Inventory(4, 4)
world.add_entity(human)
assert drop_item(human, world, "no-such-item", "bandage", human.inventory, registry) is False
assert world.ground_items_at("field", 2, 2, 0) == []
def test_drop_item_is_a_no_op_without_an_inventory(registry):
world = make_world(registry)
human = make_human(registry, position=EntityPosition("field", 2, 2, 0))
world.add_entity(human)
assert drop_item(human, world, "item-1", "bandage", human.inventory, registry) is False

51
tests/test_spawning.py Normal file
View File

@ -0,0 +1,51 @@
from pathlib import Path
import pytest
from engine.defs import DefRegistry
from engine.entity import EntityPosition
from resources.logic.spawning import spawn_npc
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
@pytest.fixture()
def registry():
return DefRegistry.load(DEFS_DIR)
def test_spawn_npc_builds_a_character_from_the_template(registry):
npc = spawn_npc(registry, "npc_reptilian", EntityPosition("field", 2, 2, 0))
assert npc.name == "Reptilian"
assert npc.def_id == "npc_reptilian"
assert npc.species_id == "reptilian_quad"
assert npc.position == EntityPosition("field", 2, 2, 0)
def test_spawn_npc_fills_the_ai_slot_from_the_templates_behavior(registry):
npc = spawn_npc(registry, "npc_reptilian", EntityPosition("field", 2, 2, 0))
assert npc.ai is not None
assert npc.ai.behavior_id == "wander"
def test_spawn_npc_leaves_the_ai_slot_none_without_a_behavior(registry):
npc = spawn_npc(registry, "npc_alien", EntityPosition("field", 2, 2, 0))
assert npc.ai is None
def test_spawn_npc_creates_an_inventory_when_the_template_declares_a_size(registry):
npc = spawn_npc(registry, "npc_reptilian", EntityPosition("field", 2, 2, 0))
assert npc.inventory is not None
assert (npc.inventory.width, npc.inventory.height) == (6, 5)
def test_spawn_npc_leaves_inventory_none_without_a_declared_size(registry):
npc = spawn_npc(registry, "companion_dog", EntityPosition("field", 2, 2, 0))
assert npc.inventory is None
def test_spawn_npc_gives_independent_ai_state_per_instance(registry):
a = spawn_npc(registry, "npc_reptilian", EntityPosition("field", 2, 2, 0))
b = spawn_npc(registry, "npc_reptilian", EntityPosition("field", 3, 3, 0))
a.ai.state["next_move_at"] = 5.0
assert b.ai.state == {}

View File

@ -44,33 +44,57 @@ def test_human_species_gets_its_organs(registry):
assert {o.organ_def_id for o in torso.organs} == {"human_heart", "human_lungs", "human_liver", "human_kidneys"}
def test_bot_species_gets_bespoke_mechanical_organs(registry):
parts = registry.new_default_body_parts("bot")
def test_hydraulibot_species_gets_bespoke_mechanical_organs(registry):
parts = registry.new_default_body_parts("hydraulibot")
head = next(p for p in parts if p.slot == "head")
torso = next(p for p in parts if p.slot == "torso")
assert {o.organ_def_id for o in head.organs} == {"bot_cpu", "bot_npu"}
assert {o.organ_def_id for o in head.organs} == {"hydraulibot_cpu", "hydraulibot_npu", "hydraulibot_camera_module", "hydraulibot_mic"}
assert {o.organ_def_id for o in torso.organs} == {
"bot_power_cell", "bot_hydraulic_fluid_bladder", "bot_cooling_fan",
"hydraulibot_power_cell", "hydraulibot_hydraulic_fluid_bladder", "hydraulibot_cooling_fan",
}
def test_bot_power_cell_affects_multiple_organs(registry):
bot = make_character(registry, "bot")
torso = bot.get_or_create_body_part_override("torso")
power_cell = next(o for o in torso.organs if o.organ_def_id == "bot_power_cell")
def test_hydraulibot_power_cell_affects_multiple_organs(registry):
hydraulibot = make_character(registry, "hydraulibot")
torso = hydraulibot.get_or_create_body_part_override("torso")
power_cell = next(o for o in torso.organs if o.organ_def_id == "hydraulibot_power_cell")
power_cell.integrity = 0.0
apply_organ_interactions_tick(bot, registry, ticks=10)
apply_organ_interactions_tick(hydraulibot, registry, ticks=10)
head = bot.get_body_part("head")
cpu = next(o for o in head.organs if o.organ_def_id == "bot_cpu")
npu = next(o for o in head.organs if o.organ_def_id == "bot_npu")
fluid_bladder = next(o for o in bot.get_body_part("torso").organs if o.organ_def_id == "bot_hydraulic_fluid_bladder")
head = hydraulibot.get_body_part("head")
cpu = next(o for o in head.organs if o.organ_def_id == "hydraulibot_cpu")
npu = next(o for o in head.organs if o.organ_def_id == "hydraulibot_npu")
fluid_bladder = next(
o for o in hydraulibot.get_body_part("torso").organs if o.organ_def_id == "hydraulibot_hydraulic_fluid_bladder"
)
assert cpu.integrity < 100.0
assert npu.integrity < 100.0
assert fluid_bladder.integrity < 100.0
def test_motorbot_species_gets_bespoke_mechanical_organs(registry):
parts = registry.new_default_body_parts("motorbot")
head = next(p for p in parts if p.slot == "head")
torso = next(p for p in parts if p.slot == "torso")
assert {o.organ_def_id for o in head.organs} == {"motorbot_cpu", "motorbot_npu", "motorbot_camera_lenses", "motorbot_mic"}
assert {o.organ_def_id for o in torso.organs} == {
"motorbot_battery", "motorbot_mechanical_transmission", "motorbot_main_motor",
}
def test_motorbot_battery_affects_main_motor(registry):
motorbot = make_character(registry, "motorbot")
torso = motorbot.get_or_create_body_part_override("torso")
battery = next(o for o in torso.organs if o.organ_def_id == "motorbot_battery")
battery.integrity = 0.0
apply_organ_interactions_tick(motorbot, registry, ticks=10)
main_motor = next(o for o in motorbot.get_body_part("torso").organs if o.organ_def_id == "motorbot_main_motor")
assert main_motor.integrity < 100.0
def test_reptilian_venom_gland_is_bespoke_not_shared_with_other_species(registry):
parts = registry.new_default_body_parts("reptilian_quad")
torso = next(p for p in parts if p.slot == "torso")
@ -86,7 +110,7 @@ def test_zenari_organs_are_bespoke(registry):
assert {o.organ_def_id for o in torso.organs} == {
"zenari_cardial_node", "zenari_respirator", "zenari_metabolizer", "zenari_filtration_organ",
}
assert {o.organ_def_id for o in head.organs} == {"zenari_cortex"}
assert {o.organ_def_id for o in head.organs} == {"zenari_cortex", "zenari_eyes", "zenari_ears"}
def test_dog_organs_are_bespoke_not_shared_with_human(registry):
@ -96,7 +120,7 @@ def test_dog_organs_are_bespoke_not_shared_with_human(registry):
def test_no_species_shares_an_organ_id_with_another(registry):
species_ids = ("human", "reptilian_quad", "alien_tri", "bot", "dog")
species_ids = ("human", "reptilian_quad", "alien_tri", "hydraulibot", "motorbot", "dog")
seen: dict[str, str] = {}
for species_id in species_ids:
species = registry.get_species_def(species_id)

382
tests/test_turrets.py Normal file
View File

@ -0,0 +1,382 @@
import math
from pathlib import Path
import pytest
from engine.ai import AIController
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.ai import AI_BEHAVIORS, run_ai_tick
from resources.logic.turrets import (
TURRET_BEHAVIOR_ID,
find_closest_hostile,
is_hostile,
rotate_toward,
spawn_turret,
turret_ai_behavior,
)
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
@pytest.fixture()
def registry():
return DefRegistry.load(DEFS_DIR)
class FixedRng:
def __init__(self, roll: int = 15):
self._roll = roll
def randint(self, _lo, _hi):
return self._roll
def choice(self, seq):
return seq[0]
def uniform(self, a, b):
return a
def make_world(registry) -> World:
field = Map("field", 20, 20, 1)
for x in range(20):
for y in range(20):
field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass")))
world = World(registry=registry)
world.add_map(field)
return world
def make_human(registry, **kwargs) -> Character:
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
def make_turret(
registry,
position,
entity_def_id="turret_pistol",
hostile_factions=("hostile",),
allied_factions=(),
hostile_entity_ids=(),
allied_entity_ids=(),
) -> Character:
return spawn_turret(
registry,
entity_def_id,
position,
hostile_factions=hostile_factions,
allied_factions=allied_factions,
hostile_entity_ids=hostile_entity_ids,
allied_entity_ids=allied_entity_ids,
)
# --- rotate_toward --------------------------------------------------------------------------
def test_rotate_toward_snaps_directly_when_within_turn_rate():
assert rotate_toward(0.0, 10.0, 15.0) == 10.0
def test_rotate_toward_is_capped_by_turn_rate():
assert rotate_toward(0.0, 90.0, 15.0) == 15.0
def test_rotate_toward_turns_the_shorter_way_around_the_wrap():
# from 350 to 10 is a 20-degree turn forward through 0, not backward through 180
assert rotate_toward(350.0, 10.0, 30.0) == 10.0
assert rotate_toward(350.0, 10.0, 5.0) == pytest.approx(355.0)
def test_rotate_toward_turns_negative_direction_when_shorter():
assert rotate_toward(90.0, 0.0, 15.0) == 75.0
# --- is_hostile ------------------------------------------------------------------------------
def test_is_hostile_defaults_to_false_for_an_untagged_character(registry):
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
bystander = make_human(registry, position=EntityPosition("field", 1, 0, 0))
assert is_hostile(turret, bystander) is False
def test_is_hostile_true_for_a_matching_hostile_faction(registry):
turret = make_turret(registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",))
enemy = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["raiders"])
assert is_hostile(turret, enemy) is True
def test_is_hostile_false_for_a_matching_allied_faction(registry):
turret = make_turret(
registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",), allied_factions=("crew",)
)
friendly = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["crew"])
assert is_hostile(turret, friendly) is False
def test_individual_hostile_override_works_without_any_faction(registry):
target = make_human(registry, position=EntityPosition("field", 1, 0, 0))
turret = make_turret(
registry, EntityPosition("field", 0, 0, 0), hostile_factions=(), hostile_entity_ids=(target.entity_id,)
)
assert is_hostile(turret, target) is True
def test_individual_allied_override_beats_a_hostile_faction(registry):
target = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["raiders"])
turret = make_turret(
registry,
EntityPosition("field", 0, 0, 0),
hostile_factions=("raiders",),
allied_entity_ids=(target.entity_id,),
)
assert is_hostile(turret, target) is False
# --- find_closest_hostile ----------------------------------------------------------------------
def test_find_closest_hostile_picks_the_nearest_one(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
far = make_human(registry, position=EntityPosition("field", 5, 0, 0), factions=["hostile"])
near = make_human(registry, position=EntityPosition("field", 2, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(far)
world.add_entity(near)
assert find_closest_hostile(turret, world, registry) is near
def test_find_closest_hostile_ignores_allies(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
ally = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["player"])
world.add_entity(turret)
world.add_entity(ally)
assert find_closest_hostile(turret, world, registry) is None
def test_find_closest_hostile_ignores_targets_out_of_weapon_range(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) # pistol_basic range = 6
far = make_human(registry, position=EntityPosition("field", 15, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(far)
assert find_closest_hostile(turret, world, registry) is None
def test_find_closest_hostile_is_none_without_a_weapon_assigned(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.ai.state["weapon_item_id"] = None
enemy = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
assert find_closest_hostile(turret, world, registry) is None
# --- turret_ai_behavior ------------------------------------------------------------------------
def test_behavior_is_a_noop_with_no_power(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("psu").removed = True
enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] == 0.0
assert turret.ai.state["target_entity_id"] is None
def test_behavior_does_not_acquire_a_target_without_a_camera(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("camera").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] is None
assert turret.ai.state["facing_degrees"] == 0.0
def test_behavior_does_not_acquire_a_target_without_a_cpu(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("cpu").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] is None
def test_behavior_tracks_but_cannot_turn_without_motors(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("motors").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] == enemy.entity_id # still sees/tracks it
assert turret.ai.state["facing_degrees"] == 0.0 # but physically can't turn
def test_behavior_tracks_and_turns_but_does_not_fire_without_a_gun(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.get_or_create_body_part_override("gun").removed = True
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
enemy_start_integrity = enemy.get_body_part("torso").integrity
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] != 0.0 # still turns toward the target
torso = enemy.get_body_part("torso")
assert torso.integrity == enemy_start_integrity # never actually got shot at
def _total_damage_taken(character: Character) -> float:
return sum(100.0 - part.integrity for part in character.resolved_body_parts().values() if part is not None)
def test_behavior_rotates_toward_and_fires_at_a_fully_functional_turret(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng(roll=20))
assert turret.ai.state["target_entity_id"] == enemy.entity_id
assert turret.ai.state["facing_degrees"] == 0.0 # enemy due east - already facing that way
assert _total_damage_taken(enemy) > 0.0 # a roll of 20 should land a hit somewhere
def test_behavior_turn_is_capped_by_the_configured_turn_rate(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.ai.state["turn_rate_degrees_per_tick"] = 10.0
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) # due "south", 90 degrees
world.add_entity(turret)
world.add_entity(enemy)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] == pytest.approx(10.0)
def test_behavior_converges_on_target_over_several_ticks(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
turret.ai.state["turn_rate_degrees_per_tick"] = 10.0
enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
for _ in range(20):
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["facing_degrees"] == pytest.approx(90.0)
def test_behavior_ignores_a_bystander_with_no_hostile_tag(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
bystander = make_human(registry, position=EntityPosition("field", 6, 0, 0))
world.add_entity(turret)
world.add_entity(bystander)
turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] is None
assert turret.ai.state["facing_degrees"] == 0.0
# --- spawn_turret ------------------------------------------------------------------------------
def test_spawn_turret_builds_a_turret_species_character(registry):
turret = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0))
assert turret.species_id == "turret"
assert turret.name == "Pistol Turret"
assert {p.slot for p in turret.default_body_parts} == {"psu", "cpu", "motors", "gun", "camera"}
def test_spawn_turret_fills_the_ai_slot_with_the_turret_behavior(registry):
turret = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0))
assert turret.ai is not None
assert turret.ai.behavior_id == TURRET_BEHAVIOR_ID
assert AI_BEHAVIORS[TURRET_BEHAVIOR_ID] is turret_ai_behavior
def test_spawn_turret_reads_weapon_and_turn_rate_from_the_entity_template(registry):
pistol = spawn_turret(registry, "turret_pistol", EntityPosition("field", 0, 0, 0))
autocannon = spawn_turret(registry, "turret_autocannon", EntityPosition("field", 0, 0, 0))
assert pistol.ai.state["weapon_item_id"] == "pistol_basic"
assert pistol.ai.state["turn_rate_degrees_per_tick"] == 30
assert autocannon.ai.state["weapon_item_id"] == "turret_autocannon"
assert autocannon.ai.state["turn_rate_degrees_per_tick"] == 12
def test_spawn_turret_stores_the_given_targeting_config(registry):
turret = spawn_turret(
registry,
"turret_pistol",
EntityPosition("field", 0, 0, 0),
hostile_factions=("raiders",),
allied_factions=("crew",),
hostile_entity_ids=("npc-1",),
allied_entity_ids=("npc-2",),
)
assert turret.ai.state["hostile_factions"] == ["raiders"]
assert turret.ai.state["allied_factions"] == ["crew"]
assert turret.ai.state["hostile_entity_ids"] == ["npc-1"]
assert turret.ai.state["allied_entity_ids"] == ["npc-2"]
def test_spawn_turret_gives_independent_ai_state_per_instance(registry):
a = spawn_turret(registry, "turret_pistol", EntityPosition("field", 0, 0, 0))
b = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0))
a.ai.state["facing_degrees"] = 45.0
assert b.ai.state["facing_degrees"] == 0.0
# --- run_ai_tick dispatch ------------------------------------------------------------------------
def test_run_ai_tick_dispatches_to_the_turret_behavior(registry):
world = make_world(registry)
turret = make_turret(registry, EntityPosition("field", 0, 0, 0))
enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"])
world.add_entity(turret)
world.add_entity(enemy)
run_ai_tick(turret, world, registry, now=0.0, rng=FixedRng())
assert turret.ai.state["target_entity_id"] == enemy.entity_id