Add ship rotation speed limits, combat ailments, and character creator depth
- Ships turn gradually toward a desired heading at a per-ship max rotation speed instead of snapping instantly (MapEmbedding.desired_rotation + advance_ship_rotation, wired into the main loop). - Combat hits can now inflict ailments (impact_trauma/fracture/brain_trauma) based on the raw d20 roll, which factor into relevant skill checks via a new AilmentDef system mirroring the existing organ-penalty mechanic. - Character creator: species-defined body types and hair colors, starter clothing selection (reuses Character.wear_item's fit rules), a player-species whitelist excluding the NPC-only dog species, and four skill-allocation modes (point-buy, roll-and-assign, manual, random). - New WorldCreationMenu/WorldTemplate for picking a starting world, currently just the one sample map. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mFzQQZYexNaSB69e2oU95main
parent
67c9435d9e
commit
3bd2c6fa9f
|
|
@ -16,6 +16,20 @@ class Ailment:
|
||||||
progress: float = 0.0 # 0-100 meter for ailments that advance over time (e.g. heatstroke)
|
progress: float = 0.0 # 0-100 meter for ailments that advance over time (e.g. heatstroke)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AilmentDef:
|
||||||
|
"""How much an ailment (at its current severity) degrades relevant checks - mirrors
|
||||||
|
OrganDef.check_weights exactly, just keyed by ailment id instead of organ id. Not every
|
||||||
|
Ailment instance needs a matching AilmentDef; one with no def (or a def with no weight for
|
||||||
|
the check in question) simply contributes nothing, same as an organ with no relevant
|
||||||
|
check_weights entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
check_weights: dict[str, float] = field(default_factory=dict) # e.g. {"strength": 0.5}
|
||||||
|
|
||||||
|
|
||||||
class _SlottedOverride(Protocol):
|
class _SlottedOverride(Protocol):
|
||||||
slot: str
|
slot: str
|
||||||
removed: bool
|
removed: bool
|
||||||
|
|
@ -124,6 +138,8 @@ class Character(Entity):
|
||||||
*args,
|
*args,
|
||||||
species_id: str | None = None,
|
species_id: str | None = None,
|
||||||
gender: str | None = None,
|
gender: str | None = None,
|
||||||
|
body_type: str | None = None,
|
||||||
|
hair_color: str | None = None,
|
||||||
body_parts: list[BodyPart] | None = None,
|
body_parts: list[BodyPart] | None = None,
|
||||||
default_body_parts: list[BodyPart] | None = None,
|
default_body_parts: list[BodyPart] | None = None,
|
||||||
clothing: list[ClothingPiece] | None = None,
|
clothing: list[ClothingPiece] | None = None,
|
||||||
|
|
@ -141,6 +157,10 @@ class Character(Entity):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.species_id = species_id
|
self.species_id = species_id
|
||||||
self.gender = gender # open marker; valid values are species-defined, not enforced here
|
self.gender = gender # open marker; valid values are species-defined, not enforced here
|
||||||
|
self.body_type = body_type # cosmetic build/archetype (e.g. "average"/"thin"/"fat"/"hulk");
|
||||||
|
# valid values are species-defined via SpeciesDef.body_type_options, not enforced here -
|
||||||
|
# NOT the same as SpeciesDef.body_type, which is a fixed anatomical trait
|
||||||
|
self.hair_color = hair_color # valid values are species-defined via SpeciesDef.hair_colors
|
||||||
self.body_parts: list[BodyPart] = body_parts or []
|
self.body_parts: list[BodyPart] = body_parts or []
|
||||||
self.default_body_parts: list[BodyPart] = default_body_parts or []
|
self.default_body_parts: list[BodyPart] = default_body_parts or []
|
||||||
self.clothing: list[ClothingPiece] = clothing or []
|
self.clothing: list[ClothingPiece] = clothing or []
|
||||||
|
|
@ -436,11 +456,40 @@ class Character(Entity):
|
||||||
return 0.0
|
return 0.0
|
||||||
return lost_weight / total_weight
|
return lost_weight / total_weight
|
||||||
|
|
||||||
def total_check_penalty(self, check_id: str, registry) -> float:
|
def ailment_penalty(self, check_id: str, registry) -> float:
|
||||||
"""Combined penalty from both damaged body parts and damaged organs for a named check,
|
"""Fraction (0..1) of `check_id` lost to active ailments (e.g. a combat-inflicted
|
||||||
applied multiplicatively (as independent sources of lost effectiveness) so it can never
|
fracture or brain_trauma - see resources/logic/combat.py) - mirrors organ_penalty, but
|
||||||
exceed 1.0 lost. Reduces to skill_penalty alone when no organ affects this check or all
|
weighted by each ailment's own severity (already a 0..1 fraction) instead of an organ's
|
||||||
relevant organs are healthy - existing skill-only checks are unaffected either way.
|
integrity. An ailment id with no matching AilmentDef (or no weight for this check)
|
||||||
|
simply contributes nothing.
|
||||||
"""
|
"""
|
||||||
remaining = (1.0 - self.skill_penalty(check_id, registry)) * (1.0 - self.organ_penalty(check_id, registry))
|
total_weight = 0.0
|
||||||
|
lost_weight = 0.0
|
||||||
|
for part in self.resolved_body_parts().values():
|
||||||
|
if part is None:
|
||||||
|
continue
|
||||||
|
for ailment in part.ailments:
|
||||||
|
ailment_def = registry.get_ailment_def(ailment.id)
|
||||||
|
if ailment_def is None:
|
||||||
|
continue
|
||||||
|
weight = ailment_def.check_weights.get(check_id, 0.0)
|
||||||
|
if weight == 0.0:
|
||||||
|
continue
|
||||||
|
total_weight += weight
|
||||||
|
lost_weight += weight * min(1.0, max(0.0, ailment.severity))
|
||||||
|
if total_weight == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return lost_weight / total_weight
|
||||||
|
|
||||||
|
def total_check_penalty(self, check_id: str, registry) -> float:
|
||||||
|
"""Combined penalty from damaged body parts, damaged organs, and active ailments for a
|
||||||
|
named check, applied multiplicatively (as independent sources of lost effectiveness) so
|
||||||
|
it can never exceed 1.0 lost. Reduces to skill_penalty alone when nothing else affects
|
||||||
|
this check or everything else is healthy - existing skill-only checks are unaffected.
|
||||||
|
"""
|
||||||
|
remaining = (
|
||||||
|
(1.0 - self.skill_penalty(check_id, registry))
|
||||||
|
* (1.0 - self.organ_penalty(check_id, registry))
|
||||||
|
* (1.0 - self.ailment_penalty(check_id, registry))
|
||||||
|
)
|
||||||
return 1.0 - remaining
|
return 1.0 - remaining
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,91 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from engine.character import BodyPart, Character
|
from engine.character import BodyPart, Character, ClothingPiece
|
||||||
from engine.character_library import CharacterLibrary
|
from engine.character_library import CharacterLibrary
|
||||||
from engine.defs import DefRegistry
|
from engine.defs import DefRegistry
|
||||||
from engine.skills import default_bio
|
from engine.skills import default_bio
|
||||||
|
|
||||||
DEFAULT_SKILL_POINTS = 10
|
DEFAULT_SKILL_POINTS = 10
|
||||||
DEFAULT_MAX_SKILL_LEVEL = 5
|
DEFAULT_MAX_SKILL_LEVEL = 5
|
||||||
|
DEFAULT_ROLL_COUNT = 6 # how many values roll_assign mode generates to place among the skills
|
||||||
|
|
||||||
|
# The four ways skills can be filled in - see CharacterCreator.set_skill_mode.
|
||||||
|
SKILL_MODE_POINT_BUY = "point_buy" # spend a shared budget freely (increase_skill/decrease_skill)
|
||||||
|
SKILL_MODE_ROLL_ASSIGN = "roll_assign" # roll a small pool of values, place each on a skill you pick
|
||||||
|
SKILL_MODE_MANUAL = "manual" # set any skill to any value directly, no budget
|
||||||
|
SKILL_MODE_RANDOM = "random" # every skill gets its own random value immediately, nothing to place
|
||||||
|
SKILL_MODES = (SKILL_MODE_POINT_BUY, SKILL_MODE_ROLL_ASSIGN, SKILL_MODE_MANUAL, SKILL_MODE_RANDOM)
|
||||||
|
|
||||||
|
# Species a player can create a character as. "dog" is deliberately excluded - it's a
|
||||||
|
# companion/NPC-only species (see the companion_dog entity template), still fully valid to
|
||||||
|
# spawn as an NPC or load via CharacterLibrary, just not offered as a player choice here.
|
||||||
|
PLAYER_SELECTABLE_SPECIES: tuple[str, ...] = ("human", "reptilian_quad", "alien_tri", "bot")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CharacterCreator:
|
class CharacterCreator:
|
||||||
"""Pure state/logic for building a new persistent character: pick a species, a gender
|
"""Pure state/logic for building a new persistent character: pick a species, a gender
|
||||||
valid for that species (species define their own valid genders - see SpeciesDef.genders),
|
valid for that species (species define their own valid genders - see SpeciesDef.genders),
|
||||||
a name, and spend a fixed point budget across the classic D&D-style bio skills
|
a cosmetic body type and hair color (also species-defined, see SpeciesDef.body_type_options/
|
||||||
(engine/skills.py) - then confirm() builds an actual Character and saves it into the
|
hair_colors - either can be empty for a species that doesn't offer that choice, e.g. no
|
||||||
CharacterLibrary under a given account, ready to appear on the StartScreen.
|
species-defined hair colors means no hair to color), starter clothing (species/arm-count
|
||||||
|
compatibility enforced the same way Character.wear_item enforces it - see
|
||||||
|
set_starter_clothing), a name, and a fixed point budget spent across the classic D&D-style
|
||||||
|
bio skills (engine/skills.py) - then confirm() builds an actual Character and saves it into
|
||||||
|
the CharacterLibrary under a given account, ready to appear on the StartScreen.
|
||||||
|
|
||||||
No rendering here - same boundary as StartScreen/CharacterMenu/LayerPickerMenu/AbilityBar
|
No rendering here - same boundary as StartScreen/CharacterMenu/LayerPickerMenu/AbilityBar
|
||||||
(engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, and skill
|
(engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, color
|
||||||
+/- steppers is a follow-up UI-layer task. This is the state/logic side: valid choices,
|
swatches, and skill +/- steppers is a follow-up UI-layer task. This is the state/logic
|
||||||
point-budget bookkeeping, and producing the finished Character.
|
side: valid choices, point-budget bookkeeping, and producing the finished Character.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
registry: DefRegistry
|
registry: DefRegistry
|
||||||
|
selectable_species: tuple[str, ...] = PLAYER_SELECTABLE_SPECIES
|
||||||
skill_points_budget: int = DEFAULT_SKILL_POINTS
|
skill_points_budget: int = DEFAULT_SKILL_POINTS
|
||||||
max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL
|
max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
species_id: str | None = None
|
species_id: str | None = None
|
||||||
gender: str | None = None
|
gender: str | None = None
|
||||||
|
body_type: str | None = None
|
||||||
|
hair_color: str | None = None
|
||||||
|
starter_clothing: dict[str, str] = field(default_factory=dict) # slot -> item_def_id
|
||||||
bio: dict[str, int] = field(default_factory=default_bio)
|
bio: dict[str, int] = field(default_factory=default_bio)
|
||||||
|
skill_mode: str = SKILL_MODE_POINT_BUY
|
||||||
|
rolled_values: list[int] = field(default_factory=list) # roll_assign mode's unplaced pool
|
||||||
|
|
||||||
def set_name(self, name: str) -> None:
|
def set_name(self, name: str) -> None:
|
||||||
self.name = name.strip()
|
self.name = name.strip()
|
||||||
|
|
||||||
def set_species(self, species_id: str) -> bool:
|
def available_species(self) -> list[str]:
|
||||||
"""Picks a species, resetting gender to that species' first valid option if the
|
"""Species ids a player can actually pick, for a UI to list - selectable_species
|
||||||
current gender (from a previous species pick) isn't valid for it.
|
filtered against whatever's actually loaded in the registry.
|
||||||
"""
|
"""
|
||||||
if species_id not in self.registry.species_defs:
|
return [s for s in self.selectable_species if s in self.registry.species_defs]
|
||||||
|
|
||||||
|
def set_species(self, species_id: str) -> bool:
|
||||||
|
"""Picks a species, resetting gender/body_type/hair_color to that species' first valid
|
||||||
|
option if the current pick (from a previous species) isn't valid for it, and dropping
|
||||||
|
any starter clothing (fit/compatibility is species-specific - see set_starter_clothing).
|
||||||
|
|
||||||
|
Rejects any species not in selectable_species (e.g. "dog" - companion/NPC-only, not a
|
||||||
|
player choice) even if it's otherwise a perfectly valid, fully-loaded species.
|
||||||
|
"""
|
||||||
|
if species_id not in self.selectable_species or species_id not in self.registry.species_defs:
|
||||||
return False
|
return False
|
||||||
self.species_id = species_id
|
self.species_id = species_id
|
||||||
species = self.registry.get_species_def(species_id)
|
species = self.registry.get_species_def(species_id)
|
||||||
if self.gender not in species.genders:
|
if self.gender not in species.genders:
|
||||||
self.gender = species.genders[0] if species.genders else None
|
self.gender = species.genders[0] if species.genders else None
|
||||||
|
if self.body_type not in species.body_type_options:
|
||||||
|
self.body_type = species.body_type_options[0] if species.body_type_options else None
|
||||||
|
if self.hair_color not in species.hair_colors:
|
||||||
|
self.hair_color = species.hair_colors[0] if species.hair_colors else None
|
||||||
|
self.starter_clothing = {}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_gender(self, gender: str) -> bool:
|
def set_gender(self, gender: str) -> bool:
|
||||||
|
|
@ -58,6 +97,93 @@ class CharacterCreator:
|
||||||
self.gender = gender
|
self.gender = gender
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def set_body_type(self, body_type: str) -> bool:
|
||||||
|
if self.species_id is None:
|
||||||
|
return False
|
||||||
|
species = self.registry.get_species_def(self.species_id)
|
||||||
|
if body_type not in species.body_type_options:
|
||||||
|
return False
|
||||||
|
self.body_type = body_type
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_hair_color(self, hair_color: str) -> bool:
|
||||||
|
if self.species_id is None:
|
||||||
|
return False
|
||||||
|
species = self.registry.get_species_def(self.species_id)
|
||||||
|
if hair_color not in species.hair_colors:
|
||||||
|
return False
|
||||||
|
self.hair_color = hair_color
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_starter_clothing(self, slot: str, item_def_id: str) -> bool:
|
||||||
|
"""Picks a starter garment for `slot`, validated exactly the way Character.wear_item
|
||||||
|
validates it (species body_type + arm-sleeve fit) - via a throwaway probe character, so
|
||||||
|
the fit rule only has to live in one place. Requires a species to already be chosen.
|
||||||
|
"""
|
||||||
|
if self.species_id is None:
|
||||||
|
return False
|
||||||
|
probe = Character(species_id=self.species_id, default_body_parts=self.registry.new_default_body_parts(self.species_id))
|
||||||
|
if not probe.wear_item(slot, item_def_id, self.registry):
|
||||||
|
return False
|
||||||
|
self.starter_clothing[slot] = item_def_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_starter_clothing(self, slot: str) -> None:
|
||||||
|
self.starter_clothing.pop(slot, None)
|
||||||
|
|
||||||
|
def set_skill_mode(self, mode: str, rng: random.Random | None = None) -> bool:
|
||||||
|
"""Switches how skills get filled in, resetting bio to a clean slate for that mode:
|
||||||
|
|
||||||
|
- point_buy: everything back to 0, spend a shared budget freely (increase_skill/
|
||||||
|
decrease_skill) - the default, classic point-buy.
|
||||||
|
- roll_assign: rolls a fixed pool of DEFAULT_ROLL_COUNT values (0..max_skill_level
|
||||||
|
each) up front; each gets placed on a skill of your choosing via assign_rolled_value,
|
||||||
|
one at a time - whatever skills are left unassigned stay at 0.
|
||||||
|
- manual: every skill directly settable to any value via set_skill_manual, no budget at
|
||||||
|
all - full freeform control.
|
||||||
|
- random: every skill immediately gets its own independent random value - nothing left
|
||||||
|
to place, the character's ready to go as rolled (switching to another mode discards
|
||||||
|
this and starts that mode fresh).
|
||||||
|
"""
|
||||||
|
if mode not in SKILL_MODES:
|
||||||
|
return False
|
||||||
|
self.skill_mode = mode
|
||||||
|
self.bio = default_bio()
|
||||||
|
self.rolled_values = []
|
||||||
|
rng = rng or random.Random()
|
||||||
|
if mode == SKILL_MODE_ROLL_ASSIGN:
|
||||||
|
self.rolled_values = [rng.randint(0, self.max_skill_level) for _ in range(DEFAULT_ROLL_COUNT)]
|
||||||
|
elif mode == SKILL_MODE_RANDOM:
|
||||||
|
for skill_id in self.bio:
|
||||||
|
self.bio[skill_id] = rng.randint(0, self.max_skill_level)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def assign_rolled_value(self, roll_index: int, skill_id: str) -> bool:
|
||||||
|
"""roll_assign mode only: places one of the still-unplaced rolled values onto a skill
|
||||||
|
(overwriting whatever it currently holds - 0, if untouched) and removes that value from
|
||||||
|
the pool, so each rolled value can only be placed once.
|
||||||
|
"""
|
||||||
|
if self.skill_mode != SKILL_MODE_ROLL_ASSIGN:
|
||||||
|
return False
|
||||||
|
if not (0 <= roll_index < len(self.rolled_values)):
|
||||||
|
return False
|
||||||
|
if skill_id not in self.bio:
|
||||||
|
return False
|
||||||
|
value = self.rolled_values.pop(roll_index)
|
||||||
|
self.bio[skill_id] = value
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_skill_manual(self, skill_id: str, value: int) -> bool:
|
||||||
|
"""manual mode only: directly sets a skill to any value in [0, max_skill_level]."""
|
||||||
|
if self.skill_mode != SKILL_MODE_MANUAL:
|
||||||
|
return False
|
||||||
|
if skill_id not in self.bio:
|
||||||
|
return False
|
||||||
|
if not (0 <= value <= self.max_skill_level):
|
||||||
|
return False
|
||||||
|
self.bio[skill_id] = value
|
||||||
|
return True
|
||||||
|
|
||||||
def spent_skill_points(self) -> int:
|
def spent_skill_points(self) -> int:
|
||||||
return sum(self.bio.values())
|
return sum(self.bio.values())
|
||||||
|
|
||||||
|
|
@ -65,6 +191,9 @@ class CharacterCreator:
|
||||||
return self.skill_points_budget - self.spent_skill_points()
|
return self.skill_points_budget - self.spent_skill_points()
|
||||||
|
|
||||||
def increase_skill(self, skill_id: str) -> bool:
|
def increase_skill(self, skill_id: str) -> bool:
|
||||||
|
"""point_buy mode only - see set_skill_mode for the other three ways to fill in skills."""
|
||||||
|
if self.skill_mode != SKILL_MODE_POINT_BUY:
|
||||||
|
return False
|
||||||
if skill_id not in self.bio:
|
if skill_id not in self.bio:
|
||||||
return False
|
return False
|
||||||
if self.remaining_skill_points() <= 0 or self.bio[skill_id] >= self.max_skill_level:
|
if self.remaining_skill_points() <= 0 or self.bio[skill_id] >= self.max_skill_level:
|
||||||
|
|
@ -73,6 +202,9 @@ class CharacterCreator:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def decrease_skill(self, skill_id: str) -> bool:
|
def decrease_skill(self, skill_id: str) -> bool:
|
||||||
|
"""point_buy mode only - see set_skill_mode for the other three ways to fill in skills."""
|
||||||
|
if self.skill_mode != SKILL_MODE_POINT_BUY:
|
||||||
|
return False
|
||||||
if skill_id not in self.bio or self.bio[skill_id] <= 0:
|
if skill_id not in self.bio or self.bio[skill_id] <= 0:
|
||||||
return False
|
return False
|
||||||
self.bio[skill_id] -= 1
|
self.bio[skill_id] -= 1
|
||||||
|
|
@ -87,7 +219,14 @@ class CharacterCreator:
|
||||||
return self.registry.new_default_body_parts(self.species_id)
|
return self.registry.new_default_body_parts(self.species_id)
|
||||||
|
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
return bool(self.name) and self.species_id is not None and self.gender is not None
|
if not self.name or self.species_id is None or self.gender is None:
|
||||||
|
return False
|
||||||
|
species = self.registry.get_species_def(self.species_id)
|
||||||
|
if species.body_type_options and self.body_type is None:
|
||||||
|
return False
|
||||||
|
if species.hair_colors and self.hair_color is None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def build_character(self) -> Character | None:
|
def build_character(self) -> Character | None:
|
||||||
if not self.is_ready():
|
if not self.is_ready():
|
||||||
|
|
@ -97,7 +236,10 @@ class CharacterCreator:
|
||||||
name=self.name,
|
name=self.name,
|
||||||
species_id=self.species_id,
|
species_id=self.species_id,
|
||||||
gender=self.gender,
|
gender=self.gender,
|
||||||
|
body_type=self.body_type,
|
||||||
|
hair_color=self.hair_color,
|
||||||
default_body_parts=self.registry.new_default_body_parts(self.species_id),
|
default_body_parts=self.registry.new_default_body_parts(self.species_id),
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS library_characters (
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
species_id TEXT,
|
species_id TEXT,
|
||||||
gender TEXT,
|
gender TEXT,
|
||||||
|
body_type TEXT,
|
||||||
|
hair_color TEXT,
|
||||||
blood_percentage REAL NOT NULL DEFAULT 100.0,
|
blood_percentage REAL NOT NULL DEFAULT 100.0,
|
||||||
strength INTEGER NOT NULL DEFAULT 10,
|
strength INTEGER NOT NULL DEFAULT 10,
|
||||||
PRIMARY KEY (account_id, character_id)
|
PRIMARY KEY (account_id, character_id)
|
||||||
|
|
@ -188,10 +190,10 @@ class CharacterLibrary:
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO library_characters (account_id, character_id, name, species_id, gender,"
|
"INSERT INTO library_characters (account_id, character_id, name, species_id, gender,"
|
||||||
" blood_percentage, strength) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
" body_type, hair_color, blood_percentage, strength) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
account_id, character_id, character.name, character.species_id, character.gender,
|
account_id, character_id, character.name, character.species_id, character.gender,
|
||||||
character.blood_percentage, character.strength,
|
character.body_type, character.hair_color, character.blood_percentage, character.strength,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -293,13 +295,13 @@ class CharacterLibrary:
|
||||||
def load_character(self, account_id: str, character_id: str, registry: DefRegistry) -> Character | None:
|
def load_character(self, account_id: str, character_id: str, registry: DefRegistry) -> Character | None:
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
row = cur.execute(
|
row = cur.execute(
|
||||||
"SELECT name, species_id, gender, blood_percentage, strength FROM library_characters"
|
"SELECT name, species_id, gender, body_type, hair_color, blood_percentage, strength"
|
||||||
" WHERE account_id = ? AND character_id = ?",
|
" FROM library_characters WHERE account_id = ? AND character_id = ?",
|
||||||
(account_id, character_id),
|
(account_id, character_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
name, species_id, gender, blood_percentage, strength = row
|
name, species_id, gender, body_type, hair_color, blood_percentage, strength = row
|
||||||
|
|
||||||
body_parts: list[BodyPart] = []
|
body_parts: list[BodyPart] = []
|
||||||
default_body_parts: list[BodyPart] = []
|
default_body_parts: list[BodyPart] = []
|
||||||
|
|
@ -398,6 +400,8 @@ class CharacterLibrary:
|
||||||
position=None,
|
position=None,
|
||||||
species_id=species_id,
|
species_id=species_id,
|
||||||
gender=gender,
|
gender=gender,
|
||||||
|
body_type=body_type,
|
||||||
|
hair_color=hair_color,
|
||||||
blood_percentage=blood_percentage,
|
blood_percentage=blood_percentage,
|
||||||
strength=strength,
|
strength=strength,
|
||||||
body_parts=body_parts,
|
body_parts=body_parts,
|
||||||
|
|
|
||||||
21
engine/db.py
21
engine/db.py
|
|
@ -60,6 +60,8 @@ CREATE TABLE IF NOT EXISTS entities (
|
||||||
z INTEGER NOT NULL,
|
z INTEGER NOT NULL,
|
||||||
species_id TEXT,
|
species_id TEXT,
|
||||||
gender TEXT,
|
gender TEXT,
|
||||||
|
body_type TEXT,
|
||||||
|
hair_color TEXT,
|
||||||
blood_percentage REAL
|
blood_percentage REAL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -209,8 +211,8 @@ class WorldRepository:
|
||||||
assert entity.position is not None
|
assert entity.position is not None
|
||||||
is_character = isinstance(entity, Character)
|
is_character = isinstance(entity, Character)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO entities (entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage)"
|
"INSERT INTO entities (entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender,"
|
||||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
" body_type, hair_color, blood_percentage) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(
|
(
|
||||||
entity.entity_id,
|
entity.entity_id,
|
||||||
entity.def_id,
|
entity.def_id,
|
||||||
|
|
@ -222,6 +224,8 @@ class WorldRepository:
|
||||||
entity.position.z,
|
entity.position.z,
|
||||||
entity.species_id if is_character else None,
|
entity.species_id if is_character else None,
|
||||||
entity.gender if is_character else None,
|
entity.gender if is_character else None,
|
||||||
|
entity.body_type if is_character else None,
|
||||||
|
entity.hair_color if is_character else None,
|
||||||
entity.blood_percentage if is_character else None,
|
entity.blood_percentage if is_character else None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -320,13 +324,15 @@ class WorldRepository:
|
||||||
)
|
)
|
||||||
|
|
||||||
entity_rows = cur.execute(
|
entity_rows = cur.execute(
|
||||||
"SELECT entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage FROM entities"
|
"SELECT entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, body_type,"
|
||||||
|
" hair_color, blood_percentage FROM entities"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage in entity_rows:
|
for entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, body_type, hair_color, blood_percentage in entity_rows:
|
||||||
position = EntityPosition(map_id, x, y, z)
|
position = EntityPosition(map_id, x, y, z)
|
||||||
if kind == "character":
|
if kind == "character":
|
||||||
entity: Entity = self._load_character(
|
entity: Entity = self._load_character(
|
||||||
cur, registry, entity_id, def_id, name, position, species_id, gender, blood_percentage
|
cur, registry, entity_id, def_id, name, position, species_id, gender, body_type,
|
||||||
|
hair_color, blood_percentage,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
entity = Entity(entity_id=entity_id, name=name, def_id=def_id, position=position)
|
entity = Entity(entity_id=entity_id, name=name, def_id=def_id, position=position)
|
||||||
|
|
@ -360,7 +366,8 @@ class WorldRepository:
|
||||||
return inventory
|
return inventory
|
||||||
|
|
||||||
def _load_character(
|
def _load_character(
|
||||||
self, cur, registry: DefRegistry, entity_id, def_id, name, position, species_id, gender, blood_percentage
|
self, cur, registry: DefRegistry, entity_id, def_id, name, position, species_id, gender, body_type,
|
||||||
|
hair_color, blood_percentage,
|
||||||
) -> Character:
|
) -> Character:
|
||||||
body_parts: list[BodyPart] = []
|
body_parts: list[BodyPart] = []
|
||||||
default_body_parts: list[BodyPart] = []
|
default_body_parts: list[BodyPart] = []
|
||||||
|
|
@ -418,6 +425,8 @@ class WorldRepository:
|
||||||
position=position,
|
position=position,
|
||||||
species_id=species_id,
|
species_id=species_id,
|
||||||
gender=gender,
|
gender=gender,
|
||||||
|
body_type=body_type,
|
||||||
|
hair_color=hair_color,
|
||||||
blood_percentage=blood_percentage if blood_percentage is not None else 100.0,
|
blood_percentage=blood_percentage if blood_percentage is not None else 100.0,
|
||||||
body_parts=body_parts,
|
body_parts=body_parts,
|
||||||
default_body_parts=default_body_parts,
|
default_body_parts=default_body_parts,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from engine.character import BodyPart, BodyPartDef, ClothingPiece, Organ, OrganDef
|
from engine.character import AilmentDef, BodyPart, BodyPartDef, ClothingPiece, Organ, OrganDef
|
||||||
from engine.item import ItemDef
|
from engine.item import ItemDef
|
||||||
from engine.species import AbilityDef, SpeciesDef
|
from engine.species import AbilityDef, SpeciesDef
|
||||||
from engine.tile import TILE_LAYERS, TileLayerDef
|
from engine.tile import TILE_LAYERS, TileLayerDef
|
||||||
|
|
@ -27,6 +27,7 @@ class DefRegistry:
|
||||||
self.item_defs: dict[str, ItemDef] = {}
|
self.item_defs: dict[str, ItemDef] = {}
|
||||||
self.body_part_defs: dict[str, BodyPartDef] = {}
|
self.body_part_defs: dict[str, BodyPartDef] = {}
|
||||||
self.organ_defs: dict[str, OrganDef] = {}
|
self.organ_defs: dict[str, OrganDef] = {}
|
||||||
|
self.ailment_defs: dict[str, AilmentDef] = {}
|
||||||
self.species_defs: dict[str, SpeciesDef] = {}
|
self.species_defs: dict[str, SpeciesDef] = {}
|
||||||
self.entity_defs: dict[str, EntityDef] = {}
|
self.entity_defs: dict[str, EntityDef] = {}
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ class DefRegistry:
|
||||||
registry._load_items(defs_dir / "items.json")
|
registry._load_items(defs_dir / "items.json")
|
||||||
registry._load_body_parts(defs_dir / "body_parts.json")
|
registry._load_body_parts(defs_dir / "body_parts.json")
|
||||||
registry._load_organs(defs_dir / "organs.json")
|
registry._load_organs(defs_dir / "organs.json")
|
||||||
|
registry._load_ailments(defs_dir / "ailments.json")
|
||||||
registry._load_species(defs_dir / "species.json")
|
registry._load_species(defs_dir / "species.json")
|
||||||
registry._load_entities(defs_dir / "entities.json")
|
registry._load_entities(defs_dir / "entities.json")
|
||||||
return registry
|
return registry
|
||||||
|
|
@ -126,6 +128,15 @@ class DefRegistry:
|
||||||
affects_organs=dict(fields.get("affects_organs", {})),
|
affects_organs=dict(fields.get("affects_organs", {})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _load_ailments(self, path: Path) -> None:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
for def_id, fields in data.items():
|
||||||
|
self.ailment_defs[def_id] = AilmentDef(
|
||||||
|
id=def_id,
|
||||||
|
name=fields.get("name", def_id),
|
||||||
|
check_weights=dict(fields.get("check_weights", {})),
|
||||||
|
)
|
||||||
|
|
||||||
def _load_species(self, path: Path) -> None:
|
def _load_species(self, path: Path) -> None:
|
||||||
data = json.loads(path.read_text())
|
data = json.loads(path.read_text())
|
||||||
for def_id, fields in data.items():
|
for def_id, fields in data.items():
|
||||||
|
|
@ -148,6 +159,8 @@ class DefRegistry:
|
||||||
default_body_parts=default_parts,
|
default_body_parts=default_parts,
|
||||||
default_organs=list(fields.get("default_organs", [])),
|
default_organs=list(fields.get("default_organs", [])),
|
||||||
body_type=fields.get("body_type", "biped"),
|
body_type=fields.get("body_type", "biped"),
|
||||||
|
body_type_options=list(fields.get("body_type_options", [])),
|
||||||
|
hair_colors=list(fields.get("hair_colors", [])),
|
||||||
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),
|
||||||
|
|
@ -184,6 +197,12 @@ class DefRegistry:
|
||||||
def get_organ_def(self, def_id: str) -> OrganDef:
|
def get_organ_def(self, def_id: str) -> OrganDef:
|
||||||
return self.organ_defs[def_id]
|
return self.organ_defs[def_id]
|
||||||
|
|
||||||
|
def get_ailment_def(self, def_id: str) -> AilmentDef | None:
|
||||||
|
"""None (not KeyError) for an unrecognized ailment id - not every Ailment instance
|
||||||
|
needs a matching def (e.g. ad-hoc/test-only ailments), see Character.ailment_penalty.
|
||||||
|
"""
|
||||||
|
return self.ailment_defs.get(def_id)
|
||||||
|
|
||||||
def get_species_def(self, def_id: str) -> SpeciesDef:
|
def get_species_def(self, def_id: str) -> SpeciesDef:
|
||||||
return self.species_defs[def_id]
|
return self.species_defs[def_id]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,22 @@ class MapEmbedding:
|
||||||
boundary in both directions with one code path, at any anchor/rotation.
|
boundary in both directions with one code path, at any anchor/rotation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent_map: "Map", child_map: "Map", anchor: tuple[int, int, int], rotation: int = 0):
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent_map: "Map",
|
||||||
|
child_map: "Map",
|
||||||
|
anchor: tuple[int, int, int],
|
||||||
|
rotation: int = 0,
|
||||||
|
max_rotation_speed: float = 90.0,
|
||||||
|
):
|
||||||
self.parent_map = parent_map
|
self.parent_map = parent_map
|
||||||
self.child_map = child_map
|
self.child_map = child_map
|
||||||
self.anchor = anchor
|
self.anchor = anchor
|
||||||
self.rotation = rotation % 4
|
self.rotation = rotation % 4
|
||||||
|
self.max_rotation_speed = max_rotation_speed # degrees/second - adjustable per ship
|
||||||
|
self.desired_rotation = self.rotation # heading steering is trying to reach, see
|
||||||
|
# resources/logic/steering.py::try_turn_ship / advance_ship_rotation
|
||||||
|
self.rotation_ready_at = 0.0 # game-clock time the next quarter-turn step is allowed
|
||||||
|
|
||||||
def footprint_size(self) -> tuple[int, int]:
|
def footprint_size(self) -> tuple[int, int]:
|
||||||
return rotated_size(self.child_map.width, self.child_map.height, self.rotation)
|
return rotated_size(self.child_map.width, self.child_map.height, self.rotation)
|
||||||
|
|
@ -114,7 +125,11 @@ class MapEmbedding:
|
||||||
self.anchor = anchor
|
self.anchor = anchor
|
||||||
|
|
||||||
def rotate_to(self, rotation: int) -> None:
|
def rotate_to(self, rotation: int) -> None:
|
||||||
"""Turn the embedded map to a new quarter-turn orientation (0-3, clockwise)."""
|
"""Turn the embedded map to a new quarter-turn orientation (0-3, clockwise) *instantly*,
|
||||||
|
bypassing max_rotation_speed - used internally by the rate-limited steering step (see
|
||||||
|
resources/logic/steering.py::advance_ship_rotation) to apply one already-validated
|
||||||
|
quarter-turn, and available directly for callers that don't care about rate limiting.
|
||||||
|
"""
|
||||||
self.rotation = rotation % 4
|
self.rotation = rotation % 4
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -149,8 +164,10 @@ class Map:
|
||||||
raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}")
|
raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}")
|
||||||
self._tiles[self._index(fx, fy, fz)] = tile
|
self._tiles[self._index(fx, fy, fz)] = tile
|
||||||
|
|
||||||
def embed(self, child: "Map", anchor: tuple[int, int, int], rotation: int = 0) -> MapEmbedding:
|
def embed(
|
||||||
embedding = MapEmbedding(self, child, anchor, rotation)
|
self, child: "Map", anchor: tuple[int, int, int], rotation: int = 0, max_rotation_speed: float = 90.0
|
||||||
|
) -> MapEmbedding:
|
||||||
|
embedding = MapEmbedding(self, child, anchor, rotation, max_rotation_speed)
|
||||||
self.embeddings.append(embedding)
|
self.embeddings.append(embedding)
|
||||||
child.parent_embedding = embedding
|
child.parent_embedding = embedding
|
||||||
return embedding
|
return embedding
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ class MapLoader:
|
||||||
|
|
||||||
for embedding in data.get("embeddings", []):
|
for embedding in data.get("embeddings", []):
|
||||||
child = self.load(embedding["map_file"])
|
child = self.load(embedding["map_file"])
|
||||||
m.embed(child, tuple(embedding["anchor"]), embedding.get("rotation", 0))
|
m.embed(
|
||||||
|
child,
|
||||||
|
tuple(embedding["anchor"]),
|
||||||
|
embedding.get("rotation", 0),
|
||||||
|
embedding.get("max_rotation_speed", 90.0),
|
||||||
|
)
|
||||||
|
|
||||||
return m
|
return m
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ class SpeciesDef:
|
||||||
default_organs: list[str] = field(default_factory=list) # organ_def ids; placed by each organ's own host_slot
|
default_organs: list[str] = field(default_factory=list) # organ_def ids; placed by each organ's own host_slot
|
||||||
body_type: str = "biped" # coarse anatomical shape (e.g. "biped", "quadruped") - gates which
|
body_type: str = "biped" # coarse anatomical shape (e.g. "biped", "quadruped") - gates which
|
||||||
# clothing fits at all, regardless of arm count; see ItemDef.compatible_body_types
|
# clothing fits at all, regardless of arm count; see ItemDef.compatible_body_types
|
||||||
|
body_type_options: list[str] = field(default_factory=list) # cosmetic build/archetype choices
|
||||||
|
# for character creation (e.g. "average"/"thin"/"fat"/"hulk") - NOT the same as body_type
|
||||||
|
# above, which is a fixed anatomical trait, not a player choice
|
||||||
|
hair_colors: list[str] = field(default_factory=list) # valid hair color choices for character
|
||||||
|
# creation; empty for species with no hair (e.g. reptilian, bot)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.map_loader import MapLoader
|
||||||
|
from engine.world import World
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorldTemplate:
|
||||||
|
"""One selectable starting-world option on the 'create world' screen.
|
||||||
|
|
||||||
|
For now every template is just a fixed map file this engine ships with. Planned follow-up
|
||||||
|
game modes (campaign/survival/creative) will add templates that build a World from
|
||||||
|
procedural generation instead of a map_file - map_file is left optional so those can slot
|
||||||
|
in as templates with map_file=None and their own generator, without changing this shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
map_file: str | None = None # resources/defs/maps/*.json, loaded via MapLoader
|
||||||
|
|
||||||
|
|
||||||
|
AVAILABLE_WORLD_TEMPLATES: list[WorldTemplate] = [
|
||||||
|
WorldTemplate(
|
||||||
|
id="test_map",
|
||||||
|
name="Test Map",
|
||||||
|
description="A small sample field with a docked, rotatable two-deck ship - exercises "
|
||||||
|
"the engine's core mechanics (embedding, steering, staircases, weather).",
|
||||||
|
map_file="harbor_world.json",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorldCreationMenu:
|
||||||
|
"""Pure interaction state for the 'create world' screen: pick which starting world template
|
||||||
|
to build a fresh World from. No rendering here - same boundary as StartScreen/CharacterMenu
|
||||||
|
(see their docstrings in this module and engine/ui.py) - this is the state/logic side: which
|
||||||
|
templates exist, which is selected, and handing back the chosen template once confirmed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
templates: list[WorldTemplate] = field(default_factory=lambda: list(AVAILABLE_WORLD_TEMPLATES))
|
||||||
|
selected_index: int | None = None
|
||||||
|
|
||||||
|
def select(self, index: int) -> None:
|
||||||
|
if 0 <= index < len(self.templates):
|
||||||
|
self.selected_index = index
|
||||||
|
|
||||||
|
def selected_template(self) -> WorldTemplate | None:
|
||||||
|
if self.selected_index is None:
|
||||||
|
return None
|
||||||
|
return self.templates[self.selected_index]
|
||||||
|
|
||||||
|
|
||||||
|
def build_world_from_template(template: WorldTemplate, registry: DefRegistry, map_loader: MapLoader) -> World:
|
||||||
|
"""Builds an empty (player-less) World from a template's map - the "confirm" action once a
|
||||||
|
WorldCreationMenu selection is made. Spawning/joining a character into it is a separate
|
||||||
|
step - see engine/start_screen.py::join_world_with_character.
|
||||||
|
"""
|
||||||
|
if template.map_file is None:
|
||||||
|
raise NotImplementedError(f"world template {template.id!r} has no map_file and no generator yet")
|
||||||
|
world = World(registry=registry)
|
||||||
|
root_map = map_loader.load(template.map_file)
|
||||||
|
world.add_map(root_map)
|
||||||
|
for embedding in root_map.embeddings:
|
||||||
|
world.add_map(embedding.child_map)
|
||||||
|
return world
|
||||||
3
main.py
3
main.py
|
|
@ -29,7 +29,7 @@ from engine.world import World
|
||||||
from resources.logic.actions import activate_hand
|
from resources.logic.actions import activate_hand
|
||||||
from resources.logic.health_ticks import apply_bleeding_tick
|
from resources.logic.health_ticks import apply_bleeding_tick
|
||||||
from resources.logic.organs import apply_organ_interactions_tick
|
from resources.logic.organs import apply_organ_interactions_tick
|
||||||
from resources.logic.steering import at_helm, try_steer_ship
|
from resources.logic.steering import advance_all_ship_rotations, at_helm, try_steer_ship
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parent
|
ROOT_DIR = Path(__file__).resolve().parent
|
||||||
DEFS_DIR = ROOT_DIR / "resources" / "defs"
|
DEFS_DIR = ROOT_DIR / "resources" / "defs"
|
||||||
|
|
@ -143,6 +143,7 @@ def main() -> None:
|
||||||
|
|
||||||
ticks = clock.advance(dt)
|
ticks = clock.advance(dt)
|
||||||
world.expire_time_warp_zones(clock.total_time)
|
world.expire_time_warp_zones(clock.total_time)
|
||||||
|
advance_all_ship_rotations(world, clock.total_time)
|
||||||
if ticks:
|
if ticks:
|
||||||
for entity in world.entities.values():
|
for entity in world.entities.values():
|
||||||
if isinstance(entity, Character):
|
if isinstance(entity, Character):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"impact_trauma": {
|
||||||
|
"name": "Impact Trauma",
|
||||||
|
"check_weights": { "strength": 0.2, "athletics": 0.2 }
|
||||||
|
},
|
||||||
|
"fracture": {
|
||||||
|
"name": "Fracture",
|
||||||
|
"check_weights": { "strength": 0.5, "athletics": 0.4, "acrobatics": 0.3, "sleight_of_hand": 0.3 }
|
||||||
|
},
|
||||||
|
"brain_trauma": {
|
||||||
|
"name": "Brain Trauma",
|
||||||
|
"check_weights": { "insight": 0.6, "perception": 0.5, "sleight_of_hand": 0.2 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
],
|
],
|
||||||
"default_organs": ["human_heart", "human_lungs", "human_liver", "human_kidneys", "human_brain"],
|
"default_organs": ["human_heart", "human_lungs", "human_liver", "human_kidneys", "human_brain"],
|
||||||
"body_type": "biped",
|
"body_type": "biped",
|
||||||
|
"body_type_options": ["average", "thin", "fat", "hulk"],
|
||||||
|
"hair_colors": ["black", "brown", "blonde", "red", "gray", "white"],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
|
@ -43,6 +45,8 @@
|
||||||
"reptilian_brain"
|
"reptilian_brain"
|
||||||
],
|
],
|
||||||
"body_type": "biped",
|
"body_type": "biped",
|
||||||
|
"body_type_options": ["average", "stocky", "slender"],
|
||||||
|
"hair_colors": [],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
|
@ -71,6 +75,8 @@
|
||||||
"zenari_cortex"
|
"zenari_cortex"
|
||||||
],
|
],
|
||||||
"body_type": "biped",
|
"body_type": "biped",
|
||||||
|
"body_type_options": ["average", "slender", "willowy"],
|
||||||
|
"hair_colors": [],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
|
@ -100,6 +106,8 @@
|
||||||
"bot_cooling_fan"
|
"bot_cooling_fan"
|
||||||
],
|
],
|
||||||
"body_type": "biped",
|
"body_type": "biped",
|
||||||
|
"body_type_options": ["compact", "standard", "heavy"],
|
||||||
|
"hair_colors": [],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
{ "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] },
|
||||||
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
{ "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] },
|
||||||
|
|
@ -122,6 +130,8 @@
|
||||||
],
|
],
|
||||||
"default_organs": ["dog_heart", "dog_lungs", "dog_liver", "dog_kidneys", "dog_brain"],
|
"default_organs": ["dog_heart", "dog_lungs", "dog_liver", "dog_kidneys", "dog_brain"],
|
||||||
"body_type": "quadruped",
|
"body_type": "quadruped",
|
||||||
|
"body_type_options": ["small", "medium", "large"],
|
||||||
|
"hair_colors": ["black", "brown", "golden", "white", "gray"],
|
||||||
"abilities": [
|
"abilities": [
|
||||||
{
|
{
|
||||||
"id": "leap",
|
"id": "leap",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||||
from typing import Protocol, Sequence, TypeVar
|
from typing import Protocol, Sequence, TypeVar
|
||||||
|
|
||||||
from engine.aim import apply_cone_deviation
|
from engine.aim import apply_cone_deviation
|
||||||
from engine.character import BodyPart, Character
|
from engine.character import Ailment, BodyPart, Character
|
||||||
from engine.defs import DefRegistry
|
from engine.defs import DefRegistry
|
||||||
from engine.item import ItemDef
|
from engine.item import ItemDef
|
||||||
from engine.map import rotate_vector
|
from engine.map import rotate_vector
|
||||||
|
|
@ -21,6 +21,25 @@ BLOCK_SKILL = "athletics"
|
||||||
BODY_PART_DIFFICULTY_ORDER = ("torso", "left_arm", "right_arm", "left_leg", "right_leg", "head")
|
BODY_PART_DIFFICULTY_ORDER = ("torso", "left_arm", "right_arm", "left_leg", "right_leg", "head")
|
||||||
PRECISION_PER_SLOT = 4 # attacker_check points needed to reach one slot further down the list
|
PRECISION_PER_SLOT = 4 # attacker_check points needed to reach one slot further down the list
|
||||||
|
|
||||||
|
# How precise the attacker's raw d20 roll needs to be (independent of skill, which already
|
||||||
|
# drives whether/how hard the hit lands) for a hit to do more than just chip integrity: a
|
||||||
|
# solid roll bruises, a near-perfect one breaks something - a fracture anywhere, or
|
||||||
|
# brain_trauma instead if it lands on the head. See resources/defs/ailments.json for how each
|
||||||
|
# of these then feeds into relevant ability checks (Character.ailment_penalty).
|
||||||
|
IMPACT_TRAUMA_ROLL_THRESHOLD = 15
|
||||||
|
FRACTURE_ROLL_THRESHOLD = 19
|
||||||
|
|
||||||
|
COMBAT_AILMENT_SEVERITY = {
|
||||||
|
"impact_trauma": 0.4,
|
||||||
|
"fracture": 0.8,
|
||||||
|
"brain_trauma": 0.9,
|
||||||
|
}
|
||||||
|
COMBAT_AILMENT_NAMES = {
|
||||||
|
"impact_trauma": "Impact Trauma",
|
||||||
|
"fracture": "Fracture",
|
||||||
|
"brain_trauma": "Brain Trauma",
|
||||||
|
}
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -72,6 +91,36 @@ def apply_body_part_damage(character: Character, slot: str, amount: float) -> Bo
|
||||||
return override
|
return override
|
||||||
|
|
||||||
|
|
||||||
|
def combat_ailment_for_roll(roll: int, slot: str) -> str | None:
|
||||||
|
"""Which ailment (if any) a hit this precise inflicts, on top of the flat integrity damage
|
||||||
|
every hit already does: a normal hit (low roll) just chips integrity, a solidly-rolled hit
|
||||||
|
also bruises (impact_trauma), and a near-perfect roll breaks something - a fracture on any
|
||||||
|
slot, or brain_trauma instead if it lands on the head. Driven by the raw d20 roll (not the
|
||||||
|
full attacker_check, which skill/luck can inflate arbitrarily) so the tiers stay meaningful
|
||||||
|
regardless of how skilled either fighter is.
|
||||||
|
"""
|
||||||
|
if roll >= FRACTURE_ROLL_THRESHOLD:
|
||||||
|
return "brain_trauma" if slot == "head" else "fracture"
|
||||||
|
if roll >= IMPACT_TRAUMA_ROLL_THRESHOLD:
|
||||||
|
return "impact_trauma"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_combat_ailment(body_part: BodyPart, ailment_id: str) -> Ailment:
|
||||||
|
"""Adds a combat-inflicted ailment to a body part override, or refreshes it (to whichever
|
||||||
|
severity is worse) if the same ailment is already present - getting hit hard enough for a
|
||||||
|
second fracture doesn't stack two separate fractures on top of each other.
|
||||||
|
"""
|
||||||
|
severity = COMBAT_AILMENT_SEVERITY[ailment_id]
|
||||||
|
existing = next((a for a in body_part.ailments if a.id == ailment_id), None)
|
||||||
|
if existing is not None:
|
||||||
|
existing.severity = max(existing.severity, severity)
|
||||||
|
return existing
|
||||||
|
ailment = Ailment(id=ailment_id, name=COMBAT_AILMENT_NAMES[ailment_id], severity=severity)
|
||||||
|
body_part.ailments.append(ailment)
|
||||||
|
return ailment
|
||||||
|
|
||||||
|
|
||||||
def roll_skill_check(
|
def roll_skill_check(
|
||||||
character: Character, skill_id: str, registry: DefRegistry, rng: DiceRoller
|
character: Character, skill_id: str, registry: DefRegistry, rng: DiceRoller
|
||||||
) -> tuple[int, float]:
|
) -> tuple[int, float]:
|
||||||
|
|
@ -137,7 +186,10 @@ def resolve_hit(
|
||||||
if hit:
|
if hit:
|
||||||
hit_slot = target_slot if target_slot is not None else choose_hit_slot(defender, attacker_check)
|
hit_slot = target_slot if target_slot is not None else choose_hit_slot(defender, attacker_check)
|
||||||
if hit_slot is not None:
|
if hit_slot is not None:
|
||||||
apply_body_part_damage(defender, hit_slot, damage)
|
hit_part = apply_body_part_damage(defender, hit_slot, damage)
|
||||||
|
ailment_id = combat_ailment_for_roll(roll, hit_slot)
|
||||||
|
if ailment_id is not None and hit_part is not None:
|
||||||
|
apply_combat_ailment(hit_part, ailment_id)
|
||||||
|
|
||||||
return AttackResult(
|
return AttackResult(
|
||||||
hit=hit,
|
hit=hit,
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,50 @@ def try_steer_ship(character: Character, world: World, dx: int, dy: int, dz: int
|
||||||
|
|
||||||
|
|
||||||
def try_turn_ship(character: Character, world: World, quarter_turns: int) -> bool:
|
def try_turn_ship(character: Character, world: World, quarter_turns: int) -> bool:
|
||||||
"""Turns the embedded map the character is helming, if the rotated footprint is clear."""
|
"""Sets the helmed ship's desired heading, quarter_turns further from its current desired
|
||||||
|
rotation (so two consecutive "turn right" commands queue up a full 180, same as if the
|
||||||
|
player held the control down). The ship doesn't snap to the new heading - it turns toward
|
||||||
|
it at its own max_rotation_speed, one quarter-turn at a time; see advance_ship_rotation,
|
||||||
|
called once per game tick, for the actual stepping. Returns whether the eventual target
|
||||||
|
orientation's footprint is clear right now - if the world changes before the ship gets
|
||||||
|
there, advance_ship_rotation simply pauses rather than forcing through a collision.
|
||||||
|
"""
|
||||||
embedding = at_helm(character, world)
|
embedding = at_helm(character, world)
|
||||||
if embedding is None:
|
if embedding is None:
|
||||||
return False
|
return False
|
||||||
new_rotation = (embedding.rotation + quarter_turns) % 4
|
new_desired = (embedding.desired_rotation + quarter_turns) % 4
|
||||||
if not _footprint_is_clear(world, embedding, embedding.anchor, new_rotation):
|
if not _footprint_is_clear(world, embedding, embedding.anchor, new_desired):
|
||||||
return False
|
return False
|
||||||
embedding.rotate_to(new_rotation)
|
embedding.desired_rotation = new_desired
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def advance_ship_rotation(embedding: MapEmbedding, world: World, now: float) -> bool:
|
||||||
|
"""Steps `embedding` one quarter-turn closer to its desired_rotation, if enough time has
|
||||||
|
passed since the last step (per its own max_rotation_speed) and the intermediate footprint
|
||||||
|
is clear. Turns the shorter way around (e.g. 0 -> 3 is one counter-clockwise step, not
|
||||||
|
three clockwise ones). No-op (returns False) if already at the desired heading, still on
|
||||||
|
cooldown, or the next step would collide - a blocked ship just waits, it doesn't give up on
|
||||||
|
the desired heading.
|
||||||
|
"""
|
||||||
|
if embedding.rotation == embedding.desired_rotation:
|
||||||
|
return False
|
||||||
|
if now < embedding.rotation_ready_at:
|
||||||
|
return False
|
||||||
|
delta = (embedding.desired_rotation - embedding.rotation) % 4
|
||||||
|
step = -1 if delta == 3 else 1 # 3 quarter-turns one way == 1 the other way, take the short one
|
||||||
|
next_rotation = (embedding.rotation + step) % 4
|
||||||
|
if not _footprint_is_clear(world, embedding, embedding.anchor, next_rotation):
|
||||||
|
return False
|
||||||
|
embedding.rotate_to(next_rotation)
|
||||||
|
embedding.rotation_ready_at = now + 90.0 / embedding.max_rotation_speed
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def advance_all_ship_rotations(world: World, now: float) -> None:
|
||||||
|
"""Advances every embedding in every map in the world one step, if it's due - call once per
|
||||||
|
game tick from the main loop (same spot as World.expire_time_warp_zones).
|
||||||
|
"""
|
||||||
|
for map_ in world.maps.values():
|
||||||
|
for embedding in map_.embeddings:
|
||||||
|
advance_ship_rotation(embedding, world, now)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
import random
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from engine.character_creator import CharacterCreator
|
from engine.character_creator import 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
|
||||||
|
|
||||||
|
|
@ -36,6 +37,24 @@ def test_set_species_rejects_unknown_species(registry):
|
||||||
assert creator.species_id is None
|
assert creator.species_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dog_is_not_a_player_selectable_species(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_species("dog") is False
|
||||||
|
assert creator.species_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_species_excludes_dog(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert "dog" not in creator.available_species()
|
||||||
|
assert set(creator.available_species()) == {"human", "reptilian_quad", "alien_tri", "bot"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_selectable_species_is_configurable(registry):
|
||||||
|
creator = CharacterCreator(registry, selectable_species=("dog",))
|
||||||
|
assert creator.set_species("dog") is True
|
||||||
|
assert creator.set_species("human") is False
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -65,13 +84,124 @@ def test_switching_species_resets_gender_if_no_longer_valid(registry):
|
||||||
|
|
||||||
|
|
||||||
def test_switching_species_keeps_gender_if_still_valid(registry):
|
def test_switching_species_keeps_gender_if_still_valid(registry):
|
||||||
creator = CharacterCreator(registry)
|
creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",))
|
||||||
creator.set_species("human")
|
creator.set_species("human")
|
||||||
creator.set_gender("female")
|
creator.set_gender("female")
|
||||||
creator.set_species("dog") # dog also has "male"/"female"
|
creator.set_species("dog") # dog also has "male"/"female"
|
||||||
assert creator.gender == "female"
|
assert creator.gender == "female"
|
||||||
|
|
||||||
|
|
||||||
|
# --- body type / hair color selection ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_species_auto_fills_body_type_and_hair_color(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.body_type == "average"
|
||||||
|
assert creator.hair_color == "black"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_species_with_no_hair_colors_leaves_hair_color_none(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("reptilian_quad")
|
||||||
|
assert creator.body_type == "average"
|
||||||
|
assert creator.hair_color is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_body_type_accepts_a_valid_option(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.set_body_type("hulk") is True
|
||||||
|
assert creator.body_type == "hulk"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_body_type_rejects_invalid_option(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.set_body_type("gigantic") is False
|
||||||
|
assert creator.body_type == "average"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_hair_color_accepts_a_valid_option(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.set_hair_color("red") is True
|
||||||
|
assert creator.hair_color == "red"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_hair_color_rejects_invalid_option_for_species_with_no_hair(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("bot")
|
||||||
|
assert creator.set_hair_color("black") is False
|
||||||
|
assert creator.hair_color is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_body_type_before_species_is_a_no_op(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_body_type("hulk") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_species_resets_body_type_and_hair_color_if_no_longer_valid(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
creator.set_body_type("hulk")
|
||||||
|
creator.set_hair_color("red")
|
||||||
|
creator.set_species("bot") # no body_type_options overlap, no hair at all
|
||||||
|
assert creator.body_type == "compact" # bot's first body_type option
|
||||||
|
assert creator.hair_color is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- starter clothing selection -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_starter_clothing_accepts_a_compatible_item(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
assert creator.set_starter_clothing("torso", "leather_jacket") is True
|
||||||
|
assert creator.starter_clothing["torso"] == "leather_jacket"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_starter_clothing_rejects_incompatible_body_type(registry):
|
||||||
|
creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",))
|
||||||
|
creator.set_species("dog")
|
||||||
|
assert creator.set_starter_clothing("torso", "leather_jacket") is False
|
||||||
|
assert "torso" not in creator.starter_clothing
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_starter_clothing_rejects_incompatible_arm_count(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("reptilian_quad") # 4 arms
|
||||||
|
assert creator.set_starter_clothing("torso", "leather_jacket") is False # 2-sleeve jacket
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_starter_clothing_accepts_matching_species_gear(registry):
|
||||||
|
creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",))
|
||||||
|
creator.set_species("dog")
|
||||||
|
assert creator.set_starter_clothing("torso", "dog_harness") is True
|
||||||
|
assert creator.set_starter_clothing("back", "dog_saddlebag") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_starter_clothing_before_species_is_a_no_op(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_starter_clothing("torso", "leather_jacket") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_starter_clothing_clears_a_slot(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_species("human")
|
||||||
|
creator.set_starter_clothing("torso", "leather_jacket")
|
||||||
|
creator.remove_starter_clothing("torso")
|
||||||
|
assert "torso" not in creator.starter_clothing
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_species_drops_starter_clothing(registry):
|
||||||
|
creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",))
|
||||||
|
creator.set_species("human")
|
||||||
|
creator.set_starter_clothing("torso", "leather_jacket")
|
||||||
|
creator.set_species("dog")
|
||||||
|
assert creator.starter_clothing == {}
|
||||||
|
|
||||||
|
|
||||||
# --- skill point budget ---------------------------------------------------------------------
|
# --- skill point budget ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,6 +252,132 @@ def test_decrease_skill_below_zero_is_a_no_op(registry):
|
||||||
assert creator.decrease_skill("stealth") is False
|
assert creator.decrease_skill("stealth") is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- skill allocation modes ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_to_point_buy_mode(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.skill_mode == "point_buy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_skill_mode_rejects_unknown_mode(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
assert creator.set_skill_mode("vibes") is False
|
||||||
|
assert creator.skill_mode == "point_buy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_to_point_buy_resets_bio_and_clears_rolled_values(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.increase_skill("stealth")
|
||||||
|
creator.set_skill_mode("point_buy")
|
||||||
|
assert creator.bio["stealth"] == 0
|
||||||
|
assert creator.rolled_values == []
|
||||||
|
|
||||||
|
|
||||||
|
# roll_assign: roll a fixed pool, place each value on a skill of your choosing
|
||||||
|
|
||||||
|
|
||||||
|
def test_roll_assign_generates_a_fixed_pool_of_values(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("roll_assign", rng=random.Random(1))
|
||||||
|
assert len(creator.rolled_values) == 6
|
||||||
|
assert all(0 <= v <= creator.max_skill_level for v in creator.rolled_values)
|
||||||
|
assert all(v == 0 for v in creator.bio.values()) # nothing placed yet
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_rolled_value_places_a_value_and_removes_it_from_the_pool(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("roll_assign")
|
||||||
|
creator.rolled_values = [3, 1, 4]
|
||||||
|
|
||||||
|
assert creator.assign_rolled_value(0, "stealth") is True
|
||||||
|
assert creator.bio["stealth"] == 3
|
||||||
|
assert creator.rolled_values == [1, 4] # the placed value is gone, order preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_rolled_value_out_of_range_is_a_no_op(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("roll_assign")
|
||||||
|
creator.rolled_values = [3]
|
||||||
|
assert creator.assign_rolled_value(5, "stealth") is False
|
||||||
|
assert creator.rolled_values == [3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_rolled_value_rejects_unknown_skill(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("roll_assign")
|
||||||
|
creator.rolled_values = [3]
|
||||||
|
assert creator.assign_rolled_value(0, "lockpicking") is False
|
||||||
|
assert creator.rolled_values == [3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_rolled_value_only_works_in_roll_assign_mode(registry):
|
||||||
|
creator = CharacterCreator(registry) # defaults to point_buy
|
||||||
|
assert creator.assign_rolled_value(0, "stealth") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_skill_does_nothing_outside_point_buy_mode(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("roll_assign")
|
||||||
|
assert creator.increase_skill("stealth") is False
|
||||||
|
assert creator.decrease_skill("stealth") is False
|
||||||
|
|
||||||
|
|
||||||
|
# manual: any skill, any value, no budget
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_skill_manual_sets_any_value(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("manual")
|
||||||
|
assert creator.set_skill_manual("stealth", 5) is True
|
||||||
|
assert creator.bio["stealth"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_skill_manual_rejects_value_out_of_range(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("manual")
|
||||||
|
assert creator.set_skill_manual("stealth", 6) is False
|
||||||
|
assert creator.set_skill_manual("stealth", -1) is False
|
||||||
|
assert creator.bio["stealth"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_skill_manual_rejects_unknown_skill(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("manual")
|
||||||
|
assert creator.set_skill_manual("lockpicking", 3) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_skill_manual_only_works_in_manual_mode(registry):
|
||||||
|
creator = CharacterCreator(registry) # defaults to point_buy
|
||||||
|
assert creator.set_skill_manual("stealth", 3) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_skill_manual_has_no_budget_limit(registry):
|
||||||
|
creator = CharacterCreator(registry, skill_points_budget=1)
|
||||||
|
creator.set_skill_mode("manual")
|
||||||
|
for skill_id in creator.bio:
|
||||||
|
assert creator.set_skill_manual(skill_id, 5) is True
|
||||||
|
assert creator.spent_skill_points() == 5 * len(creator.bio) # far past the point-buy budget
|
||||||
|
|
||||||
|
|
||||||
|
# random: every skill gets its own value immediately
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_mode_fills_every_skill_immediately(registry):
|
||||||
|
creator = CharacterCreator(registry)
|
||||||
|
creator.set_skill_mode("random", rng=random.Random(1))
|
||||||
|
assert all(0 <= v <= creator.max_skill_level for v in creator.bio.values())
|
||||||
|
assert creator.rolled_values == [] # nothing left to place - it's all already assigned
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_mode_is_deterministic_given_the_same_rng_seed(registry):
|
||||||
|
a = CharacterCreator(registry)
|
||||||
|
a.set_skill_mode("random", rng=random.Random(42))
|
||||||
|
b = CharacterCreator(registry)
|
||||||
|
b.set_skill_mode("random", rng=random.Random(42))
|
||||||
|
assert a.bio == b.bio
|
||||||
|
|
||||||
|
|
||||||
# --- body part preview ------------------------------------------------------------------------
|
# --- body part preview ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -131,7 +387,7 @@ def test_preview_body_parts_empty_before_species_chosen(registry):
|
||||||
|
|
||||||
|
|
||||||
def test_preview_body_parts_matches_species_defaults(registry):
|
def test_preview_body_parts_matches_species_defaults(registry):
|
||||||
creator = CharacterCreator(registry)
|
creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",))
|
||||||
creator.set_species("dog")
|
creator.set_species("dog")
|
||||||
slots = {p.slot for p in creator.preview_body_parts()}
|
slots = {p.slot for p in creator.preview_body_parts()}
|
||||||
assert slots == {"head", "torso", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"}
|
assert slots == {"head", "torso", "front_left_leg", "front_right_leg", "back_left_leg", "back_right_leg"}
|
||||||
|
|
@ -166,12 +422,26 @@ def test_build_character_produces_a_character_with_chosen_traits(registry):
|
||||||
assert character.name == "Vex" # whitespace trimmed
|
assert character.name == "Vex" # whitespace trimmed
|
||||||
assert character.species_id == "reptilian_quad"
|
assert character.species_id == "reptilian_quad"
|
||||||
assert character.gender == "none"
|
assert character.gender == "none"
|
||||||
|
assert character.body_type == "average" # auto-filled, reptilian has no hair to color
|
||||||
|
assert character.hair_color is None
|
||||||
assert character.bio["athletics"] == 1
|
assert character.bio["athletics"] == 1
|
||||||
assert {p.slot for p in character.default_body_parts} == {
|
assert {p.slot for p in character.default_body_parts} == {
|
||||||
"head", "torso", "left_arm", "right_arm", "left_arm_2", "right_arm_2", "left_leg", "right_leg",
|
"head", "torso", "left_arm", "right_arm", "left_arm_2", "right_arm_2", "left_leg", "right_leg",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_character_includes_starter_clothing(registry):
|
||||||
|
creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",))
|
||||||
|
creator.set_name("Rex")
|
||||||
|
creator.set_species("dog")
|
||||||
|
creator.set_starter_clothing("torso", "dog_harness")
|
||||||
|
creator.set_starter_clothing("back", "dog_saddlebag")
|
||||||
|
|
||||||
|
character = creator.build_character()
|
||||||
|
assert character.get_clothing("torso").item_def_id == "dog_harness"
|
||||||
|
assert character.get_clothing("back").item_def_id == "dog_saddlebag"
|
||||||
|
|
||||||
|
|
||||||
# --- confirm: saves into the library -------------------------------------------------------------
|
# --- confirm: saves into the library -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ def build_character(registry) -> Character:
|
||||||
name="Vex",
|
name="Vex",
|
||||||
species_id="reptilian_quad",
|
species_id="reptilian_quad",
|
||||||
gender="none",
|
gender="none",
|
||||||
|
body_type="stocky",
|
||||||
blood_percentage=88.0,
|
blood_percentage=88.0,
|
||||||
strength=14,
|
strength=14,
|
||||||
body_parts=[
|
body_parts=[
|
||||||
|
|
@ -63,6 +64,8 @@ def test_save_and_load_roundtrip_preserves_identity_and_species(registry, librar
|
||||||
assert loaded.name == "Vex"
|
assert loaded.name == "Vex"
|
||||||
assert loaded.species_id == "reptilian_quad"
|
assert loaded.species_id == "reptilian_quad"
|
||||||
assert loaded.gender == "none"
|
assert loaded.gender == "none"
|
||||||
|
assert loaded.body_type == "stocky"
|
||||||
|
assert loaded.hair_color is None
|
||||||
assert loaded.blood_percentage == pytest.approx(88.0)
|
assert loaded.blood_percentage == pytest.approx(88.0)
|
||||||
assert loaded.strength == 14
|
assert loaded.strength == 14
|
||||||
assert loaded.position is None # portable template, not tied to any world
|
assert loaded.position is None # portable template, not tied to any world
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.character import Ailment, BodyPart, Character
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from resources.logic.combat import (
|
||||||
|
FRACTURE_ROLL_THRESHOLD,
|
||||||
|
IMPACT_TRAUMA_ROLL_THRESHOLD,
|
||||||
|
apply_combat_ailment,
|
||||||
|
combat_ailment_for_roll,
|
||||||
|
perform_attack,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def make_human(registry, **kwargs) -> Character:
|
||||||
|
return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRng:
|
||||||
|
def __init__(self, roll: int):
|
||||||
|
self._roll = roll
|
||||||
|
|
||||||
|
def randint(self, _lo, _hi):
|
||||||
|
return self._roll
|
||||||
|
|
||||||
|
def choice(self, seq):
|
||||||
|
return seq[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --- combat_ailment_for_roll: pure roll -> ailment tiers -------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_roll_causes_no_ailment():
|
||||||
|
assert combat_ailment_for_roll(IMPACT_TRAUMA_ROLL_THRESHOLD - 1, "torso") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_solid_roll_causes_impact_trauma():
|
||||||
|
assert combat_ailment_for_roll(IMPACT_TRAUMA_ROLL_THRESHOLD, "torso") == "impact_trauma"
|
||||||
|
assert combat_ailment_for_roll(FRACTURE_ROLL_THRESHOLD - 1, "left_arm") == "impact_trauma"
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_perfect_roll_causes_a_fracture_on_a_non_head_slot():
|
||||||
|
assert combat_ailment_for_roll(FRACTURE_ROLL_THRESHOLD, "left_arm") == "fracture"
|
||||||
|
assert combat_ailment_for_roll(20, "torso") == "fracture"
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_perfect_roll_on_the_head_causes_brain_trauma_instead():
|
||||||
|
assert combat_ailment_for_roll(FRACTURE_ROLL_THRESHOLD, "head") == "brain_trauma"
|
||||||
|
assert combat_ailment_for_roll(20, "head") == "brain_trauma"
|
||||||
|
|
||||||
|
|
||||||
|
# --- apply_combat_ailment: add vs. refresh -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_combat_ailment_adds_a_new_ailment():
|
||||||
|
part = BodyPart("torso", "human_torso")
|
||||||
|
ailment = apply_combat_ailment(part, "fracture")
|
||||||
|
assert part.ailments == [ailment]
|
||||||
|
assert ailment.id == "fracture"
|
||||||
|
assert ailment.severity == pytest.approx(0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_combat_ailment_does_not_duplicate_the_same_ailment():
|
||||||
|
part = BodyPart("torso", "human_torso")
|
||||||
|
apply_combat_ailment(part, "impact_trauma")
|
||||||
|
apply_combat_ailment(part, "impact_trauma")
|
||||||
|
assert len(part.ailments) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_combat_ailment_upgrades_severity_but_never_downgrades():
|
||||||
|
part = BodyPart("torso", "human_torso")
|
||||||
|
part.ailments.append(Ailment(id="fracture", name="Fracture", severity=0.2)) # e.g. healing
|
||||||
|
apply_combat_ailment(part, "fracture")
|
||||||
|
assert part.ailments[0].severity == pytest.approx(0.8) # took the worse (combat) severity
|
||||||
|
|
||||||
|
|
||||||
|
# --- integration: perform_attack actually inflicts ailments on a hit ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_precision_hit_damages_but_inflicts_no_ailment(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, target_slot="torso", rng=FixedRng(roll=12))
|
||||||
|
assert result.hit is True
|
||||||
|
torso = defender.get_body_part("torso")
|
||||||
|
assert torso.integrity < 100.0
|
||||||
|
assert torso.ailments == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_solid_hit_inflicts_impact_trauma(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, target_slot="torso", rng=FixedRng(roll=IMPACT_TRAUMA_ROLL_THRESHOLD))
|
||||||
|
assert result.hit is True
|
||||||
|
torso = defender.get_body_part("torso")
|
||||||
|
assert [a.id for a in torso.ailments] == ["impact_trauma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_perfect_hit_inflicts_a_fracture(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, target_slot="left_arm", rng=FixedRng(roll=FRACTURE_ROLL_THRESHOLD))
|
||||||
|
assert result.hit is True
|
||||||
|
arm = defender.get_body_part("left_arm")
|
||||||
|
assert [a.id for a in arm.ailments] == ["fracture"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_near_perfect_hit_on_the_head_inflicts_brain_trauma(registry):
|
||||||
|
attacker = make_human(registry)
|
||||||
|
attacker.bio["athletics"] = 10
|
||||||
|
defender = make_human(registry)
|
||||||
|
|
||||||
|
result = perform_attack(attacker, defender, registry, target_slot="head", rng=FixedRng(roll=20))
|
||||||
|
assert result.hit is True
|
||||||
|
head = defender.get_body_part("head")
|
||||||
|
assert [a.id for a in head.ailments] == ["brain_trauma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_miss_inflicts_no_ailment(registry):
|
||||||
|
attacker = make_human(registry) # athletics 0
|
||||||
|
defender = make_human(registry)
|
||||||
|
result = perform_attack(attacker, defender, registry, target_slot="head", rng=FixedRng(roll=1))
|
||||||
|
assert result.hit is False
|
||||||
|
assert defender.get_body_part("head").ailments == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- ailment_penalty / total_check_penalty ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ailment_penalty_zero_when_no_ailments(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
assert character.ailment_penalty("strength", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ailment_penalty_zero_for_an_ailment_with_no_matching_def(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
torso.ailments.append(Ailment(id="frostbite", name="Frostbite", severity=1.0)) # no AilmentDef exists for this
|
||||||
|
assert character.ailment_penalty("strength", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ailment_penalty_rises_with_a_fracture(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
arm = character.get_or_create_body_part_override("left_arm")
|
||||||
|
apply_combat_ailment(arm, "fracture")
|
||||||
|
penalty = character.ailment_penalty("strength", registry)
|
||||||
|
assert penalty > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ailment_penalty_zero_for_an_unrelated_check(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
torso = character.get_or_create_body_part_override("torso")
|
||||||
|
apply_combat_ailment(torso, "fracture") # fracture doesn't weight "insight"
|
||||||
|
assert character.ailment_penalty("insight", registry) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_brain_trauma_hits_insight_and_perception_hard(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
head = character.get_or_create_body_part_override("head")
|
||||||
|
apply_combat_ailment(head, "brain_trauma")
|
||||||
|
assert character.ailment_penalty("insight", registry) == pytest.approx(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_check_penalty_combines_skill_organ_and_ailment_damage(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [BodyPart("left_arm", "human_left_arm", integrity=60.0)]
|
||||||
|
arm = character.get_or_create_body_part_override("left_arm")
|
||||||
|
apply_combat_ailment(arm, "impact_trauma")
|
||||||
|
|
||||||
|
combined = character.total_check_penalty("athletics", registry)
|
||||||
|
body_only = character.skill_penalty("athletics", registry)
|
||||||
|
ailment_only = character.ailment_penalty("athletics", registry)
|
||||||
|
assert combined > body_only
|
||||||
|
assert combined > ailment_only
|
||||||
|
assert combined <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_check_penalty_unaffected_when_no_ailments_present(registry):
|
||||||
|
character = make_human(registry)
|
||||||
|
character.body_parts = [BodyPart("left_leg", "human_left_leg", integrity=20.0)]
|
||||||
|
assert character.total_check_penalty("acrobatics", registry) == pytest.approx(
|
||||||
|
character.skill_penalty("acrobatics", registry)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- registry loading -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ailment_defs_load_with_check_weights(registry):
|
||||||
|
fracture = registry.get_ailment_def("fracture")
|
||||||
|
assert fracture is not None
|
||||||
|
assert fracture.check_weights["strength"] == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ailment_def_returns_none_for_unknown_id(registry):
|
||||||
|
assert registry.get_ailment_def("not_a_real_ailment") is None
|
||||||
|
|
@ -36,6 +36,8 @@ def build_world(registry) -> World:
|
||||||
position=EntityPosition("demo_world", 7, 5, 0),
|
position=EntityPosition("demo_world", 7, 5, 0),
|
||||||
species_id="human",
|
species_id="human",
|
||||||
gender="nonbinary",
|
gender="nonbinary",
|
||||||
|
body_type="thin",
|
||||||
|
hair_color="red",
|
||||||
blood_percentage=82.5,
|
blood_percentage=82.5,
|
||||||
body_parts=[
|
body_parts=[
|
||||||
BodyPart("head", "human_head_hat"),
|
BodyPart("head", "human_head_hat"),
|
||||||
|
|
@ -94,6 +96,8 @@ def test_save_and_load_roundtrip_preserves_health_fields(registry):
|
||||||
|
|
||||||
assert player.species_id == "human"
|
assert player.species_id == "human"
|
||||||
assert player.gender == "nonbinary"
|
assert player.gender == "nonbinary"
|
||||||
|
assert player.body_type == "thin"
|
||||||
|
assert player.hair_color == "red"
|
||||||
assert player.blood_percentage == pytest.approx(82.5)
|
assert player.blood_percentage == pytest.approx(82.5)
|
||||||
assert player.mental_stats == {"insanity": 12.5}
|
assert player.mental_stats == {"insanity": 12.5}
|
||||||
assert player.bio["stealth"] == 3
|
assert player.bio["stealth"] == 3
|
||||||
|
|
|
||||||
|
|
@ -113,3 +113,29 @@ def test_demo_world_tiles_resolve_against_tile_defs(registry):
|
||||||
door_tile = ship.get_tile(2, 0, 0)
|
door_tile = ship.get_tile(2, 0, 0)
|
||||||
assert door_tile.room is None
|
assert door_tile.room is None
|
||||||
assert door_tile.is_walkable(registry) is True
|
assert door_tile.is_walkable(registry) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_loader_defaults_max_rotation_speed_to_90(registry):
|
||||||
|
loader = MapLoader(DEFS_DIR / "maps")
|
||||||
|
harbor = loader.load("harbor_world.json")
|
||||||
|
assert harbor.embeddings[0].max_rotation_speed == 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_loader_reads_a_custom_max_rotation_speed(tmp_path):
|
||||||
|
import json
|
||||||
|
|
||||||
|
maps_dir = tmp_path / "maps"
|
||||||
|
maps_dir.mkdir()
|
||||||
|
(maps_dir / "slow_ship.json").write_text(json.dumps({
|
||||||
|
"map_id": "slow_ship", "width": 2, "height": 2, "depth": 1,
|
||||||
|
"default_tile": {"flooring": "wood_deck"}, "tiles": [],
|
||||||
|
}))
|
||||||
|
(maps_dir / "field.json").write_text(json.dumps({
|
||||||
|
"map_id": "field", "width": 5, "height": 5, "depth": 1,
|
||||||
|
"default_tile": {"flooring": "grass"}, "tiles": [],
|
||||||
|
"embeddings": [{"map_file": "slow_ship.json", "anchor": [1, 1, 0], "max_rotation_speed": 15.0}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
loader = MapLoader(maps_dir)
|
||||||
|
field = loader.load("field.json")
|
||||||
|
assert field.embeddings[0].max_rotation_speed == 15.0
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,22 @@ def test_embedding_at_detects_child_footprint():
|
||||||
assert parent.embedding_at(0, 0, 0) is None # outside ship footprint
|
assert parent.embedding_at(0, 0, 0) is None # outside ship footprint
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_defaults_max_rotation_speed_and_desired_rotation():
|
||||||
|
_parent, child = make_world()
|
||||||
|
embedding = child.parent_embedding
|
||||||
|
assert embedding.max_rotation_speed == 90.0
|
||||||
|
assert embedding.desired_rotation == embedding.rotation == 0
|
||||||
|
assert embedding.rotation_ready_at == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_accepts_a_custom_max_rotation_speed():
|
||||||
|
parent = Map("world", 10, 10, 1)
|
||||||
|
child = Map("ship", 2, 2, 1)
|
||||||
|
embedding = parent.embed(child, (0, 0, 0), rotation=1, max_rotation_speed=45.0)
|
||||||
|
assert embedding.max_rotation_speed == 45.0
|
||||||
|
assert embedding.rotation == embedding.desired_rotation == 1
|
||||||
|
|
||||||
|
|
||||||
class FakeRegistry:
|
class FakeRegistry:
|
||||||
def get_tile_layer_def(self, layer, def_id):
|
def get_tile_layer_def(self, layer, def_id):
|
||||||
if layer == "room" and def_id == "wood_wall":
|
if layer == "room" and def_id == "wood_wall":
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from engine.item import ItemInstance
|
||||||
from engine.map_loader import MapLoader
|
from engine.map_loader import MapLoader
|
||||||
from engine.world import World
|
from engine.world import World
|
||||||
from resources.logic.actions import activate_hand
|
from resources.logic.actions import activate_hand
|
||||||
from resources.logic.steering import at_helm, try_steer_ship, try_turn_ship
|
from resources.logic.steering import advance_ship_rotation, at_helm, try_steer_ship, try_turn_ship
|
||||||
from resources.logic.weather import is_exposed
|
from resources.logic.weather import is_exposed
|
||||||
|
|
||||||
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
@ -125,6 +125,9 @@ def test_turning_the_ship_changes_its_rotation(world_and_ship, registry):
|
||||||
helmsman = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1))
|
helmsman = make_human(registry, position=EntityPosition("ship_test", 1, 1, 1))
|
||||||
world.add_entity(helmsman)
|
world.add_entity(helmsman)
|
||||||
assert try_turn_ship(helmsman, world, 1) is True
|
assert try_turn_ship(helmsman, world, 1) is True
|
||||||
|
assert embedding.desired_rotation == 2
|
||||||
|
# ship doesn't snap - it turns toward the desired heading at its own max_rotation_speed
|
||||||
|
assert advance_ship_rotation(embedding, world, now=0.0) is True
|
||||||
assert embedding.rotation == 2
|
assert embedding.rotation == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from engine.entity import EntityPosition
|
||||||
from engine.map_loader import MapLoader
|
from engine.map_loader import MapLoader
|
||||||
from engine.tile import TileLayerInstance
|
from engine.tile import TileLayerInstance
|
||||||
from engine.world import World
|
from engine.world import World
|
||||||
from resources.logic.steering import at_helm, try_steer_ship, try_turn_ship
|
from resources.logic.steering import advance_all_ship_rotations, advance_ship_rotation, at_helm, try_steer_ship, try_turn_ship
|
||||||
|
|
||||||
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
@ -91,12 +91,89 @@ def test_steering_fails_off_the_parent_map_edge(registry):
|
||||||
assert try_steer_ship(helmsman, world, -1, 0, 0) is False
|
assert try_steer_ship(helmsman, world, -1, 0, 0) is False
|
||||||
|
|
||||||
|
|
||||||
def test_turning_the_ship_rotates_the_embedding(registry):
|
def test_turning_the_ship_sets_desired_rotation_but_not_the_actual_one_yet(registry):
|
||||||
world = build_demo_world(registry)
|
world = build_demo_world(registry)
|
||||||
embedding = world.maps["ship_small"].parent_embedding
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0))
|
helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0))
|
||||||
|
|
||||||
assert try_turn_ship(helmsman, world, 1) is True
|
assert try_turn_ship(helmsman, world, 1) is True
|
||||||
|
assert embedding.desired_rotation == 1
|
||||||
|
assert embedding.rotation == 0 # doesn't snap - advance_ship_rotation steps it there
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_turn_commands_stack_the_desired_rotation(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
|
helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0))
|
||||||
|
|
||||||
|
try_turn_ship(helmsman, world, 1)
|
||||||
|
try_turn_ship(helmsman, world, 1)
|
||||||
|
assert embedding.desired_rotation == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_ship_rotation_steps_one_quarter_turn_at_a_time(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
|
helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0))
|
||||||
|
embedding.max_rotation_speed = 90.0 # 1 quarter-turn/second
|
||||||
|
|
||||||
|
try_turn_ship(helmsman, world, 2) # desired_rotation = 2, two steps needed
|
||||||
|
assert advance_ship_rotation(embedding, world, now=0.0) is True
|
||||||
|
assert embedding.rotation == 1
|
||||||
|
# not enough time has passed yet for the second step
|
||||||
|
assert advance_ship_rotation(embedding, world, now=0.5) is False
|
||||||
|
assert embedding.rotation == 1
|
||||||
|
assert advance_ship_rotation(embedding, world, now=1.0) is True
|
||||||
|
assert embedding.rotation == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_ship_rotation_takes_the_shorter_direction(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
|
embedding.desired_rotation = 3
|
||||||
|
advance_ship_rotation(embedding, world, now=0.0)
|
||||||
|
assert embedding.rotation == 3 # one counter-clockwise step, not three clockwise ones
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_ship_rotation_no_op_when_already_at_desired(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
|
assert advance_ship_rotation(embedding, world, now=0.0) is False
|
||||||
|
assert embedding.rotation == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_slower_max_rotation_speed_takes_longer_per_step(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
|
embedding.max_rotation_speed = 30.0 # 3 seconds per quarter-turn
|
||||||
|
embedding.desired_rotation = 1
|
||||||
|
|
||||||
|
assert advance_ship_rotation(embedding, world, now=0.0) is True
|
||||||
|
assert embedding.rotation == 1
|
||||||
|
assert embedding.rotation_ready_at == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_ship_rotation_pauses_if_blocked_but_keeps_desired_heading(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
parent = world.maps["demo_world"]
|
||||||
|
ship = world.maps["ship_small"]
|
||||||
|
embedding = ship.parent_embedding
|
||||||
|
# block the intermediate rotated footprint so the step can't complete
|
||||||
|
for x in range(parent.width):
|
||||||
|
for y in range(parent.height):
|
||||||
|
parent.get_tile(x, y, 0).room = TileLayerInstance("wood_wall")
|
||||||
|
embedding.desired_rotation = 1
|
||||||
|
|
||||||
|
assert advance_ship_rotation(embedding, world, now=0.0) is False
|
||||||
|
assert embedding.rotation == 0
|
||||||
|
assert embedding.desired_rotation == 1 # still wants to get there, just blocked for now
|
||||||
|
|
||||||
|
|
||||||
|
def test_advance_all_ship_rotations_advances_every_embedding(registry):
|
||||||
|
world = build_demo_world(registry)
|
||||||
|
embedding = world.maps["ship_small"].parent_embedding
|
||||||
|
embedding.desired_rotation = 1
|
||||||
|
advance_all_ship_rotations(world, now=0.0)
|
||||||
assert embedding.rotation == 1
|
assert embedding.rotation == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.defs import DefRegistry
|
||||||
|
from engine.map_loader import MapLoader
|
||||||
|
from engine.world_creation import AVAILABLE_WORLD_TEMPLATES, WorldCreationMenu, WorldTemplate, build_world_from_template
|
||||||
|
|
||||||
|
DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def registry():
|
||||||
|
return DefRegistry.load(DEFS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def map_loader():
|
||||||
|
return MapLoader(DEFS_DIR / "maps")
|
||||||
|
|
||||||
|
|
||||||
|
# --- WorldCreationMenu -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_starts_with_the_available_templates():
|
||||||
|
menu = WorldCreationMenu()
|
||||||
|
assert [t.id for t in menu.templates] == [t.id for t in AVAILABLE_WORLD_TEMPLATES]
|
||||||
|
assert menu.selected_index is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_currently_only_one_template_is_available():
|
||||||
|
# campaign/survival/creative (procedurally generated) modes are planned follow-ups, not yet
|
||||||
|
# implemented - this pins down the "just the test map for now" scope explicitly
|
||||||
|
assert [t.id for t in AVAILABLE_WORLD_TEMPLATES] == ["test_map"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_sets_selected_index():
|
||||||
|
menu = WorldCreationMenu()
|
||||||
|
menu.select(0)
|
||||||
|
assert menu.selected_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_out_of_range_is_ignored():
|
||||||
|
menu = WorldCreationMenu()
|
||||||
|
menu.select(99)
|
||||||
|
assert menu.selected_index is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_template_reads_back_the_right_one():
|
||||||
|
menu = WorldCreationMenu()
|
||||||
|
menu.select(0)
|
||||||
|
assert menu.selected_template() is AVAILABLE_WORLD_TEMPLATES[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_template_none_before_any_selection():
|
||||||
|
menu = WorldCreationMenu()
|
||||||
|
assert menu.selected_template() is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_world_from_template ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_world_from_template_loads_the_map_and_its_embeddings(registry, map_loader):
|
||||||
|
template = AVAILABLE_WORLD_TEMPLATES[0]
|
||||||
|
world = build_world_from_template(template, registry, map_loader)
|
||||||
|
assert "harbor_world" in world.maps
|
||||||
|
assert "ship_test" in world.maps # embedded child map also gets added
|
||||||
|
assert world.entities == {} # world creation alone spawns no one
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_world_from_template_raises_for_a_generator_only_template(registry, map_loader):
|
||||||
|
generator_template = WorldTemplate(id="campaign", name="Campaign", map_file=None)
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
build_world_from_template(generator_template, registry, map_loader)
|
||||||
Loading…
Reference in New Issue