diff --git a/engine/character.py b/engine/character.py index 2fd50bb..5a5c8d3 100644 --- a/engine/character.py +++ b/engine/character.py @@ -16,6 +16,20 @@ class Ailment: 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): slot: str removed: bool @@ -124,6 +138,8 @@ class Character(Entity): *args, species_id: str | None = None, gender: str | None = None, + body_type: str | None = None, + hair_color: str | None = None, body_parts: list[BodyPart] | None = None, default_body_parts: list[BodyPart] | None = None, clothing: list[ClothingPiece] | None = None, @@ -141,6 +157,10 @@ class Character(Entity): super().__init__(*args, **kwargs) self.species_id = species_id 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.default_body_parts: list[BodyPart] = default_body_parts or [] self.clothing: list[ClothingPiece] = clothing or [] @@ -436,11 +456,40 @@ class Character(Entity): return 0.0 return lost_weight / total_weight - def total_check_penalty(self, check_id: str, registry) -> float: - """Combined penalty from both damaged body parts and damaged organs for a named check, - applied multiplicatively (as independent sources of lost effectiveness) so it can never - exceed 1.0 lost. Reduces to skill_penalty alone when no organ affects this check or all - relevant organs are healthy - existing skill-only checks are unaffected either way. + def ailment_penalty(self, check_id: str, registry) -> float: + """Fraction (0..1) of `check_id` lost to active ailments (e.g. a combat-inflicted + fracture or brain_trauma - see resources/logic/combat.py) - mirrors organ_penalty, but + weighted by each ailment's own severity (already a 0..1 fraction) instead of an organ's + 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 diff --git a/engine/character_creator.py b/engine/character_creator.py index ba2bc01..91c2c81 100644 --- a/engine/character_creator.py +++ b/engine/character_creator.py @@ -1,52 +1,91 @@ from __future__ import annotations +import random 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.defs import DefRegistry from engine.skills import default_bio DEFAULT_SKILL_POINTS = 10 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 class CharacterCreator: """Pure state/logic for building a new persistent character: pick a species, a gender valid for that species (species define their own valid genders - see SpeciesDef.genders), - a name, and spend a fixed point budget across the classic D&D-style bio skills - (engine/skills.py) - then confirm() builds an actual Character and saves it into the - CharacterLibrary under a given account, ready to appear on the StartScreen. + a cosmetic body type and hair color (also species-defined, see SpeciesDef.body_type_options/ + hair_colors - either can be empty for a species that doesn't offer that choice, e.g. no + 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 - (engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, and skill - +/- steppers is a follow-up UI-layer task. This is the state/logic side: valid choices, - point-budget bookkeeping, and producing the finished Character. + (engine/ui.py, engine/start_screen.py): drawing species swatches, a name field, color + swatches, and skill +/- steppers is a follow-up UI-layer task. This is the state/logic + side: valid choices, point-budget bookkeeping, and producing the finished Character. """ registry: DefRegistry + selectable_species: tuple[str, ...] = PLAYER_SELECTABLE_SPECIES skill_points_budget: int = DEFAULT_SKILL_POINTS max_skill_level: int = DEFAULT_MAX_SKILL_LEVEL name: str = "" species_id: str | None = None gender: str | None = None + 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) + 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: self.name = name.strip() - def set_species(self, species_id: str) -> bool: - """Picks a species, resetting gender to that species' first valid option if the - current gender (from a previous species pick) isn't valid for it. + def available_species(self) -> list[str]: + """Species ids a player can actually pick, for a UI to list - selectable_species + filtered against whatever's actually loaded in the registry. """ - 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 self.species_id = species_id species = self.registry.get_species_def(species_id) if self.gender not in species.genders: self.gender = species.genders[0] if species.genders else None + 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 def set_gender(self, gender: str) -> bool: @@ -58,6 +97,93 @@ class CharacterCreator: self.gender = gender 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: return sum(self.bio.values()) @@ -65,6 +191,9 @@ class CharacterCreator: return self.skill_points_budget - self.spent_skill_points() 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: return False if self.remaining_skill_points() <= 0 or self.bio[skill_id] >= self.max_skill_level: @@ -73,6 +202,9 @@ class CharacterCreator: return True 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: return False self.bio[skill_id] -= 1 @@ -87,7 +219,14 @@ class CharacterCreator: return self.registry.new_default_body_parts(self.species_id) def is_ready(self) -> bool: - return bool(self.name) and self.species_id is not None and self.gender is not None + 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: if not self.is_ready(): @@ -97,7 +236,10 @@ class CharacterCreator: name=self.name, species_id=self.species_id, 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_clothing=[ClothingPiece(slot, item_def_id) for slot, item_def_id in self.starter_clothing.items()], bio=dict(self.bio), ) diff --git a/engine/character_library.py b/engine/character_library.py index e65c8fb..bbe9224 100644 --- a/engine/character_library.py +++ b/engine/character_library.py @@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS library_characters ( name TEXT NOT NULL, species_id TEXT, gender TEXT, + body_type TEXT, + hair_color TEXT, blood_percentage REAL NOT NULL DEFAULT 100.0, strength INTEGER NOT NULL DEFAULT 10, PRIMARY KEY (account_id, character_id) @@ -188,10 +190,10 @@ class CharacterLibrary: cur.execute( "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, - 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: cur = self.conn.cursor() row = cur.execute( - "SELECT name, species_id, gender, blood_percentage, strength FROM library_characters" - " WHERE account_id = ? AND character_id = ?", + "SELECT name, species_id, gender, body_type, hair_color, blood_percentage, strength" + " FROM library_characters WHERE account_id = ? AND character_id = ?", (account_id, character_id), ).fetchone() if row is None: return None - name, species_id, gender, blood_percentage, strength = row + name, species_id, gender, body_type, hair_color, blood_percentage, strength = row body_parts: list[BodyPart] = [] default_body_parts: list[BodyPart] = [] @@ -398,6 +400,8 @@ class CharacterLibrary: position=None, species_id=species_id, gender=gender, + body_type=body_type, + hair_color=hair_color, blood_percentage=blood_percentage, strength=strength, body_parts=body_parts, diff --git a/engine/db.py b/engine/db.py index 030c116..686bb66 100644 --- a/engine/db.py +++ b/engine/db.py @@ -60,6 +60,8 @@ CREATE TABLE IF NOT EXISTS entities ( z INTEGER NOT NULL, species_id TEXT, gender TEXT, + body_type TEXT, + hair_color TEXT, blood_percentage REAL ); @@ -209,8 +211,8 @@ class WorldRepository: assert entity.position is not None is_character = isinstance(entity, Character) cur.execute( - "INSERT INTO entities (entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender, blood_percentage)" - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO entities (entity_id, def_id, kind, name, map_id, x, y, z, species_id, gender," + " body_type, hair_color, blood_percentage) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( entity.entity_id, entity.def_id, @@ -222,6 +224,8 @@ class WorldRepository: entity.position.z, entity.species_id 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, ), ) @@ -320,13 +324,15 @@ class WorldRepository: ) 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() - 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) if kind == "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: entity = Entity(entity_id=entity_id, name=name, def_id=def_id, position=position) @@ -360,7 +366,8 @@ class WorldRepository: return inventory 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: body_parts: list[BodyPart] = [] default_body_parts: list[BodyPart] = [] @@ -418,6 +425,8 @@ class WorldRepository: position=position, species_id=species_id, gender=gender, + body_type=body_type, + hair_color=hair_color, blood_percentage=blood_percentage if blood_percentage is not None else 100.0, body_parts=body_parts, default_body_parts=default_body_parts, diff --git a/engine/defs.py b/engine/defs.py index bddc0d3..c4a80a1 100644 --- a/engine/defs.py +++ b/engine/defs.py @@ -4,7 +4,7 @@ import json from dataclasses import dataclass, field 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.species import AbilityDef, SpeciesDef from engine.tile import TILE_LAYERS, TileLayerDef @@ -27,6 +27,7 @@ class DefRegistry: self.item_defs: dict[str, ItemDef] = {} self.body_part_defs: dict[str, BodyPartDef] = {} self.organ_defs: dict[str, OrganDef] = {} + self.ailment_defs: dict[str, AilmentDef] = {} self.species_defs: dict[str, SpeciesDef] = {} self.entity_defs: dict[str, EntityDef] = {} @@ -37,6 +38,7 @@ class DefRegistry: registry._load_items(defs_dir / "items.json") registry._load_body_parts(defs_dir / "body_parts.json") registry._load_organs(defs_dir / "organs.json") + registry._load_ailments(defs_dir / "ailments.json") registry._load_species(defs_dir / "species.json") registry._load_entities(defs_dir / "entities.json") return registry @@ -126,6 +128,15 @@ class DefRegistry: 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: data = json.loads(path.read_text()) for def_id, fields in data.items(): @@ -148,6 +159,8 @@ class DefRegistry: default_body_parts=default_parts, default_organs=list(fields.get("default_organs", [])), 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, blood_danger_threshold=fields.get("blood_danger_threshold", 50.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: 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: return self.species_defs[def_id] diff --git a/engine/map.py b/engine/map.py index eb9b794..e81027e 100644 --- a/engine/map.py +++ b/engine/map.py @@ -69,11 +69,22 @@ class MapEmbedding: 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.child_map = child_map self.anchor = anchor 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]: return rotated_size(self.child_map.width, self.child_map.height, self.rotation) @@ -114,7 +125,11 @@ class MapEmbedding: self.anchor = anchor 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 @@ -149,8 +164,10 @@ class Map: raise IndexError(f"({x}, {y}, {z}) is out of bounds for map {self.map_id!r}") self._tiles[self._index(fx, fy, fz)] = tile - def embed(self, child: "Map", anchor: tuple[int, int, int], rotation: int = 0) -> MapEmbedding: - embedding = MapEmbedding(self, child, anchor, rotation) + def embed( + 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) child.parent_embedding = embedding return embedding diff --git a/engine/map_loader.py b/engine/map_loader.py index 29022e9..0da40fe 100644 --- a/engine/map_loader.py +++ b/engine/map_loader.py @@ -42,6 +42,11 @@ class MapLoader: for embedding in data.get("embeddings", []): 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 diff --git a/engine/species.py b/engine/species.py index 29d258e..e57d1f8 100644 --- a/engine/species.py +++ b/engine/species.py @@ -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 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 + 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) blood_danger_threshold: float = 50.0 # % blood remaining blood_critical_threshold: float = 25.0 diff --git a/engine/world_creation.py b/engine/world_creation.py new file mode 100644 index 0000000..d82937b --- /dev/null +++ b/engine/world_creation.py @@ -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 diff --git a/main.py b/main.py index 4561ae8..9c1be91 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,7 @@ from engine.world import World from resources.logic.actions import activate_hand from resources.logic.health_ticks import apply_bleeding_tick from resources.logic.organs import apply_organ_interactions_tick -from resources.logic.steering import at_helm, try_steer_ship +from resources.logic.steering import advance_all_ship_rotations, at_helm, try_steer_ship ROOT_DIR = Path(__file__).resolve().parent DEFS_DIR = ROOT_DIR / "resources" / "defs" @@ -143,6 +143,7 @@ def main() -> None: ticks = clock.advance(dt) world.expire_time_warp_zones(clock.total_time) + advance_all_ship_rotations(world, clock.total_time) if ticks: for entity in world.entities.values(): if isinstance(entity, Character): diff --git a/resources/defs/ailments.json b/resources/defs/ailments.json new file mode 100644 index 0000000..11597dd --- /dev/null +++ b/resources/defs/ailments.json @@ -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 } + } +} diff --git a/resources/defs/species.json b/resources/defs/species.json index c9f97b7..06ccf81 100644 --- a/resources/defs/species.json +++ b/resources/defs/species.json @@ -12,6 +12,8 @@ ], "default_organs": ["human_heart", "human_lungs", "human_liver", "human_kidneys", "human_brain"], "body_type": "biped", + "body_type_options": ["average", "thin", "fat", "hulk"], + "hair_colors": ["black", "brown", "blonde", "red", "gray", "white"], "abilities": [ { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, @@ -43,6 +45,8 @@ "reptilian_brain" ], "body_type": "biped", + "body_type_options": ["average", "stocky", "slender"], + "hair_colors": [], "abilities": [ { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, @@ -71,6 +75,8 @@ "zenari_cortex" ], "body_type": "biped", + "body_type_options": ["average", "slender", "willowy"], + "hair_colors": [], "abilities": [ { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, { "id": "melee_attack", "name": "Melee Attack", "required_slots": ["right_arm"] }, @@ -100,6 +106,8 @@ "bot_cooling_fan" ], "body_type": "biped", + "body_type_options": ["compact", "standard", "heavy"], + "hair_colors": [], "abilities": [ { "id": "leap", "name": "Leap", "required_slots": ["left_leg", "right_leg"] }, { "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"], "body_type": "quadruped", + "body_type_options": ["small", "medium", "large"], + "hair_colors": ["black", "brown", "golden", "white", "gray"], "abilities": [ { "id": "leap", diff --git a/resources/logic/combat.py b/resources/logic/combat.py index 0f76ccf..9ff45b8 100644 --- a/resources/logic/combat.py +++ b/resources/logic/combat.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Protocol, Sequence, TypeVar from engine.aim import apply_cone_deviation -from engine.character import BodyPart, Character +from engine.character import Ailment, BodyPart, Character from engine.defs import DefRegistry from engine.item import ItemDef 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") 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") @@ -72,6 +91,36 @@ def apply_body_part_damage(character: Character, slot: str, amount: float) -> Bo 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( character: Character, skill_id: str, registry: DefRegistry, rng: DiceRoller ) -> tuple[int, float]: @@ -137,7 +186,10 @@ def resolve_hit( if hit: hit_slot = target_slot if target_slot is not None else choose_hit_slot(defender, attacker_check) if hit_slot is not None: - apply_body_part_damage(defender, hit_slot, damage) + 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( hit=hit, diff --git a/resources/logic/steering.py b/resources/logic/steering.py index 67c65cd..65f6f5d 100644 --- a/resources/logic/steering.py +++ b/resources/logic/steering.py @@ -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: - """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) if embedding is None: return False - new_rotation = (embedding.rotation + quarter_turns) % 4 - if not _footprint_is_clear(world, embedding, embedding.anchor, new_rotation): + new_desired = (embedding.desired_rotation + quarter_turns) % 4 + if not _footprint_is_clear(world, embedding, embedding.anchor, new_desired): return False - embedding.rotate_to(new_rotation) + embedding.desired_rotation = new_desired 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) diff --git a/tests/test_character_creator.py b/tests/test_character_creator.py index 8abd421..533b3e5 100644 --- a/tests/test_character_creator.py +++ b/tests/test_character_creator.py @@ -1,9 +1,10 @@ +import random import sqlite3 from pathlib import Path 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.defs import DefRegistry @@ -36,6 +37,24 @@ def test_set_species_rejects_unknown_species(registry): 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): creator = CharacterCreator(registry) 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): - creator = CharacterCreator(registry) + creator = CharacterCreator(registry, selectable_species=PLAYER_SELECTABLE_SPECIES + ("dog",)) creator.set_species("human") creator.set_gender("female") creator.set_species("dog") # dog also has "male"/"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 --------------------------------------------------------------------- @@ -122,6 +252,132 @@ def test_decrease_skill_below_zero_is_a_no_op(registry): 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 ------------------------------------------------------------------------ @@ -131,7 +387,7 @@ def test_preview_body_parts_empty_before_species_chosen(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") 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"} @@ -166,12 +422,26 @@ def test_build_character_produces_a_character_with_chosen_traits(registry): assert character.name == "Vex" # whitespace trimmed assert character.species_id == "reptilian_quad" 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 {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", } +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 ------------------------------------------------------------- diff --git a/tests/test_character_library.py b/tests/test_character_library.py index 72578e4..5cdbef5 100644 --- a/tests/test_character_library.py +++ b/tests/test_character_library.py @@ -28,6 +28,7 @@ def build_character(registry) -> Character: name="Vex", species_id="reptilian_quad", gender="none", + body_type="stocky", blood_percentage=88.0, strength=14, body_parts=[ @@ -63,6 +64,8 @@ def test_save_and_load_roundtrip_preserves_identity_and_species(registry, librar assert loaded.name == "Vex" assert loaded.species_id == "reptilian_quad" 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.strength == 14 assert loaded.position is None # portable template, not tied to any world diff --git a/tests/test_combat_ailments.py b/tests/test_combat_ailments.py new file mode 100644 index 0000000..bbd4ecc --- /dev/null +++ b/tests/test_combat_ailments.py @@ -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 diff --git a/tests/test_db_roundtrip.py b/tests/test_db_roundtrip.py index 8c5056d..2109cb3 100644 --- a/tests/test_db_roundtrip.py +++ b/tests/test_db_roundtrip.py @@ -36,6 +36,8 @@ def build_world(registry) -> World: position=EntityPosition("demo_world", 7, 5, 0), species_id="human", gender="nonbinary", + body_type="thin", + hair_color="red", blood_percentage=82.5, body_parts=[ 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.gender == "nonbinary" + assert player.body_type == "thin" + assert player.hair_color == "red" assert player.blood_percentage == pytest.approx(82.5) assert player.mental_stats == {"insanity": 12.5} assert player.bio["stealth"] == 3 diff --git a/tests/test_defs_loading.py b/tests/test_defs_loading.py index cac97ab..30bc22a 100644 --- a/tests/test_defs_loading.py +++ b/tests/test_defs_loading.py @@ -113,3 +113,29 @@ def test_demo_world_tiles_resolve_against_tile_defs(registry): door_tile = ship.get_tile(2, 0, 0) assert door_tile.room is None 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 diff --git a/tests/test_map_embedding.py b/tests/test_map_embedding.py index a0dde55..aedc583 100644 --- a/tests/test_map_embedding.py +++ b/tests/test_map_embedding.py @@ -38,6 +38,22 @@ def test_embedding_at_detects_child_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: def get_tile_layer_def(self, layer, def_id): if layer == "room" and def_id == "wood_wall": diff --git a/tests/test_ship_test_map.py b/tests/test_ship_test_map.py index e706930..1e39f3a 100644 --- a/tests/test_ship_test_map.py +++ b/tests/test_ship_test_map.py @@ -10,7 +10,7 @@ from engine.item import ItemInstance from engine.map_loader import MapLoader from engine.world import World 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 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)) world.add_entity(helmsman) 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 diff --git a/tests/test_steering.py b/tests/test_steering.py index 2d78402..3d05190 100644 --- a/tests/test_steering.py +++ b/tests/test_steering.py @@ -8,7 +8,7 @@ from engine.entity import EntityPosition from engine.map_loader import MapLoader from engine.tile import TileLayerInstance 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" @@ -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 -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) embedding = world.maps["ship_small"].parent_embedding helmsman = make_human(registry, position=EntityPosition("ship_small", 2, 1, 0)) 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 diff --git a/tests/test_world_creation.py b/tests/test_world_creation.py new file mode 100644 index 0000000..f420e2d --- /dev/null +++ b/tests/test_world_creation.py @@ -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)