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_015mFzQQZYexNaSB69e2oU95
main
Amir Alexander Abdelbaki 2026-07-15 14:10:13 +02:00
parent 67c9435d9e
commit 3bd2c6fa9f
23 changed files with 1166 additions and 48 deletions

View File

@ -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

View File

@ -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),
)

View File

@ -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,

View File

@ -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,

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

70
engine/world_creation.py Normal file
View File

@ -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

View File

@ -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):

View File

@ -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 }
}
}

View File

@ -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",

View File

@ -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,

View File

@ -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)

View File

@ -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 -------------------------------------------------------------

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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":

View File

@ -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

View File

@ -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

View File

@ -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)