diff --git a/config/controller_bindings.conf b/config/controller_bindings.conf index 8b25f54..9577a3c 100644 --- a/config/controller_bindings.conf +++ b/config/controller_bindings.conf @@ -15,6 +15,7 @@ [actions] leap = gp_a block = gp_left_bumper +pickup = gp_left_thumb [hands] activate_right_hand = gp_right_bumper diff --git a/config/keybindings.conf b/config/keybindings.conf index 96a1626..52f3abc 100644 --- a/config/keybindings.conf +++ b/config/keybindings.conf @@ -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 diff --git a/engine/ai.py b/engine/ai.py new file mode 100644 index 0000000..3f9a58e --- /dev/null +++ b/engine/ai.py @@ -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) diff --git a/engine/character.py b/engine/character.py index c612f27..9095feb 100644 --- a/engine/character.py +++ b/engine/character.py @@ -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 diff --git a/engine/character_creator.py b/engine/character_creator.py index 91c2c81..3b0f663 100644 --- a/engine/character_creator.py +++ b/engine/character_creator.py @@ -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) - diff --git a/engine/config.py b/engine/config.py index 64aeab6..d213e79 100644 --- a/engine/config.py +++ b/engine/config.py @@ -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. diff --git a/engine/defs.py b/engine/defs.py index 602a607..c2d3fc4 100644 --- a/engine/defs.py +++ b/engine/defs.py @@ -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: diff --git a/engine/dev_tools.py b/engine/dev_tools.py index dcb9de2..b70ee19 100644 --- a/engine/dev_tools.py +++ b/engine/dev_tools.py @@ -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: diff --git a/engine/game_modes.py b/engine/game_modes.py index 0cd0b09..069105f 100644 --- a/engine/game_modes.py +++ b/engine/game_modes.py @@ -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 diff --git a/engine/gamepad.py b/engine/gamepad.py index 54eb181..9e64ec8 100644 --- a/engine/gamepad.py +++ b/engine/gamepad.py @@ -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"], } diff --git a/engine/input.py b/engine/input.py index 2cd2b16..8677bd1 100644 --- a/engine/input.py +++ b/engine/input.py @@ -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() diff --git a/engine/inventory.py b/engine/inventory.py index e8129b8..f4ef3c8 100644 --- a/engine/inventory.py +++ b/engine/inventory.py @@ -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) diff --git a/engine/item.py b/engine/item.py index 37ad003..47b1da6 100644 --- a/engine/item.py +++ b/engine/item.py @@ -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: diff --git a/engine/render/menu_draw.py b/engine/render/menu_draw.py index 052f6cb..07095bb 100644 --- a/engine/render/menu_draw.py +++ b/engine/render/menu_draw.py @@ -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) diff --git a/engine/render/renderer.py b/engine/render/renderer.py index 2e094f1..b352a6d 100644 --- a/engine/render/renderer.py +++ b/engine/render/renderer.py @@ -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: diff --git a/engine/skills.py b/engine/skills.py index c29dd49..35c5992 100644 --- a/engine/skills.py +++ b/engine/skills.py @@ -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. diff --git a/engine/species.py b/engine/species.py index a4c26c9..87fe505 100644 --- a/engine/species.py +++ b/engine/species.py @@ -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 diff --git a/engine/tile.py b/engine/tile.py index 1d5c7ed..4a75078 100644 --- a/engine/tile.py +++ b/engine/tile.py @@ -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) diff --git a/engine/ui.py b/engine/ui.py index 0ff2da7..5a6cae3 100644 --- a/engine/ui.py +++ b/engine/ui.py @@ -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) diff --git a/engine/world.py b/engine/world.py index 8af38d2..142b56b 100644 --- a/engine/world.py +++ b/engine/world.py @@ -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: diff --git a/main.py b/main.py index 7b3c4ee..31e8128 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/resources/defs/ailments.json b/resources/defs/ailments.json index 2344e30..94c1cd2 100644 --- a/resources/defs/ailments.json +++ b/resources/defs/ailments.json @@ -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 } } } diff --git a/resources/defs/body_parts.json b/resources/defs/body_parts.json index 857ff80..d059755 100644 --- a/resources/defs/body_parts.json +++ b/resources/defs/body_parts.json @@ -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 } } } diff --git a/resources/defs/entities.json b/resources/defs/entities.json index c34af0a..30a7399 100644 --- a/resources/defs/entities.json +++ b/resources/defs/entities.json @@ -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 } } diff --git a/resources/defs/items.json b/resources/defs/items.json index 0149c2c..08cdebb 100644 --- a/resources/defs/items.json +++ b/resources/defs/items.json @@ -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 } } diff --git a/resources/defs/organs.json b/resources/defs/organs.json index d175d1a..7147c12 100644 --- a/resources/defs/organs.json +++ b/resources/defs/organs.json @@ -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" } } diff --git a/resources/defs/species.json b/resources/defs/species.json index ff5d4b7..a672ab3 100644 --- a/resources/defs/species.json +++ b/resources/defs/species.json @@ -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": {} } } diff --git a/resources/defs/tiles.json b/resources/defs/tiles.json index 1e48a9f..a70deba 100644 --- a/resources/defs/tiles.json +++ b/resources/defs/tiles.json @@ -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 }, diff --git a/resources/logic/abilities.py b/resources/logic/abilities.py index 9e6e4ef..435426e 100644 --- a/resources/logic/abilities.py +++ b/resources/logic/abilities.py @@ -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 diff --git a/resources/logic/actions.py b/resources/logic/actions.py index 42300ec..3eaaa36 100644 --- a/resources/logic/actions.py +++ b/resources/logic/actions.py @@ -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: diff --git a/resources/logic/ai.py b/resources/logic/ai.py new file mode 100644 index 0000000..48b77be --- /dev/null +++ b/resources/logic/ai.py @@ -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) diff --git a/resources/logic/contagion.py b/resources/logic/contagion.py new file mode 100644 index 0000000..4e261d9 --- /dev/null +++ b/resources/logic/contagion.py @@ -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 diff --git a/resources/logic/falling.py b/resources/logic/falling.py new file mode 100644 index 0000000..e626e61 --- /dev/null +++ b/resources/logic/falling.py @@ -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 diff --git a/resources/logic/hacking.py b/resources/logic/hacking.py new file mode 100644 index 0000000..1724cc7 --- /dev/null +++ b/resources/logic/hacking.py @@ -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 diff --git a/resources/logic/health_ticks.py b/resources/logic/health_ticks.py index 8dacd63..ac3c1e5 100644 --- a/resources/logic/health_ticks.py +++ b/resources/logic/health_ticks.py @@ -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) - total_loss += part_def.bleeding_speed * ticks + 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) diff --git a/resources/logic/medical.py b/resources/logic/medical.py new file mode 100644 index 0000000..81c11a2 --- /dev/null +++ b/resources/logic/medical.py @@ -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 diff --git a/resources/logic/pickup.py b/resources/logic/pickup.py new file mode 100644 index 0000000..e882b6c --- /dev/null +++ b/resources/logic/pickup.py @@ -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 diff --git a/resources/logic/spawning.py b/resources/logic/spawning.py new file mode 100644 index 0000000..82dbb84 --- /dev/null +++ b/resources/logic/spawning.py @@ -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 diff --git a/resources/logic/turrets.py b/resources/logic/turrets.py new file mode 100644 index 0000000..7a1c8e2 --- /dev/null +++ b/resources/logic/turrets.py @@ -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), + }, + ), + ) diff --git a/tests/test_actions.py b/tests/test_actions.py index f7c77b3..e9da15f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -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" diff --git a/tests/test_adrenablend.py b/tests/test_adrenablend.py new file mode 100644 index 0000000..63e33ca --- /dev/null +++ b/tests/test_adrenablend.py @@ -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 diff --git a/tests/test_ai.py b/tests/test_ai.py new file mode 100644 index 0000000..6328bfe --- /dev/null +++ b/tests/test_ai.py @@ -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) diff --git a/tests/test_character_creator.py b/tests/test_character_creator.py index 533b3e5..c1d5326 100644 --- a/tests/test_character_creator.py +++ b/tests/test_character_creator.py @@ -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 ------------------------------------------------------------- diff --git a/tests/test_character_health.py b/tests/test_character_health.py index 299cf66..8342bc4 100644 --- a/tests/test_character_health.py +++ b/tests/test_character_health.py @@ -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) diff --git a/tests/test_character_library.py b/tests/test_character_library.py index 5cdbef5..213bdf8 100644 --- a/tests/test_character_library.py +++ b/tests/test_character_library.py @@ -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): diff --git a/tests/test_character_menu.py b/tests/test_character_menu.py index 4002f6f..63d6c27 100644 --- a/tests/test_character_menu.py +++ b/tests/test_character_menu.py @@ -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" diff --git a/tests/test_contagion.py b/tests/test_contagion.py new file mode 100644 index 0000000..3425e46 --- /dev/null +++ b/tests/test_contagion.py @@ -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") diff --git a/tests/test_defs_loading.py b/tests/test_defs_loading.py index bddc705..7b1869e 100644 --- a/tests/test_defs_loading.py +++ b/tests/test_defs_loading.py @@ -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" diff --git a/tests/test_dev_tools.py b/tests/test_dev_tools.py index 2440954..3476a3e 100644 --- a/tests/test_dev_tools.py +++ b/tests/test_dev_tools.py @@ -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) diff --git a/tests/test_falling.py b/tests/test_falling.py new file mode 100644 index 0000000..e919d5c --- /dev/null +++ b/tests/test_falling.py @@ -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 diff --git a/tests/test_game_modes.py b/tests/test_game_modes.py index 564d57d..a49f646 100644 --- a/tests/test_game_modes.py +++ b/tests/test_game_modes.py @@ -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" diff --git a/tests/test_gravity.py b/tests/test_gravity.py index f6e3d50..7bc840d 100644 --- a/tests/test_gravity.py +++ b/tests/test_gravity.py @@ -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 diff --git a/tests/test_ground_items.py b/tests/test_ground_items.py new file mode 100644 index 0000000..26a0b5f --- /dev/null +++ b/tests/test_ground_items.py @@ -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 diff --git a/tests/test_hacking.py b/tests/test_hacking.py new file mode 100644 index 0000000..c9bdce3 --- /dev/null +++ b/tests/test_hacking.py @@ -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" diff --git a/tests/test_input.py b/tests/test_input.py index 67306a2..d10a8b6 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -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 diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 5b69fd9..27801e3 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -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 diff --git a/tests/test_launch_lightning.py b/tests/test_launch_lightning.py index 6b10c01..aa8a467 100644 --- a/tests/test_launch_lightning.py +++ b/tests/test_launch_lightning.py @@ -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) diff --git a/tests/test_medical.py b/tests/test_medical.py new file mode 100644 index 0000000..d60691b --- /dev/null +++ b/tests/test_medical.py @@ -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 diff --git a/tests/test_medical_log.py b/tests/test_medical_log.py new file mode 100644 index 0000000..f1ae4d6 --- /dev/null +++ b/tests/test_medical_log.py @@ -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}" diff --git a/tests/test_menu_draw.py b/tests/test_menu_draw.py index 9ef1c40..7877c68 100644 --- a/tests/test_menu_draw.py +++ b/tests/test_menu_draw.py @@ -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) diff --git a/tests/test_organs.py b/tests/test_organs.py index 0fd0665..9612364 100644 --- a/tests/test_organs.py +++ b/tests/test_organs.py @@ -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): diff --git a/tests/test_pickup.py b/tests/test_pickup.py new file mode 100644 index 0000000..8bc4dfc --- /dev/null +++ b/tests/test_pickup.py @@ -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 diff --git a/tests/test_spawning.py b/tests/test_spawning.py new file mode 100644 index 0000000..5efa69a --- /dev/null +++ b/tests/test_spawning.py @@ -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 == {} diff --git a/tests/test_species_samples.py b/tests/test_species_samples.py index 8dbbc48..29577e5 100644 --- a/tests/test_species_samples.py +++ b/tests/test_species_samples.py @@ -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) diff --git a/tests/test_turrets.py b/tests/test_turrets.py new file mode 100644 index 0000000..4f42556 --- /dev/null +++ b/tests/test_turrets.py @@ -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