Mi2dRPGamEng/engine/character_creator.py

275 lines
13 KiB
Python

from __future__ import annotations
import random
from dataclasses import dataclass, field
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", "hydraulibot", "motorbot")
# Every species is fair game for an NPC, including "dog" - the one species intentionally left
# out of PLAYER_SELECTABLE_SPECIES since a player can't create a character as one (see above).
NPC_SELECTABLE_SPECIES: tuple[str, ...] = PLAYER_SELECTABLE_SPECIES + ("dog",)
# The "secret items password" field on the final creation step (see CharacterCreationFlow) -
# entering one of these exact strings pre-installs the mapped implant on the finished character,
# no other way to get it. Matched case-sensitively, deliberately not documented anywhere in-game.
SECRET_ITEM_PASSWORD_GRANTS: dict[str, str] = {
"The_miro": "cyberdeck_the_miro_model_3a",
}
@dataclass
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 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, 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 = ""
secret_password: str = "" # optional; see SECRET_ITEM_PASSWORD_GRANTS
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_secret_password(self, password: str) -> None:
self.secret_password = password
def available_species(self) -> list[str]:
"""Species ids a player can actually pick, for a UI to list - selectable_species
filtered against whatever's actually loaded in the registry.
"""
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:
if self.species_id is None:
return False
species = self.registry.get_species_def(self.species_id)
if gender not in species.genders:
return False
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())
def remaining_skill_points(self) -> int:
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:
return False
self.bio[skill_id] += 1
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
return True
def preview_body_parts(self) -> list[BodyPart]:
"""Read-only preview of the anatomy this character will start with, for a UI to render
without needing to build a whole Character first.
"""
if self.species_id is None:
return []
return self.registry.new_default_body_parts(self.species_id)
def is_ready(self) -> bool:
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():
return None
assert self.species_id is not None
character = Character(
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),
)
granted_implant_id = SECRET_ITEM_PASSWORD_GRANTS.get(self.secret_password)
if granted_implant_id is not None:
character.install_implant(granted_implant_id, self.registry)
return character
def confirm(self, library: CharacterLibrary, account_id: str, character_id: str) -> Character | None:
"""Builds the character and saves it into the library under (account_id, character_id) -
the "create" action once name/species/gender/skills are all set. No-op (returns None,
nothing saved) if the creator isn't ready yet.
"""
character = self.build_character()
if character is None:
return None
library.save_character(account_id, character_id, character)
return character