Add item pickup/drop, NPC AI framework, hacking, and turrets
- Item pickup (key) and drag-to-drop from a dedicated container popup card (backpacks, pockets), replacing the earlier inline inventory grid - NPC AI slot framework (engine/ai.py, resources/logic/ai.py) with a wander behavior, an NPC spawner UI (dev-mode, reuses the character creator), and a "secret items password" creator field - Hacking mechanic: derived tech skill (intelligence + perception), a sample lockable door, a hacking tool item, and six cyberdeck implants with bonus/roll-floor effects (including a secret dev-only one) - Turrets: character faction/tag targeting, turn-rate-limited tracking of the closest hostile, and cpu/psu/motors/gun/camera body parts with weapon-type variants Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyY5qQB6XHRoBFNbsgdRQnmain
parent
616c945509
commit
e04761f7b5
|
|
@ -15,6 +15,7 @@
|
||||||
[actions]
|
[actions]
|
||||||
leap = gp_a
|
leap = gp_a
|
||||||
block = gp_left_bumper
|
block = gp_left_bumper
|
||||||
|
pickup = gp_left_thumb
|
||||||
|
|
||||||
[hands]
|
[hands]
|
||||||
activate_right_hand = gp_right_bumper
|
activate_right_hand = gp_right_bumper
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ swim_down = Control
|
||||||
block = b
|
block = b
|
||||||
pause = Escape
|
pause = Escape
|
||||||
toggle_character_card = c
|
toggle_character_card = c
|
||||||
|
cycle_character_tab = Tab
|
||||||
toggle_dev_mode = F1
|
toggle_dev_mode = F1
|
||||||
dev_cycle_spawn = F2
|
dev_cycle_spawn = F2
|
||||||
dev_spawn_object = F3
|
dev_spawn_object = F3
|
||||||
|
dev_spawn_npc_menu = F4
|
||||||
|
pickup = f
|
||||||
|
|
||||||
[hands]
|
[hands]
|
||||||
activate_right_hand = mouse_left
|
activate_right_hand = mouse_left
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -3,10 +3,13 @@ from __future__ import annotations
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol, Sequence, TypeVar
|
from typing import Protocol, Sequence, TypeVar
|
||||||
|
|
||||||
|
from engine.ai import AIController
|
||||||
from engine.entity import Entity
|
from engine.entity import Entity
|
||||||
from engine.inventory import Inventory
|
from engine.inventory import Inventory
|
||||||
from engine.skills import default_bio
|
from engine.skills import default_bio
|
||||||
|
|
||||||
|
MAX_MEDICAL_LOG_ENTRIES = 20
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Ailment:
|
class Ailment:
|
||||||
|
|
@ -28,6 +31,12 @@ class AilmentDef:
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.5}
|
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):
|
class _SlottedOverride(Protocol):
|
||||||
|
|
@ -83,6 +92,9 @@ class BodyPart:
|
||||||
ailments: list[Ailment] = field(default_factory=list)
|
ailments: list[Ailment] = field(default_factory=list)
|
||||||
organs: list[Organ] = field(default_factory=list)
|
organs: list[Organ] = field(default_factory=list)
|
||||||
removed: bool = False
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -155,6 +167,8 @@ class Character(Entity):
|
||||||
mental_stats: dict[str, float] | None = None,
|
mental_stats: dict[str, float] | None = None,
|
||||||
bio: dict[str, int] | None = None,
|
bio: dict[str, int] | None = None,
|
||||||
ability_cooldowns: dict[str, float] | None = None,
|
ability_cooldowns: dict[str, float] | None = None,
|
||||||
|
ai: AIController | None = None,
|
||||||
|
factions: list[str] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(*args, **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.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.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.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:
|
def is_ability_ready(self, ability_id: str, now: float) -> bool:
|
||||||
return now >= self.ability_cooldowns.get(ability_id, 0.0)
|
return now >= self.ability_cooldowns.get(ability_id, 0.0)
|
||||||
|
|
@ -396,11 +431,24 @@ class Character(Entity):
|
||||||
if part_def.speed_factor <= 0.0:
|
if part_def.speed_factor <= 0.0:
|
||||||
continue
|
continue
|
||||||
total_factor += part_def.speed_factor * (part.integrity / 100.0)
|
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:
|
def effective_strength(self, registry) -> float:
|
||||||
"""Raw strength reduced by organ damage (e.g. a weakened heart saps physical power)."""
|
"""Raw strength reduced by organ damage (e.g. a weakened heart saps physical power),
|
||||||
return self.strength * (1.0 - self.organ_penalty("strength", registry))
|
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:
|
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."""
|
"""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 0.0
|
||||||
return lost_weight / total_weight
|
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:
|
def total_check_penalty(self, check_id: str, registry) -> float:
|
||||||
"""Combined penalty from damaged body parts, damaged organs, and active ailments for a
|
"""Combined penalty from damaged body parts, damaged organs, active ailments, and any
|
||||||
named check, applied multiplicatively (as independent sources of lost effectiveness) so
|
innate species trait for a named check, applied multiplicatively (as independent
|
||||||
it can never exceed 1.0 lost. Reduces to skill_penalty alone when nothing else affects
|
sources of lost effectiveness) so it can never exceed 1.0 lost. Reduces to
|
||||||
this check or everything else is healthy - existing skill-only checks are unaffected.
|
skill_penalty alone when nothing else affects this check or everything else is healthy
|
||||||
|
- existing skill-only checks are unaffected.
|
||||||
"""
|
"""
|
||||||
remaining = (
|
remaining = (
|
||||||
(1.0 - self.skill_penalty(check_id, registry))
|
(1.0 - self.skill_penalty(check_id, registry))
|
||||||
* (1.0 - self.organ_penalty(check_id, registry))
|
* (1.0 - self.organ_penalty(check_id, registry))
|
||||||
* (1.0 - self.ailment_penalty(check_id, registry))
|
* (1.0 - self.ailment_penalty(check_id, registry))
|
||||||
|
* (1.0 - self.species_penalty(check_id, registry))
|
||||||
)
|
)
|
||||||
return 1.0 - remaining
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# 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.
|
# 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
|
@dataclass
|
||||||
|
|
@ -49,6 +60,7 @@ class CharacterCreator:
|
||||||
max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL
|
max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
|
secret_password: str = "" # optional; see SECRET_ITEM_PASSWORD_GRANTS
|
||||||
species_id: str | None = None
|
species_id: str | None = None
|
||||||
gender: str | None = None
|
gender: str | None = None
|
||||||
body_type: str | None = None
|
body_type: str | None = None
|
||||||
|
|
@ -61,6 +73,9 @@ class CharacterCreator:
|
||||||
def set_name(self, name: str) -> None:
|
def set_name(self, name: str) -> None:
|
||||||
self.name = name.strip()
|
self.name = name.strip()
|
||||||
|
|
||||||
|
def set_secret_password(self, password: str) -> None:
|
||||||
|
self.secret_password = password
|
||||||
|
|
||||||
def available_species(self) -> list[str]:
|
def available_species(self) -> list[str]:
|
||||||
"""Species ids a player can actually pick, for a UI to list - selectable_species
|
"""Species ids a player can actually pick, for a UI to list - selectable_species
|
||||||
filtered against whatever's actually loaded in the registry.
|
filtered against whatever's actually loaded in the registry.
|
||||||
|
|
@ -232,7 +247,7 @@ class CharacterCreator:
|
||||||
if not self.is_ready():
|
if not self.is_ready():
|
||||||
return None
|
return None
|
||||||
assert self.species_id is not None
|
assert self.species_id is not None
|
||||||
return Character(
|
character = Character(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
species_id=self.species_id,
|
species_id=self.species_id,
|
||||||
gender=self.gender,
|
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()],
|
default_clothing=[ClothingPiece(slot, item_def_id) for slot, item_def_id in self.starter_clothing.items()],
|
||||||
bio=dict(self.bio),
|
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:
|
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) -
|
"""Builds the character and saves it into the library under (account_id, character_id) -
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,14 @@ DEFAULT_KEYBINDINGS: dict[str, list[str]] = {
|
||||||
"select_ability_9": ["9"],
|
"select_ability_9": ["9"],
|
||||||
"select_ability_0": ["0"],
|
"select_ability_0": ["0"],
|
||||||
"activate_selected_ability": ["e"],
|
"activate_selected_ability": ["e"],
|
||||||
|
"pickup": ["f"],
|
||||||
"pause": ["Escape"],
|
"pause": ["Escape"],
|
||||||
"toggle_character_card": ["c"],
|
"toggle_character_card": ["c"],
|
||||||
|
"cycle_character_tab": ["Tab"],
|
||||||
"toggle_dev_mode": ["F1"],
|
"toggle_dev_mode": ["F1"],
|
||||||
"dev_cycle_spawn": ["F2"],
|
"dev_cycle_spawn": ["F2"],
|
||||||
"dev_spawn_object": ["F3"],
|
"dev_spawn_object": ["F3"],
|
||||||
|
"dev_spawn_npc_menu": ["F4"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Human-friendly config names for keys that don't have a printable representation.
|
# Human-friendly config names for keys that don't have a printable representation.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ class EntityDef:
|
||||||
species_id: str | None = None
|
species_id: str | None = None
|
||||||
default_clothing: list[ClothingPiece] = field(default_factory=list)
|
default_clothing: list[ClothingPiece] = field(default_factory=list)
|
||||||
inventory_size: tuple[int, int] | None = None
|
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:
|
class DefRegistry:
|
||||||
|
|
@ -58,6 +65,8 @@ class DefRegistry:
|
||||||
is_helm=fields.get("is_helm", False),
|
is_helm=fields.get("is_helm", False),
|
||||||
staircase_direction=fields.get("staircase_direction"),
|
staircase_direction=fields.get("staircase_direction"),
|
||||||
speed_multiplier=fields.get("speed_multiplier", 1.0),
|
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:
|
def _load_items(self, path: Path) -> None:
|
||||||
|
|
@ -102,6 +111,11 @@ class DefRegistry:
|
||||||
melee_knockback=fields.get("melee_knockback", 0.0),
|
melee_knockback=fields.get("melee_knockback", 0.0),
|
||||||
grants_ability=fields.get("grants_ability"),
|
grants_ability=fields.get("grants_ability"),
|
||||||
is_implant=fields.get("is_implant", False),
|
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:
|
def _load_body_parts(self, path: Path) -> None:
|
||||||
|
|
@ -137,6 +151,8 @@ class DefRegistry:
|
||||||
id=def_id,
|
id=def_id,
|
||||||
name=fields.get("name", def_id),
|
name=fields.get("name", def_id),
|
||||||
check_weights=dict(fields.get("check_weights", {})),
|
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:
|
def _load_species(self, path: Path) -> None:
|
||||||
|
|
@ -167,7 +183,9 @@ class DefRegistry:
|
||||||
abilities=abilities,
|
abilities=abilities,
|
||||||
blood_danger_threshold=fields.get("blood_danger_threshold", 50.0),
|
blood_danger_threshold=fields.get("blood_danger_threshold", 50.0),
|
||||||
blood_critical_threshold=fields.get("blood_critical_threshold", 25.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", [])),
|
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:
|
def _load_entities(self, path: Path) -> None:
|
||||||
|
|
@ -184,6 +202,9 @@ class DefRegistry:
|
||||||
species_id=fields.get("species_id"),
|
species_id=fields.get("species_id"),
|
||||||
default_clothing=default_clothing,
|
default_clothing=default_clothing,
|
||||||
inventory_size=tuple(inventory_size) if inventory_size else None,
|
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:
|
def get_tile_layer_def(self, layer: str, def_id: str | None) -> TileLayerDef | None:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ class DevSpawnMenu:
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self.item_ids:
|
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:
|
def cycle(self, delta: int) -> None:
|
||||||
if self.item_ids:
|
if self.item_ids:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class Mode(Enum):
|
||||||
CONTROLS = "controls"
|
CONTROLS = "controls"
|
||||||
PAUSED = "paused"
|
PAUSED = "paused"
|
||||||
PLAYING = "playing"
|
PLAYING = "playing"
|
||||||
|
NPC_SPAWN = "npc_spawn" # dev-mode-only: reuses CharacterCreationFlow to build and place an NPC
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -65,9 +66,11 @@ class PauseMenu:
|
||||||
@dataclass
|
@dataclass
|
||||||
class CharacterCreationFlow:
|
class CharacterCreationFlow:
|
||||||
"""Thin step-sequencing wrapper around CharacterCreator (engine/character_creator.py) for
|
"""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
|
the launch-time "new game" flow: pick a species (left/right arrows), type a name, then an
|
||||||
keys + backspace), then confirm. CharacterCreator itself already validates every choice -
|
optional "secret items password" (see CharacterCreator.SECRET_ITEM_PASSWORD_GRANTS - typing
|
||||||
this only tracks which step is active and routes raw menu input to the right one.
|
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) -
|
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
|
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()
|
self._lock_in_species()
|
||||||
|
|
||||||
def type_char(self, ch: str) -> None:
|
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)
|
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:
|
def backspace(self) -> None:
|
||||||
if self.step == "name":
|
if self.step == "name":
|
||||||
self.creator.set_name(self.creator.name[:-1])
|
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:
|
def advance(self) -> bool:
|
||||||
"""Moves to the next step. Returns True once the flow is actually finished (final step
|
"""Moves to the next step. Returns True once the flow is actually finished (password
|
||||||
confirmed with a valid name) - main.py builds the Character only then, via
|
step confirmed - the password itself is optional, see CharacterCreator.build_character)
|
||||||
self.creator.build_character().
|
- main.py builds the Character only then, via self.creator.build_character().
|
||||||
"""
|
"""
|
||||||
if self.step == "species":
|
if self.step == "species":
|
||||||
self.step = "name"
|
self.step = "name"
|
||||||
return False
|
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:
|
def go_back(self) -> bool:
|
||||||
"""Steps back one stage; returns False if already at the first step (caller should
|
"""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).
|
leave character creation entirely, e.g. back to the main menu).
|
||||||
"""
|
"""
|
||||||
|
if self.step == "password":
|
||||||
|
self.step = "name"
|
||||||
|
return True
|
||||||
if self.step == "name":
|
if self.step == "name":
|
||||||
self.step = "species"
|
self.step = "species"
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ DEFAULT_GAMEPAD_BINDINGS: dict[str, list[str]] = {
|
||||||
"activate_right_hand_2": ["gp_right_trigger"],
|
"activate_right_hand_2": ["gp_right_trigger"],
|
||||||
"activate_left_hand_2": ["gp_left_trigger"],
|
"activate_left_hand_2": ["gp_left_trigger"],
|
||||||
"activate_selected_ability": ["gp_b"],
|
"activate_selected_ability": ["gp_b"],
|
||||||
|
"pickup": ["gp_left_thumb"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ HAND_ACTIVATION_ACTIONS: dict[str, str] = {
|
||||||
"activate_left_hand_2": "left_hand_2",
|
"activate_left_hand_2": "left_hand_2",
|
||||||
}
|
}
|
||||||
|
|
||||||
# jupyter_rfb/rendercanvas pointer button numbering (DOM-style): 0 = left, 2 = right.
|
# This project's installed rendercanvas glfw backend numbers buttons 1 = left, 2 = right (see
|
||||||
MOUSE_BUTTON_KEYS: dict[int, str] = {0: "mouse_left", 2: "mouse_right"}
|
# 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
|
# 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.
|
# 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_hand_activation: str | None = None
|
||||||
self.pending_ability_select: int | None = None
|
self.pending_ability_select: int | None = None
|
||||||
self.pending_activate_ability_bar = False
|
self.pending_activate_ability_bar = False
|
||||||
|
self.pending_pickup = False
|
||||||
self.is_blocking = False
|
self.is_blocking = False
|
||||||
self.facing: tuple[int, int, int] = DEFAULT_FACING
|
self.facing: tuple[int, int, int] = DEFAULT_FACING
|
||||||
self.pointer_pos: tuple[float, float] | None = None
|
self.pointer_pos: tuple[float, float] | None = None
|
||||||
|
|
@ -117,6 +121,8 @@ class InputState:
|
||||||
self.pending_ability_select = SELECT_ABILITY_ACTIONS[action]
|
self.pending_ability_select = SELECT_ABILITY_ACTIONS[action]
|
||||||
elif action == "activate_selected_ability":
|
elif action == "activate_selected_ability":
|
||||||
self.pending_activate_ability_bar = True
|
self.pending_activate_ability_bar = True
|
||||||
|
elif action == "pickup":
|
||||||
|
self.pending_pickup = True
|
||||||
|
|
||||||
def _apply_up(self, action: str) -> None:
|
def _apply_up(self, action: str) -> None:
|
||||||
if action in MOVE_ACTION_VECTORS:
|
if action in MOVE_ACTION_VECTORS:
|
||||||
|
|
@ -187,6 +193,10 @@ class InputState:
|
||||||
fired, self.pending_activate_ability_bar = self.pending_activate_ability_bar, False
|
fired, self.pending_activate_ability_bar = self.pending_activate_ability_bar, False
|
||||||
return fired
|
return fired
|
||||||
|
|
||||||
|
def consume_pickup(self) -> bool:
|
||||||
|
fired, self.pending_pickup = self.pending_pickup, False
|
||||||
|
return fired
|
||||||
|
|
||||||
def clear_held_state(self) -> None:
|
def clear_held_state(self) -> None:
|
||||||
"""Drops all held-key/blocking state - call whenever leaving PLAYING for a menu, since
|
"""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
|
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_swim_down = False
|
||||||
self.pending_hand_activation = None
|
self.pending_hand_activation = None
|
||||||
self.pending_activate_ability_bar = False
|
self.pending_activate_ability_bar = False
|
||||||
|
self.pending_pickup = False
|
||||||
self._prev_gamepad_buttons = set()
|
self._prev_gamepad_buttons = set()
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,16 @@ class Inventory:
|
||||||
|
|
||||||
def items(self) -> list[PlacedItem]:
|
def items(self) -> list[PlacedItem]:
|
||||||
return list(self._items.values())
|
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)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,17 @@ class ItemDef:
|
||||||
melee_knockback: float = 0.0 # melee weapons: base knockback tiles, scaled by attacker strength
|
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
|
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
|
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:
|
class ItemInstance:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
_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
|
panel_w, panel_h = 420.0, 220.0
|
||||||
x = (viewport_w - panel_w) / 2
|
x = (viewport_w - panel_w) / 2
|
||||||
y = (viewport_h - panel_h) / 2
|
y = (viewport_h - panel_h) / 2
|
||||||
draw_panel(buckets, "background", x, y, panel_w, panel_h)
|
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":
|
if flow.step == "species":
|
||||||
species = flow.species_list()
|
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"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, 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)
|
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"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, 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)
|
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:
|
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_W = 260.0
|
||||||
CHARACTER_CARD_H = 200.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_W = 30.0
|
||||||
ABILITY_BAR_SLOT_H = 30.0
|
ABILITY_BAR_SLOT_H = 30.0
|
||||||
ABILITY_BAR_GAP = 3.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)
|
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]:
|
def _medical_lines(character, registry) -> list[str]:
|
||||||
"""One line per body part (integrity, or REMOVED) plus an indented sub-line for each of its
|
"""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)
|
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()):
|
for slot, part in sorted(character.resolved_body_parts().items()):
|
||||||
if part is None:
|
if part is None:
|
||||||
continue
|
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
|
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}%"
|
status = "removed" if organ.removed else f"{organ.integrity:.0f}%"
|
||||||
lines.append(f" {organ_name}: {status}")
|
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
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def draw_character_card(buckets, character, menu: CharacterMenu, registry, viewport_h: float) -> None:
|
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
|
"""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
|
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
|
x, y, w, h = character_card_rect(viewport_h)
|
||||||
y = viewport_h - CHARACTER_CARD_H - PADDING
|
draw_panel(buckets, "background", x, y, w, h)
|
||||||
draw_panel(buckets, "background", x, y, CHARACTER_CARD_W, CHARACTER_CARD_H)
|
|
||||||
|
|
||||||
name = character.name or "(unnamed)"
|
name = character.name or "(unnamed)"
|
||||||
draw_text(buckets, name, x + 10, y + 10)
|
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
|
body_y = tab_y + ROW_H * 1.5
|
||||||
if menu.active_tab == "bio":
|
if menu.active_tab == "bio":
|
||||||
draw_text(buckets, f"Species: {character.species_id or '-'}", x + 10, body_y)
|
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())):
|
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))
|
draw_text(buckets, f"{skill_id}: {value}", x + 10, body_y + ROW_H * (2 + i))
|
||||||
elif menu.active_tab == "equipment":
|
elif menu.active_tab == "equipment":
|
||||||
for i, piece in enumerate(character.clothing or character.default_clothing):
|
rows = _equipment_rows(character)
|
||||||
draw_text(buckets, f"{piece.slot}: {piece.item_def_id}", x + 10, body_y + ROW_H * i)
|
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:
|
else:
|
||||||
for i, line in enumerate(_medical_lines(character, registry)):
|
for i, line in enumerate(_medical_lines(character, registry)):
|
||||||
draw_text(buckets, line, x + 10, body_y + ROW_H * i)
|
draw_text(buckets, line, x + 10, body_y + ROW_H * i)
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ class Renderer:
|
||||||
if z is None:
|
if z is None:
|
||||||
continue
|
continue
|
||||||
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
|
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:
|
if depth > 0:
|
||||||
self._draw_depth_shadow(m, x, y, offset, rotation, depth, buckets)
|
self._draw_depth_shadow(m, x, y, offset, rotation, depth, buckets)
|
||||||
else:
|
else:
|
||||||
|
|
@ -146,6 +147,7 @@ class Renderer:
|
||||||
for y in range(m.height):
|
for y in range(m.height):
|
||||||
for x in range(m.width):
|
for x in range(m.width):
|
||||||
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
|
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):
|
for entity in world.entities_on_map(m.map_id):
|
||||||
if isinstance(entity, Character):
|
if isinstance(entity, Character):
|
||||||
|
|
@ -171,6 +173,35 @@ class Renderer:
|
||||||
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
|
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))
|
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(
|
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]]
|
self, m: Map, x: int, y: int, offset: tuple[int, int, int], rotation: int, depth: int, buckets: dict[str, list[float]]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ DND_SKILLS = (
|
||||||
"deception",
|
"deception",
|
||||||
"history",
|
"history",
|
||||||
"insight",
|
"insight",
|
||||||
|
"intelligence",
|
||||||
"intimidation",
|
"intimidation",
|
||||||
"investigation",
|
"investigation",
|
||||||
"medicine",
|
"medicine",
|
||||||
|
|
@ -25,3 +26,9 @@ DND_SKILLS = (
|
||||||
|
|
||||||
def default_bio() -> dict[str, int]:
|
def default_bio() -> dict[str, int]:
|
||||||
return {skill: 0 for skill in DND_SKILLS}
|
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.
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,17 @@ class SpeciesDef:
|
||||||
# for character creation (e.g. "average"/"thin"/"fat"/"hulk") - NOT the same as body_type
|
# 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
|
# 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
|
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)
|
# creation; empty for species with no hair (e.g. reptilian, hydraulibot, motorbot)
|
||||||
is_synthetic: bool = False # mechanical/electronic organs (currently just "bot") - lets game
|
is_synthetic: bool = False # mechanical/electronic organs (currently hydraulibot/motorbot) -
|
||||||
# logic gate effects like a lightning-induced short circuit without hardcoding species ids
|
# lets game logic gate effects like a lightning-induced short circuit without hardcoding
|
||||||
|
# species ids
|
||||||
abilities: list[AbilityDef] = field(default_factory=list)
|
abilities: list[AbilityDef] = field(default_factory=list)
|
||||||
blood_danger_threshold: float = 50.0 # % blood remaining
|
blood_danger_threshold: float = 50.0 # % blood remaining
|
||||||
blood_critical_threshold: float = 25.0
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class TileLayerDef:
|
||||||
is_helm: bool = False # standing here lets a character steer the embedded map it's part of
|
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
|
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
|
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)
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
38
engine/ui.py
38
engine/ui.py
|
|
@ -124,15 +124,25 @@ class CharacterMenu:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TABS = ("bio", "equipment", "medical")
|
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"
|
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:
|
def select_tab(self, tab: str) -> None:
|
||||||
if tab in self.TABS:
|
if tab in self.TABS:
|
||||||
self.active_tab = tab
|
self.active_tab = tab
|
||||||
self.open_backpack_slot = None # leaving equipment closes any open popup
|
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:
|
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
|
"""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
|
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:
|
def close_backpack_popup(self) -> None:
|
||||||
self.open_backpack_slot = 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)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
||||||
|
|
||||||
from engine.character import Character
|
from engine.character import Character
|
||||||
from engine.entity import Entity, EntityPosition
|
from engine.entity import Entity, EntityPosition
|
||||||
|
from engine.item import ItemInstance
|
||||||
from engine.map import Map
|
from engine.map import Map
|
||||||
|
|
||||||
LEAP_DISTANCE = 2
|
LEAP_DISTANCE = 2
|
||||||
|
|
@ -46,6 +47,39 @@ class World:
|
||||||
self.entities: dict[str, Entity] = {}
|
self.entities: dict[str, Entity] = {}
|
||||||
self.player: Entity | None = None
|
self.player: Entity | None = None
|
||||||
self.time_warp_zones: list[TimeWarpZone] = []
|
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:
|
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)
|
"""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
|
x, y, z = entity.position.x, entity.position.y, entity.position.z
|
||||||
if map_.get_tile(x, y, z).flooring is not None:
|
if map_.get_tile(x, y, z).flooring is not None:
|
||||||
return
|
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)
|
candidate = map_.get_tile(x, y, nz)
|
||||||
if candidate.flooring is not None and candidate.is_walkable(self.registry):
|
if candidate.flooring is not None and candidate.is_walkable(self.registry):
|
||||||
entity.position = EntityPosition(map_.map_id, x, y, nz)
|
entity.position = EntityPosition(map_.map_id, x, y, nz)
|
||||||
|
self._pending_fall_distance[entity.entity_id] = start_z - nz
|
||||||
return
|
return
|
||||||
|
|
||||||
def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool:
|
def _move_within_continuous(self, entity: Entity, map_: Map, x: float, y: float, z: float) -> bool:
|
||||||
|
|
|
||||||
155
main.py
155
main.py
|
|
@ -7,16 +7,18 @@ from pathlib import Path
|
||||||
|
|
||||||
import glfw
|
import glfw
|
||||||
|
|
||||||
|
from engine.ai import AIController
|
||||||
from engine.aim import snap_to_8way
|
from engine.aim import snap_to_8way
|
||||||
from engine.camera import OrthoCamera
|
from engine.camera import OrthoCamera
|
||||||
from engine.character import Character
|
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.config import KeyBindings
|
||||||
from engine.controls_menu import ControlsMenu
|
from engine.controls_menu import ControlsMenu
|
||||||
from engine.db import WorldRepository
|
from engine.db import WorldRepository
|
||||||
from engine.defs import DefRegistry
|
from engine.defs import DefRegistry
|
||||||
from engine.dev_config import DevConfig
|
from engine.dev_config import DevConfig
|
||||||
from engine.dev_tools import DevSpawnMenu
|
from engine.dev_tools import DevSpawnMenu
|
||||||
|
from engine.entity import EntityPosition
|
||||||
from engine.gamepad import GamepadBindings, GamepadButtonEdgeTracker, GamepadState
|
from engine.gamepad import GamepadBindings, GamepadButtonEdgeTracker, GamepadState
|
||||||
from engine.game_modes import CharacterCreationFlow, MainMenu, Mode, PauseMenu
|
from engine.game_modes import CharacterCreationFlow, MainMenu, Mode, PauseMenu
|
||||||
from engine.input import InputState
|
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.placeholder_textures import generate_depth_shadow_texture, generate_placeholder_textures
|
||||||
from engine.render.device import configure_context, request_device
|
from engine.render.device import configure_context, request_device
|
||||||
from engine.render.menu_draw import (
|
from engine.render.menu_draw import (
|
||||||
|
backpack_grid_origin,
|
||||||
|
backpack_popup_rect,
|
||||||
|
character_card_rect,
|
||||||
draw_ability_bar,
|
draw_ability_bar,
|
||||||
|
draw_backpack_popup,
|
||||||
draw_character_card,
|
draw_character_card,
|
||||||
draw_character_creation,
|
draw_character_creation,
|
||||||
draw_controls_menu,
|
draw_controls_menu,
|
||||||
|
draw_drag_ghost,
|
||||||
draw_main_menu,
|
draw_main_menu,
|
||||||
draw_pause_menu,
|
draw_pause_menu,
|
||||||
draw_world_creation,
|
draw_world_creation,
|
||||||
|
equipment_row_at,
|
||||||
|
inventory_cell_at,
|
||||||
|
point_in_rect,
|
||||||
)
|
)
|
||||||
from engine.render.pipeline import QuadPipeline
|
from engine.render.pipeline import QuadPipeline
|
||||||
from engine.render.renderer import Renderer
|
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 import World
|
||||||
from engine.world_creation import WorldCreationMenu, build_world_from_template, find_spawn_position
|
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.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.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.steering import advance_all_ship_rotations, at_helm, try_steer_ship
|
||||||
from resources.logic.swimming import in_water, try_swim_vertical
|
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.main_menu = MainMenu(has_existing_save=world is not None)
|
||||||
self.char_flow: CharacterCreationFlow | None = None
|
self.char_flow: CharacterCreationFlow | None = None
|
||||||
self.pending_character: Character | None = None # built, awaiting a world to spawn into
|
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.world_creation_menu: WorldCreationMenu | None = None
|
||||||
self.controls_menu: ControlsMenu | None = None
|
self.controls_menu: ControlsMenu | None = None
|
||||||
self.pause_menu: PauseMenu | 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.return_mode: Mode = Mode.MAIN_MENU # where Escape from the controls menu goes back to
|
||||||
self.character_card = CharacterMenu()
|
self.character_card = CharacterMenu()
|
||||||
self.character_card_visible = False
|
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.ability_bar = AbilityBar()
|
||||||
self.dev_spawn_menu = DevSpawnMenu(registry)
|
self.dev_spawn_menu = DevSpawnMenu(registry)
|
||||||
|
|
||||||
|
|
@ -233,6 +249,47 @@ def main() -> None:
|
||||||
elif len(key) == 1:
|
elif len(key) == 1:
|
||||||
flow.type_char(key)
|
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:
|
def handle_world_create_key(key: str) -> None:
|
||||||
menu = state.world_creation_menu
|
menu = state.world_creation_menu
|
||||||
assert menu is not None
|
assert menu is not None
|
||||||
|
|
@ -315,6 +372,8 @@ def main() -> None:
|
||||||
handle_controls_key(key)
|
handle_controls_key(key)
|
||||||
elif state.mode == Mode.PAUSED:
|
elif state.mode == Mode.PAUSED:
|
||||||
handle_pause_key(key)
|
handle_pause_key(key)
|
||||||
|
elif state.mode == Mode.NPC_SPAWN:
|
||||||
|
handle_npc_spawn_key(key)
|
||||||
|
|
||||||
def handle_playing_key_down(event: dict) -> None:
|
def handle_playing_key_down(event: dict) -> None:
|
||||||
key = event.get("key", "")
|
key = event.get("key", "")
|
||||||
|
|
@ -324,6 +383,13 @@ def main() -> None:
|
||||||
return
|
return
|
||||||
if action == "toggle_character_card":
|
if action == "toggle_character_card":
|
||||||
state.character_card_visible = not state.character_card_visible
|
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
|
return
|
||||||
if action == "toggle_dev_mode":
|
if action == "toggle_dev_mode":
|
||||||
dev_config.toggle()
|
dev_config.toggle()
|
||||||
|
|
@ -337,8 +403,65 @@ def main() -> None:
|
||||||
if isinstance(player, Character):
|
if isinstance(player, Character):
|
||||||
state.dev_spawn_menu.spawn_into(player)
|
state.dev_spawn_menu.spawn_into(player)
|
||||||
return
|
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)
|
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:
|
def on_event(event: dict) -> None:
|
||||||
event_type = event.get("event_type")
|
event_type = event.get("event_type")
|
||||||
|
|
||||||
|
|
@ -353,12 +476,18 @@ def main() -> None:
|
||||||
handle_menu_key(event.get("key", ""))
|
handle_menu_key(event.get("key", ""))
|
||||||
return
|
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":
|
if event_type == "key_down":
|
||||||
handle_playing_key_down(event)
|
handle_playing_key_down(event)
|
||||||
else:
|
else:
|
||||||
input_state.handle_event(event)
|
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]:
|
def current_aim_direction(player: Character) -> tuple[int, int, int]:
|
||||||
assert player.position is not None
|
assert player.position is not None
|
||||||
|
|
@ -403,6 +532,9 @@ def main() -> None:
|
||||||
trigger_pause()
|
trigger_pause()
|
||||||
elif "gp_back" in gamepad_menu_edges:
|
elif "gp_back" in gamepad_menu_edges:
|
||||||
state.character_card_visible = not state.character_card_visible
|
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:
|
else:
|
||||||
for button in gamepad_menu_edges:
|
for button in gamepad_menu_edges:
|
||||||
key = GAMEPAD_MENU_KEY_MAP.get(button)
|
key = GAMEPAD_MENU_KEY_MAP.get(button)
|
||||||
|
|
@ -422,6 +554,9 @@ def main() -> None:
|
||||||
if isinstance(entity, Character):
|
if isinstance(entity, Character):
|
||||||
apply_bleeding_tick(entity, registry, ticks)
|
apply_bleeding_tick(entity, registry, ticks)
|
||||||
apply_organ_interactions_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
|
player = world.player
|
||||||
if isinstance(player, Character) and player.position is not None:
|
if isinstance(player, Character) and player.position is not None:
|
||||||
|
|
@ -457,6 +592,13 @@ def main() -> None:
|
||||||
if input_state.consume_swim_down():
|
if input_state.consume_swim_down():
|
||||||
try_swim_vertical(player, world, -1)
|
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()
|
hand = input_state.consume_hand_activation()
|
||||||
if hand is not None:
|
if hand is not None:
|
||||||
activate_hand(
|
activate_hand(
|
||||||
|
|
@ -483,6 +625,10 @@ def main() -> None:
|
||||||
draw_ability_bar(ui_buckets, state.ability_bar, viewport_h)
|
draw_ability_bar(ui_buckets, state.ability_bar, viewport_h)
|
||||||
if state.character_card_visible:
|
if state.character_card_visible:
|
||||||
draw_character_card(ui_buckets, player, state.character_card, registry, viewport_h)
|
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:
|
if dev_config.enabled:
|
||||||
draw_text(ui_buckets, "DEV MODE", viewport_w - 130, 10)
|
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)
|
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:
|
elif state.mode == Mode.PAUSED:
|
||||||
assert state.pause_menu is not None
|
assert state.pause_menu is not None
|
||||||
draw_pause_menu(ui_buckets, state.pause_menu, viewport_w, viewport_h)
|
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)
|
renderer.render_frame(context, backdrop, camera, ui_buckets)
|
||||||
|
|
||||||
window.canvas.request_draw(draw_frame)
|
window.canvas.request_draw(draw_frame)
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,40 @@
|
||||||
"shortcircuit": {
|
"shortcircuit": {
|
||||||
"name": "Short Circuit",
|
"name": "Short Circuit",
|
||||||
"check_weights": { "insight": 0.7, "perception": 0.6, "sleight_of_hand": 0.5 }
|
"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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,51 +185,102 @@
|
||||||
"speed_factor": 0.55
|
"speed_factor": 0.55
|
||||||
},
|
},
|
||||||
|
|
||||||
"bot_head": {
|
"hydraulibot_head": {
|
||||||
"slot": "head",
|
"slot": "head",
|
||||||
"texture": "characters/bot_head.png",
|
"texture": "characters/hydraulibot_head.png",
|
||||||
"color": [150, 150, 160],
|
"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_weight": 0.0,
|
||||||
"bleeding_speed": 0.0,
|
"bleeding_speed": 0.0,
|
||||||
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
"skill_weights": { "perception": 0.6, "insight": 0.4 }
|
||||||
},
|
},
|
||||||
"bot_torso": {
|
"motorbot_torso": {
|
||||||
"slot": "torso",
|
"slot": "torso",
|
||||||
"texture": "characters/bot_torso.png",
|
"texture": "characters/motorbot_torso.png",
|
||||||
"color": [110, 110, 120],
|
"color": [70, 70, 80],
|
||||||
"bleeding_weight": 0.0,
|
"bleeding_weight": 0.0,
|
||||||
"bleeding_speed": 0.0,
|
"bleeding_speed": 0.0,
|
||||||
"skill_weights": { "athletics": 0.5 }
|
"skill_weights": { "athletics": 0.5 }
|
||||||
},
|
},
|
||||||
"bot_left_arm": {
|
"motorbot_left_arm": {
|
||||||
"slot": "left_arm",
|
"slot": "left_arm",
|
||||||
"texture": "characters/bot_left_arm.png",
|
"texture": "characters/motorbot_left_arm.png",
|
||||||
"color": [150, 150, 160],
|
"color": [95, 95, 105],
|
||||||
"bleeding_weight": 0.0,
|
"bleeding_weight": 0.0,
|
||||||
"bleeding_speed": 0.0,
|
"bleeding_speed": 0.0,
|
||||||
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
},
|
},
|
||||||
"bot_right_arm": {
|
"motorbot_right_arm": {
|
||||||
"slot": "right_arm",
|
"slot": "right_arm",
|
||||||
"texture": "characters/bot_right_arm.png",
|
"texture": "characters/motorbot_right_arm.png",
|
||||||
"color": [150, 150, 160],
|
"color": [95, 95, 105],
|
||||||
"bleeding_weight": 0.0,
|
"bleeding_weight": 0.0,
|
||||||
"bleeding_speed": 0.0,
|
"bleeding_speed": 0.0,
|
||||||
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
"skill_weights": { "sleight_of_hand": 0.5, "athletics": 0.25 }
|
||||||
},
|
},
|
||||||
"bot_left_leg": {
|
"motorbot_left_leg": {
|
||||||
"slot": "left_leg",
|
"slot": "left_leg",
|
||||||
"texture": "characters/bot_left_leg.png",
|
"texture": "characters/motorbot_left_leg.png",
|
||||||
"color": [90, 90, 100],
|
"color": [55, 55, 65],
|
||||||
"bleeding_weight": 0.0,
|
"bleeding_weight": 0.0,
|
||||||
"bleeding_speed": 0.0,
|
"bleeding_speed": 0.0,
|
||||||
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
"speed_factor": 0.5
|
"speed_factor": 0.5
|
||||||
},
|
},
|
||||||
"bot_right_leg": {
|
"motorbot_right_leg": {
|
||||||
"slot": "right_leg",
|
"slot": "right_leg",
|
||||||
"texture": "characters/bot_right_leg.png",
|
"texture": "characters/motorbot_right_leg.png",
|
||||||
"color": [90, 90, 100],
|
"color": [55, 55, 65],
|
||||||
"bleeding_weight": 0.0,
|
"bleeding_weight": 0.0,
|
||||||
"bleeding_speed": 0.0,
|
"bleeding_speed": 0.0,
|
||||||
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
"skill_weights": { "acrobatics": 0.5, "athletics": 0.25, "stealth": 0.25 },
|
||||||
|
|
@ -287,5 +338,43 @@
|
||||||
"bleeding_speed": 1.5,
|
"bleeding_speed": 1.5,
|
||||||
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
|
"skill_weights": { "acrobatics": 0.25, "athletics": 0.125, "stealth": 0.125 },
|
||||||
"speed_factor": 0.25
|
"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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
"name": "Reptilian",
|
"name": "Reptilian",
|
||||||
"species_id": "reptilian_quad",
|
"species_id": "reptilian_quad",
|
||||||
"default_clothing": [],
|
"default_clothing": [],
|
||||||
"inventory_size": [6, 5]
|
"inventory_size": [6, 5],
|
||||||
|
"ai_behavior_id": "wander"
|
||||||
},
|
},
|
||||||
"npc_alien": {
|
"npc_alien": {
|
||||||
"name": "Zenari",
|
"name": "Zenari",
|
||||||
|
|
@ -17,9 +18,15 @@
|
||||||
"default_clothing": [],
|
"default_clothing": [],
|
||||||
"inventory_size": [6, 5]
|
"inventory_size": [6, 5]
|
||||||
},
|
},
|
||||||
"npc_bot": {
|
"npc_hydraulibot": {
|
||||||
"name": "Bot",
|
"name": "Hydraulibot",
|
||||||
"species_id": "bot",
|
"species_id": "hydraulibot",
|
||||||
|
"default_clothing": [],
|
||||||
|
"inventory_size": [6, 5]
|
||||||
|
},
|
||||||
|
"npc_motorbot": {
|
||||||
|
"name": "Motorbot",
|
||||||
|
"species_id": "motorbot",
|
||||||
"default_clothing": [],
|
"default_clothing": [],
|
||||||
"inventory_size": [6, 5]
|
"inventory_size": [6, 5]
|
||||||
},
|
},
|
||||||
|
|
@ -34,5 +41,21 @@
|
||||||
"species_id": "dog",
|
"species_id": "dog",
|
||||||
"default_clothing": ["dog_harness", "dog_saddlebag"],
|
"default_clothing": ["dog_harness", "dog_saddlebag"],
|
||||||
"inventory_size": null
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,15 @@
|
||||||
"tool_kind": "constructor",
|
"tool_kind": "constructor",
|
||||||
"hands_required": 2
|
"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": {
|
"pistol_basic": {
|
||||||
"name": "Basic Pistol",
|
"name": "Basic Pistol",
|
||||||
"width": 1,
|
"width": 1,
|
||||||
|
|
@ -113,6 +122,17 @@
|
||||||
"projectile_type": "bullet",
|
"projectile_type": "bullet",
|
||||||
"hands_required": 1
|
"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": {
|
"bow_basic": {
|
||||||
"name": "Basic Bow",
|
"name": "Basic Bow",
|
||||||
"width": 1,
|
"width": 1,
|
||||||
|
|
@ -289,5 +309,99 @@
|
||||||
"color": [90, 160, 220],
|
"color": [90, 160, 220],
|
||||||
"hands_required": 1,
|
"hands_required": 1,
|
||||||
"grants_ability": "launch_lightning"
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,18 @@
|
||||||
"host_slot": "head",
|
"host_slot": "head",
|
||||||
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
"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": {
|
"reptilian_heart": {
|
||||||
"name": "Heart",
|
"name": "Heart",
|
||||||
|
|
@ -54,6 +66,18 @@
|
||||||
"host_slot": "head",
|
"host_slot": "head",
|
||||||
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
"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": {
|
"zenari_cardial_node": {
|
||||||
"name": "Cardial Node",
|
"name": "Cardial Node",
|
||||||
|
|
@ -82,35 +106,103 @@
|
||||||
"host_slot": "head",
|
"host_slot": "head",
|
||||||
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
"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",
|
"name": "CPU",
|
||||||
"host_slot": "head",
|
"host_slot": "head",
|
||||||
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
||||||
},
|
},
|
||||||
"bot_npu": {
|
"hydraulibot_npu": {
|
||||||
"name": "NPU",
|
"name": "NPU",
|
||||||
"host_slot": "head",
|
"host_slot": "head",
|
||||||
"check_weights": { "sleight_of_hand": 0.4, "perception": 0.2 }
|
"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",
|
"name": "Power Cell",
|
||||||
"host_slot": "torso",
|
"host_slot": "torso",
|
||||||
"check_weights": { "strength": 0.6, "speed": 0.5 },
|
"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"
|
"role": "heart"
|
||||||
},
|
},
|
||||||
"bot_hydraulic_fluid_bladder": {
|
"hydraulibot_hydraulic_fluid_bladder": {
|
||||||
"name": "Hydraulic Fluid Bladder",
|
"name": "Hydraulic Fluid Bladder",
|
||||||
"host_slot": "torso",
|
"host_slot": "torso",
|
||||||
"check_weights": { "strength": 0.3, "athletics": 0.3 }
|
"check_weights": { "strength": 0.3, "athletics": 0.3 }
|
||||||
},
|
},
|
||||||
"bot_cooling_fan": {
|
"hydraulibot_cooling_fan": {
|
||||||
"name": "Cooling Fan",
|
"name": "Cooling Fan",
|
||||||
"host_slot": "torso",
|
"host_slot": "torso",
|
||||||
"check_weights": { "speed": 0.3, "athletics": 0.3 }
|
"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": {
|
"dog_heart": {
|
||||||
"name": "Heart",
|
"name": "Heart",
|
||||||
"host_slot": "torso",
|
"host_slot": "torso",
|
||||||
|
|
@ -137,5 +229,17 @@
|
||||||
"name": "Brain",
|
"name": "Brain",
|
||||||
"host_slot": "head",
|
"host_slot": "head",
|
||||||
"check_weights": { "insight": 0.5, "perception": 0.3 }
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,15 @@
|
||||||
"human_left_leg",
|
"human_left_leg",
|
||||||
"human_right_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": "biped",
|
||||||
"body_type_options": ["average", "thin", "fat", "hulk"],
|
"body_type_options": ["average", "thin", "fat", "hulk"],
|
||||||
"hair_colors": ["black", "brown", "blonde", "red", "gray", "white"],
|
"hair_colors": ["black", "brown", "blonde", "red", "gray", "white"],
|
||||||
|
|
@ -22,7 +30,7 @@
|
||||||
],
|
],
|
||||||
"blood_danger_threshold": 50.0,
|
"blood_danger_threshold": 50.0,
|
||||||
"blood_critical_threshold": 25.0,
|
"blood_critical_threshold": 25.0,
|
||||||
"health_item_compatibility": []
|
"health_item_compatibility": ["bandage"]
|
||||||
},
|
},
|
||||||
"reptilian_quad": {
|
"reptilian_quad": {
|
||||||
"name": "Reptilian",
|
"name": "Reptilian",
|
||||||
|
|
@ -42,7 +50,9 @@
|
||||||
"reptilian_lung",
|
"reptilian_lung",
|
||||||
"reptilian_liver",
|
"reptilian_liver",
|
||||||
"reptilian_venom_gland",
|
"reptilian_venom_gland",
|
||||||
"reptilian_brain"
|
"reptilian_brain",
|
||||||
|
"reptilian_eyes",
|
||||||
|
"reptilian_ears"
|
||||||
],
|
],
|
||||||
"body_type": "biped",
|
"body_type": "biped",
|
||||||
"body_type_options": ["average", "stocky", "slender"],
|
"body_type_options": ["average", "stocky", "slender"],
|
||||||
|
|
@ -54,7 +64,7 @@
|
||||||
],
|
],
|
||||||
"blood_danger_threshold": 45.0,
|
"blood_danger_threshold": 45.0,
|
||||||
"blood_critical_threshold": 20.0,
|
"blood_critical_threshold": 20.0,
|
||||||
"health_item_compatibility": []
|
"health_item_compatibility": ["bandage"]
|
||||||
},
|
},
|
||||||
"alien_tri": {
|
"alien_tri": {
|
||||||
"name": "Zenari",
|
"name": "Zenari",
|
||||||
|
|
@ -72,7 +82,9 @@
|
||||||
"zenari_respirator",
|
"zenari_respirator",
|
||||||
"zenari_metabolizer",
|
"zenari_metabolizer",
|
||||||
"zenari_filtration_organ",
|
"zenari_filtration_organ",
|
||||||
"zenari_cortex"
|
"zenari_cortex",
|
||||||
|
"zenari_eyes",
|
||||||
|
"zenari_ears"
|
||||||
],
|
],
|
||||||
"body_type": "biped",
|
"body_type": "biped",
|
||||||
"body_type_options": ["average", "slender", "willowy"],
|
"body_type_options": ["average", "slender", "willowy"],
|
||||||
|
|
@ -85,25 +97,61 @@
|
||||||
],
|
],
|
||||||
"blood_danger_threshold": 55.0,
|
"blood_danger_threshold": 55.0,
|
||||||
"blood_critical_threshold": 30.0,
|
"blood_critical_threshold": 30.0,
|
||||||
"health_item_compatibility": []
|
"health_item_compatibility": ["bandage"]
|
||||||
},
|
},
|
||||||
"bot": {
|
"hydraulibot": {
|
||||||
"name": "Bot",
|
"name": "Hydraulibot",
|
||||||
"genders": ["none"],
|
"genders": ["none"],
|
||||||
"default_body_parts": [
|
"default_body_parts": [
|
||||||
"bot_head",
|
"hydraulibot_head",
|
||||||
"bot_torso",
|
"hydraulibot_torso",
|
||||||
"bot_left_arm",
|
"hydraulibot_left_arm",
|
||||||
"bot_right_arm",
|
"hydraulibot_right_arm",
|
||||||
"bot_left_leg",
|
"hydraulibot_left_leg",
|
||||||
"bot_right_leg"
|
"hydraulibot_right_leg"
|
||||||
],
|
],
|
||||||
"default_organs": [
|
"default_organs": [
|
||||||
"bot_cpu",
|
"hydraulibot_cpu",
|
||||||
"bot_npu",
|
"hydraulibot_npu",
|
||||||
"bot_power_cell",
|
"hydraulibot_camera_module",
|
||||||
"bot_hydraulic_fluid_bladder",
|
"hydraulibot_mic",
|
||||||
"bot_cooling_fan"
|
"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": "biped",
|
||||||
"body_type_options": ["compact", "standard", "heavy"],
|
"body_type_options": ["compact", "standard", "heavy"],
|
||||||
|
|
@ -116,7 +164,8 @@
|
||||||
],
|
],
|
||||||
"blood_danger_threshold": 0.0,
|
"blood_danger_threshold": 0.0,
|
||||||
"blood_critical_threshold": 0.0,
|
"blood_critical_threshold": 0.0,
|
||||||
"health_item_compatibility": []
|
"health_item_compatibility": ["sealant"],
|
||||||
|
"innate_check_penalties": { "sleight_of_hand": 0.4 }
|
||||||
},
|
},
|
||||||
"dog": {
|
"dog": {
|
||||||
"name": "Dog",
|
"name": "Dog",
|
||||||
|
|
@ -129,7 +178,7 @@
|
||||||
"dog_back_left_leg",
|
"dog_back_left_leg",
|
||||||
"dog_back_right_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": "quadruped",
|
||||||
"body_type_options": ["small", "medium", "large"],
|
"body_type_options": ["small", "medium", "large"],
|
||||||
"hair_colors": ["black", "brown", "golden", "white", "gray"],
|
"hair_colors": ["black", "brown", "golden", "white", "gray"],
|
||||||
|
|
@ -143,6 +192,22 @@
|
||||||
],
|
],
|
||||||
"blood_danger_threshold": 50.0,
|
"blood_danger_threshold": 50.0,
|
||||||
"blood_critical_threshold": 25.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": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,16 @@
|
||||||
"ladder": { "color": [130, 110, 70], "walkable": true, "staircase_direction": "up" },
|
"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" },
|
"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_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": {
|
"roof": {
|
||||||
"shingles": { "color": [140, 60, 50], "walkable": true },
|
"shingles": { "color": [140, 60, 50], "walkable": true },
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ DEFAULT_RADIUS = 3.0
|
||||||
DEFAULT_SPEED_FACTOR = 0.3 # 30% normal speed inside the sphere
|
DEFAULT_SPEED_FACTOR = 0.3 # 30% normal speed inside the sphere
|
||||||
DEFAULT_DURATION = 5.0 # seconds
|
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(
|
def cast_time_warp_sphere(
|
||||||
caster: Character,
|
caster: Character,
|
||||||
|
|
@ -50,6 +57,38 @@ def cast_time_warp_sphere(
|
||||||
return zone
|
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
|
DEFAULT_LAUNCH_LIGHTNING_RANGE = 8 # tiles
|
||||||
|
|
||||||
LIGHTNING_BOLT_BURN_FRACTION = 0.5 # roughly half the body parts, same flavor as the weather strike
|
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
|
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
|
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
|
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
|
_apply_shortcircuit - on top of everything else, since electronics are especially
|
||||||
vulnerable to a lightning strike.
|
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:
|
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
|
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
|
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
|
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
|
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
|
resources/defs/ailments.json), and since every synthetic species' own CPU/NPU-equivalent
|
||||||
depend on, see organs.json) are themselves among the randomly-damaged organs, the "huge
|
organs (whichever checks those depend on, see organs.json) are themselves among the
|
||||||
debuff to intellect" falls straight out of the existing organ_penalty math - no separate
|
randomly-damaged organs, the "huge debuff to intellect" falls straight out of the existing
|
||||||
intellect-specific mechanic needed.
|
organ_penalty math - no separate intellect-specific mechanic needed, and no per-species
|
||||||
|
organ-id branching either.
|
||||||
"""
|
"""
|
||||||
if character.species_id is None:
|
if character.species_id is None:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,22 @@ from engine.character import Character
|
||||||
from engine.defs import DefRegistry
|
from engine.defs import DefRegistry
|
||||||
from engine.ui import AbilityBarSlot
|
from engine.ui import AbilityBarSlot
|
||||||
from engine.world import World
|
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.combat import DiceRoller, perform_attack, perform_shoot
|
||||||
from resources.logic.construction import interact_with_tile
|
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.knockback import resolve_melee_knockback
|
||||||
from resources.logic.ranged_combat import fire_held_weapon
|
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 = {
|
ABILITY_CASTERS = {
|
||||||
"time_warp_sphere": cast_time_warp_sphere,
|
"time_warp_sphere": cast_time_warp_sphere,
|
||||||
"launch_lightning": cast_launch_lightning,
|
"launch_lightning": cast_launch_lightning,
|
||||||
|
"adrenablend": cast_adrenablend,
|
||||||
}
|
}
|
||||||
|
|
||||||
ABILITY_COOLDOWNS = {
|
ABILITY_COOLDOWNS = {
|
||||||
|
|
@ -78,12 +85,26 @@ def activate_hand(
|
||||||
result = perform_attack(character, target, registry, weapon_def=None, rng=rng)
|
result = perform_attack(character, target, registry, weapon_def=None, rng=rng)
|
||||||
resolve_melee_knockback(world, character, target, None, result.hit, registry)
|
resolve_melee_knockback(world, character, target, None, result.hit, registry)
|
||||||
return result
|
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
|
return None
|
||||||
|
|
||||||
item_def = registry.get_item_def(held.item_def_id)
|
item_def = registry.get_item_def(held.item_def_id)
|
||||||
|
|
||||||
if item_def.grants_ability is not None:
|
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:
|
if item_def.tool_kind is not None:
|
||||||
map_ = world.maps[character.position.map_id]
|
map_ = world.maps[character.position.map_id]
|
||||||
|
|
@ -95,9 +116,13 @@ def activate_hand(
|
||||||
layer = _first_buildable_layer(character, registry)
|
layer = _first_buildable_layer(character, registry)
|
||||||
else:
|
else:
|
||||||
tile = map_.get_tile(*ahead)
|
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:
|
if layer is None:
|
||||||
return 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)
|
return interact_with_tile(character, world, character.position.map_id, ahead[0], ahead[1], ahead[2], layer, button)
|
||||||
|
|
||||||
if item_def.magazine_size > 0:
|
if item_def.magazine_size > 0:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -5,14 +5,35 @@ from engine.defs import DefRegistry
|
||||||
|
|
||||||
|
|
||||||
def apply_bleeding_tick(character: Character, registry: DefRegistry, ticks: int) -> None:
|
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:
|
if ticks <= 0:
|
||||||
return
|
return
|
||||||
total_loss = 0.0
|
total_loss = 0.0
|
||||||
for part in character.body_parts:
|
for part in character.body_parts:
|
||||||
if not part.removed:
|
if part.bandaged:
|
||||||
continue
|
continue
|
||||||
part_def = registry.get_body_part_def(part.part_def_id)
|
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:
|
if total_loss:
|
||||||
character.blood_percentage = max(0.0, character.blood_percentage - 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -139,3 +139,54 @@ def test_deconstructor_out_of_bounds_target_is_a_no_op(registry):
|
||||||
world.add_entity(character)
|
world.add_entity(character)
|
||||||
|
|
||||||
assert activate_hand(character, world, registry, "right_hand", (-1, 0, 0)) is None
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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.character_library import CharacterLibrary
|
||||||
from engine.defs import DefRegistry
|
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):
|
def test_available_species_excludes_dog(registry):
|
||||||
creator = CharacterCreator(registry)
|
creator = CharacterCreator(registry)
|
||||||
assert "dog" not in creator.available_species()
|
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):
|
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
|
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):
|
def test_set_gender_accepts_a_valid_option(registry):
|
||||||
creator = CharacterCreator(registry)
|
creator = CharacterCreator(registry)
|
||||||
creator.set_species("human")
|
creator.set_species("human")
|
||||||
|
|
@ -79,7 +85,7 @@ def test_switching_species_resets_gender_if_no_longer_valid(registry):
|
||||||
creator = CharacterCreator(registry)
|
creator = CharacterCreator(registry)
|
||||||
creator.set_species("human")
|
creator.set_species("human")
|
||||||
creator.set_gender("nonbinary")
|
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"
|
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):
|
def test_set_hair_color_rejects_invalid_option_for_species_with_no_hair(registry):
|
||||||
creator = CharacterCreator(registry)
|
creator = CharacterCreator(registry)
|
||||||
creator.set_species("bot")
|
creator.set_species("hydraulibot")
|
||||||
assert creator.set_hair_color("black") is False
|
assert creator.set_hair_color("black") is False
|
||||||
assert creator.hair_color is None
|
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_species("human")
|
||||||
creator.set_body_type("hulk")
|
creator.set_body_type("hulk")
|
||||||
creator.set_hair_color("red")
|
creator.set_hair_color("red")
|
||||||
creator.set_species("bot") # no body_type_options overlap, no hair at all
|
creator.set_species("hydraulibot") # no body_type_options overlap, no hair at all
|
||||||
assert creator.body_type == "compact" # bot's first body_type option
|
assert creator.body_type == "compact" # hydraulibot's first body_type option
|
||||||
assert creator.hair_color is None
|
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
|
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 ------------------------------------------------------------------------
|
# --- 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"
|
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 -------------------------------------------------------------
|
# --- confirm: saves into the library -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def test_blood_status_thresholds(registry):
|
||||||
species = registry.get_species_def("human")
|
species = registry.get_species_def("human")
|
||||||
character = make_human(registry, blood_percentage=100.0)
|
character = make_human(registry, blood_percentage=100.0)
|
||||||
|
|
|
||||||
|
|
@ -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
|
# default (non-overridden) parts and their organs came back too
|
||||||
head = loaded.get_body_part("head")
|
head = loaded.get_body_part("head")
|
||||||
assert head.part_def_id == "reptilian_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):
|
def test_roundtrip_preserves_clothing_and_its_granted_inventory(registry, library):
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,25 @@ def test_select_tab_ignores_unknown_tab_names():
|
||||||
assert menu.active_tab == "bio"
|
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):
|
def test_switching_tabs_closes_any_open_backpack_popup(registry):
|
||||||
menu = CharacterMenu()
|
menu = CharacterMenu()
|
||||||
character = make_human(registry, clothing=[ClothingPiece("back", "backpack_basic")])
|
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 = CharacterMenu(open_backpack_slot="back")
|
||||||
menu.close_backpack_popup()
|
menu.close_backpack_popup()
|
||||||
assert menu.open_backpack_slot is None
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -66,6 +66,59 @@ def test_entity_def_references_species_and_inventory_size(registry):
|
||||||
assert player.default_clothing[0].slot == "back"
|
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):
|
def test_item_def_clothing_fields(registry):
|
||||||
backpack = registry.get_item_def("backpack_basic")
|
backpack = registry.get_item_def("backpack_basic")
|
||||||
assert backpack.clothing_slot == "back"
|
assert backpack.clothing_slot == "back"
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,18 @@ def make_human(registry, **kwargs) -> Character:
|
||||||
# --- DevSpawnMenu -------------------------------------------------------------------------------
|
# --- 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)
|
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]
|
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):
|
def test_cycle_wraps_around(registry):
|
||||||
menu = DevSpawnMenu(registry)
|
menu = DevSpawnMenu(registry)
|
||||||
menu.cycle(-1)
|
menu.cycle(-1)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -100,8 +100,10 @@ def test_advance_on_name_step_requires_a_non_empty_name(registry):
|
||||||
flow = CharacterCreationFlow(CharacterCreator(registry))
|
flow = CharacterCreationFlow(CharacterCreator(registry))
|
||||||
flow.advance()
|
flow.advance()
|
||||||
assert flow.advance() is False
|
assert flow.advance() is False
|
||||||
|
assert flow.step == "name"
|
||||||
flow.type_char("V")
|
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):
|
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):
|
def test_go_back_from_species_reports_theres_nowhere_further_back(registry):
|
||||||
flow = CharacterCreationFlow(CharacterCreator(registry))
|
flow = CharacterCreationFlow(CharacterCreator(registry))
|
||||||
assert flow.go_back() is False
|
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"
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,23 @@ def test_gravity_does_not_fire_on_a_single_level_map(registry):
|
||||||
walker = Entity(position=EntityPosition("flat", 0, 0, 0))
|
walker = Entity(position=EntityPosition("flat", 0, 0, 0))
|
||||||
assert world.try_move(walker, 1, 0, 0) is True
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
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():
|
def test_clear_held_state_lets_a_continuously_held_gamepad_button_refire():
|
||||||
from engine.gamepad import DEFAULT_GAMEPAD_BINDINGS, GamepadBindings, GamepadState
|
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():
|
def test_mouse_left_click_activates_right_hand():
|
||||||
input_state = InputState()
|
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() == "right_hand"
|
||||||
assert input_state.consume_hand_activation() is None
|
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():
|
def test_shift_plus_mouse_click_activates_the_second_hand_pair():
|
||||||
input_state = InputState()
|
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"
|
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():
|
def test_pointer_move_tracks_position():
|
||||||
input_state = InputState()
|
input_state = InputState()
|
||||||
assert input_state.pointer_pos is None
|
assert input_state.pointer_pos is None
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,35 @@ def test_find_first_fit_returns_leftmost_topmost_slot():
|
||||||
|
|
||||||
slot = inv.find_first_fit(SANDWICH)
|
slot = inv.find_first_fit(SANDWICH)
|
||||||
assert slot == (1, 0)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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:
|
def make_hydraulibot(registry, **kwargs) -> Character:
|
||||||
return Character(species_id="bot", default_body_parts=registry.new_default_body_parts("bot"), **kwargs)
|
return Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def test_shortcircuit_only_applies_to_synthetic_species(registry):
|
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)
|
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)
|
world = make_world(registry)
|
||||||
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
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(caster)
|
||||||
world.add_entity(target)
|
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)
|
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)
|
world = make_world(registry)
|
||||||
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
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(caster)
|
||||||
world.add_entity(target)
|
world.add_entity(target)
|
||||||
|
|
||||||
cast_launch_lightning(caster, world, now=0.0, aim_direction=(0, 1, 0), rng=random.Random(7))
|
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()
|
damaged_ids = set()
|
||||||
total_damage = 0.0
|
total_damage = 0.0
|
||||||
for part in target.body_parts:
|
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)
|
damaged_ids.add(organ.organ_def_id)
|
||||||
total_damage += 100.0 - organ.integrity
|
total_damage += 100.0 - organ.integrity
|
||||||
assert damaged_ids == all_organ_ids # every organ took at least some damage
|
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
|
# shortcircuit's 120 spread across all 7 organs, plus heart_palpitations' own separate hit
|
||||||
# to bot_power_cell (the bot's heart-equivalent organ) on top of its shortcircuit share
|
# 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
|
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):
|
def test_shortcircuit_ailment_lands_on_every_body_part_hosting_a_damaged_organ(registry):
|
||||||
world = make_world(registry)
|
world = make_world(registry)
|
||||||
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
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(caster)
|
||||||
world.add_entity(target)
|
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):
|
def test_shortcircuit_gives_a_huge_intellect_debuff(registry):
|
||||||
world = make_world(registry)
|
world = make_world(registry)
|
||||||
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
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(caster)
|
||||||
world.add_entity(target)
|
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):
|
def test_shortcircuit_recast_refreshes_rather_than_stacks(registry):
|
||||||
world = make_world(registry)
|
world = make_world(registry)
|
||||||
caster = make_human(registry, position=EntityPosition("field", 5, 5, 0))
|
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(caster)
|
||||||
world.add_entity(target)
|
world.add_entity(target)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -8,6 +8,8 @@ from engine.character_creator import CharacterCreator
|
||||||
from engine.controls_menu import ControlsMenu
|
from engine.controls_menu import ControlsMenu
|
||||||
from engine.defs import DefRegistry
|
from engine.defs import DefRegistry
|
||||||
from engine.game_modes import CharacterCreationFlow, MainMenu, PauseMenu
|
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.render import menu_draw
|
||||||
from engine.ui import AbilityBar, AbilityBarSlot, CharacterMenu
|
from engine.ui import AbilityBar, AbilityBarSlot, CharacterMenu
|
||||||
from engine.world_creation import WorldCreationMenu
|
from engine.world_creation import WorldCreationMenu
|
||||||
|
|
@ -53,6 +55,41 @@ def test_draw_character_creation_name_step(registry):
|
||||||
assert buckets["ui/panel/background.png"]
|
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():
|
def test_draw_world_creation_lists_templates_and_highlights_selection():
|
||||||
buckets = new_buckets()
|
buckets = new_buckets()
|
||||||
menu = WorldCreationMenu()
|
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)
|
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):
|
def test_draw_character_card_medical_tab_shows_removed_parts_and_ailments(registry):
|
||||||
from engine.character import Ailment
|
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")
|
card_menu.select_tab("medical")
|
||||||
menu_draw.draw_character_card(buckets, character, card_menu, registry, 600)
|
menu_draw.draw_character_card(buckets, character, card_menu, registry, 600)
|
||||||
assert buckets["ui/panel/background.png"]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
assert organ_ids == {"human_heart", "human_lungs", "human_liver", "human_kidneys"}
|
||||||
|
|
||||||
head = next(p for p in parts if p.slot == "head")
|
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")
|
arm = next(p for p in parts if p.slot == "left_arm")
|
||||||
assert arm.organs == []
|
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):
|
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"))
|
hydraulibot = Character(species_id="hydraulibot", default_body_parts=registry.new_default_body_parts("hydraulibot"))
|
||||||
found = bot.find_organ_by_role("heart", registry)
|
found = hydraulibot.find_organ_by_role("heart", registry)
|
||||||
assert found is not None
|
assert found is not None
|
||||||
_body_part, organ = found
|
_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):
|
def test_find_organ_by_role_none_when_no_organs_at_all(registry):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 == {}
|
||||||
|
|
@ -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"}
|
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):
|
def test_hydraulibot_species_gets_bespoke_mechanical_organs(registry):
|
||||||
parts = registry.new_default_body_parts("bot")
|
parts = registry.new_default_body_parts("hydraulibot")
|
||||||
head = next(p for p in parts if p.slot == "head")
|
head = next(p for p in parts if p.slot == "head")
|
||||||
torso = next(p for p in parts if p.slot == "torso")
|
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} == {
|
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):
|
def test_hydraulibot_power_cell_affects_multiple_organs(registry):
|
||||||
bot = make_character(registry, "bot")
|
hydraulibot = make_character(registry, "hydraulibot")
|
||||||
torso = bot.get_or_create_body_part_override("torso")
|
torso = hydraulibot.get_or_create_body_part_override("torso")
|
||||||
power_cell = next(o for o in torso.organs if o.organ_def_id == "bot_power_cell")
|
power_cell = next(o for o in torso.organs if o.organ_def_id == "hydraulibot_power_cell")
|
||||||
power_cell.integrity = 0.0
|
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")
|
head = hydraulibot.get_body_part("head")
|
||||||
cpu = next(o for o in head.organs if o.organ_def_id == "bot_cpu")
|
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 == "bot_npu")
|
npu = next(o for o in head.organs if o.organ_def_id == "hydraulibot_npu")
|
||||||
fluid_bladder = next(o for o in bot.get_body_part("torso").organs if o.organ_def_id == "bot_hydraulic_fluid_bladder")
|
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 cpu.integrity < 100.0
|
||||||
assert npu.integrity < 100.0
|
assert npu.integrity < 100.0
|
||||||
assert fluid_bladder.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):
|
def test_reptilian_venom_gland_is_bespoke_not_shared_with_other_species(registry):
|
||||||
parts = registry.new_default_body_parts("reptilian_quad")
|
parts = registry.new_default_body_parts("reptilian_quad")
|
||||||
torso = next(p for p in parts if p.slot == "torso")
|
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} == {
|
assert {o.organ_def_id for o in torso.organs} == {
|
||||||
"zenari_cardial_node", "zenari_respirator", "zenari_metabolizer", "zenari_filtration_organ",
|
"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):
|
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):
|
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] = {}
|
seen: dict[str, str] = {}
|
||||||
for species_id in species_ids:
|
for species_id in species_ids:
|
||||||
species = registry.get_species_def(species_id)
|
species = registry.get_species_def(species_id)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue