Mi2dRPGamEng/engine/character_library.py

440 lines
18 KiB
Python

from __future__ import annotations
import sqlite3
from pathlib import Path
from engine.character import Ailment, BodyPart, Character, ClothingPiece, HeldItem, Organ
from engine.defs import DefRegistry
from engine.inventory import Inventory
from engine.item import ItemInstance
SCHEMA = """
CREATE TABLE IF NOT EXISTS library_characters (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
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)
);
CREATE TABLE IF NOT EXISTS library_body_parts (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
is_default INTEGER NOT NULL,
slot TEXT NOT NULL,
part_def_id TEXT NOT NULL,
integrity REAL NOT NULL DEFAULT 100.0,
removed INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, character_id, is_default, slot)
);
CREATE TABLE IF NOT EXISTS library_body_part_ailments (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
is_default INTEGER NOT NULL,
slot TEXT NOT NULL,
ailment_id TEXT NOT NULL,
name TEXT NOT NULL,
severity REAL NOT NULL DEFAULT 1.0,
progress REAL NOT NULL DEFAULT 0.0
);
CREATE TABLE IF NOT EXISTS library_organs (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
is_default INTEGER NOT NULL,
slot TEXT NOT NULL,
organ_def_id TEXT NOT NULL,
integrity REAL NOT NULL DEFAULT 100.0,
removed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS library_clothing (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
is_default INTEGER NOT NULL,
slot TEXT NOT NULL,
item_def_id TEXT NOT NULL,
removed INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, character_id, is_default, slot)
);
CREATE TABLE IF NOT EXISTS library_held_items (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
hand_slot TEXT NOT NULL,
item_def_id TEXT NOT NULL,
loaded_ammo_type TEXT,
loaded_ammo_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, character_id, hand_slot)
);
CREATE TABLE IF NOT EXISTS library_held_item_attachments (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
hand_slot TEXT NOT NULL,
attachment_item_def_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS library_implants (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
item_def_id TEXT NOT NULL,
PRIMARY KEY (account_id, character_id, item_def_id)
);
CREATE TABLE IF NOT EXISTS library_mental_stats (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
stat TEXT NOT NULL,
value REAL NOT NULL,
PRIMARY KEY (account_id, character_id, stat)
);
CREATE TABLE IF NOT EXISTS library_bio (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
skill TEXT NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (account_id, character_id, skill)
);
CREATE TABLE IF NOT EXISTS library_inventories (
account_id TEXT NOT NULL,
character_id TEXT NOT NULL,
inventory_id TEXT PRIMARY KEY,
owner_clothing_is_default INTEGER,
owner_clothing_slot TEXT,
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS library_inventory_items (
inventory_id TEXT NOT NULL REFERENCES library_inventories(inventory_id),
item_id TEXT NOT NULL,
def_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
PRIMARY KEY (inventory_id, item_id)
);
"""
_CLEAR_TABLES = (
# library_inventory_items has no account_id/character_id of its own (see _clear_existing) - it's
# keyed only by inventory_id, so it's cleared via a join through library_inventories instead.
"library_inventories",
"library_bio",
"library_mental_stats",
"library_implants",
"library_held_item_attachments",
"library_held_items",
"library_clothing",
"library_organs",
"library_body_part_ailments",
"library_body_parts",
"library_characters",
)
class CharacterLibrary:
"""Cross-session character persistence, keyed by (account_id, character_id) - separate
from WorldRepository's per-world save file. A character saved here has no position or
map; it's a portable template a user can spawn into any world (single-player continuation
or joining someone else's multiplayer session with a character built up over time).
"""
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
self.conn.executescript(SCHEMA)
self.conn.commit()
@classmethod
def open(cls, path: str | Path) -> "CharacterLibrary":
conn = sqlite3.connect(str(path))
return cls(conn)
def close(self) -> None:
self.conn.close()
def list_characters(self, account_id: str) -> list[tuple[str, str, str | None]]:
"""(character_id, name, species_id) for every character saved under this account."""
cur = self.conn.cursor()
return cur.execute(
"SELECT character_id, name, species_id FROM library_characters WHERE account_id = ? ORDER BY name",
(account_id,),
).fetchall()
def _clear_existing(self, cur: sqlite3.Cursor, account_id: str, character_id: str) -> None:
cur.execute(
"DELETE FROM library_inventory_items WHERE inventory_id IN"
" (SELECT inventory_id FROM library_inventories WHERE account_id = ? AND character_id = ?)",
(account_id, character_id),
)
for table in _CLEAR_TABLES:
cur.execute(f"DELETE FROM {table} WHERE account_id = ? AND character_id = ?", (account_id, character_id))
def delete_character(self, account_id: str, character_id: str) -> None:
cur = self.conn.cursor()
self._clear_existing(cur, account_id, character_id)
self.conn.commit()
def save_character(self, account_id: str, character_id: str, character: Character) -> None:
cur = self.conn.cursor()
self._clear_existing(cur, account_id, character_id)
cur.execute(
"INSERT INTO library_characters (account_id, character_id, name, species_id, gender,"
" body_type, hair_color, blood_percentage, strength) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
account_id, character_id, character.name, character.species_id, character.gender,
character.body_type, character.hair_color, character.blood_percentage, character.strength,
),
)
for is_default, parts in ((0, character.body_parts), (1, character.default_body_parts)):
for part in parts:
cur.execute(
"INSERT INTO library_body_parts (account_id, character_id, is_default, slot, part_def_id,"
" integrity, removed) VALUES (?, ?, ?, ?, ?, ?, ?)",
(account_id, character_id, is_default, part.slot, part.part_def_id, part.integrity, int(part.removed)),
)
for ailment in part.ailments:
cur.execute(
"INSERT INTO library_body_part_ailments (account_id, character_id, is_default, slot,"
" ailment_id, name, severity, progress) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
account_id, character_id, is_default, part.slot, ailment.id, ailment.name,
ailment.severity, ailment.progress,
),
)
for organ in part.organs:
cur.execute(
"INSERT INTO library_organs (account_id, character_id, is_default, slot, organ_def_id,"
" integrity, removed) VALUES (?, ?, ?, ?, ?, ?, ?)",
(account_id, character_id, is_default, part.slot, organ.organ_def_id, organ.integrity, int(organ.removed)),
)
for is_default, pieces in ((0, character.clothing), (1, character.default_clothing)):
for piece in pieces:
cur.execute(
"INSERT INTO library_clothing (account_id, character_id, is_default, slot, item_def_id, removed)"
" VALUES (?, ?, ?, ?, ?, ?)",
(account_id, character_id, is_default, piece.slot, piece.item_def_id, int(piece.removed)),
)
if piece.inventory is not None:
self._save_inventory(
cur, f"lib-{account_id}-{character_id}-clothing-{is_default}-{piece.slot}",
account_id, character_id, piece.inventory, owner_clothing=(is_default, piece.slot),
)
for held in character.held_items:
cur.execute(
"INSERT INTO library_held_items (account_id, character_id, hand_slot, item_def_id,"
" loaded_ammo_type, loaded_ammo_count) VALUES (?, ?, ?, ?, ?, ?)",
(account_id, character_id, held.hand_slot, held.item_def_id, held.loaded_ammo_type, held.loaded_ammo_count),
)
for attachment_id in held.attachments:
cur.execute(
"INSERT INTO library_held_item_attachments (account_id, character_id, hand_slot,"
" attachment_item_def_id) VALUES (?, ?, ?, ?)",
(account_id, character_id, held.hand_slot, attachment_id),
)
for implant_item_def_id in character.implants:
cur.execute(
"INSERT INTO library_implants (account_id, character_id, item_def_id) VALUES (?, ?, ?)",
(account_id, character_id, implant_item_def_id),
)
if character.mental_stats is not None:
for stat, value in character.mental_stats.items():
cur.execute(
"INSERT INTO library_mental_stats (account_id, character_id, stat, value) VALUES (?, ?, ?, ?)",
(account_id, character_id, stat, value),
)
for skill, level in character.bio.items():
cur.execute(
"INSERT INTO library_bio (account_id, character_id, skill, level) VALUES (?, ?, ?, ?)",
(account_id, character_id, skill, level),
)
if character.inventory is not None:
self._save_inventory(cur, f"lib-{account_id}-{character_id}", account_id, character_id, character.inventory)
self.conn.commit()
def _save_inventory(
self,
cur: sqlite3.Cursor,
inventory_id: str,
account_id: str,
character_id: str,
inventory: Inventory,
owner_clothing: tuple[int, str] | None = None,
) -> None:
owner_clothing_is_default, owner_clothing_slot = owner_clothing if owner_clothing else (None, None)
cur.execute(
"INSERT INTO library_inventories (account_id, character_id, inventory_id, owner_clothing_is_default,"
" owner_clothing_slot, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)",
(account_id, character_id, inventory_id, owner_clothing_is_default, owner_clothing_slot, inventory.width, inventory.height),
)
for placed in inventory.items():
cur.execute(
"INSERT INTO library_inventory_items (inventory_id, item_id, def_id, quantity, x, y)"
" VALUES (?, ?, ?, ?, ?, ?)",
(inventory_id, placed.item.item_id, placed.item.def_id, placed.item.quantity, placed.x, placed.y),
)
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, 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, body_type, hair_color, blood_percentage, strength = row
body_parts: list[BodyPart] = []
default_body_parts: list[BodyPart] = []
part_rows = cur.execute(
"SELECT is_default, slot, part_def_id, integrity, removed FROM library_body_parts"
" WHERE account_id = ? AND character_id = ?",
(account_id, character_id),
).fetchall()
for is_default, slot, part_def_id, integrity, removed in part_rows:
part = BodyPart(slot=slot, part_def_id=part_def_id, integrity=integrity, removed=bool(removed))
ailment_rows = cur.execute(
"SELECT ailment_id, name, severity, progress FROM library_body_part_ailments"
" WHERE account_id = ? AND character_id = ? AND is_default = ? AND slot = ?",
(account_id, character_id, is_default, slot),
).fetchall()
for ailment_id, ailment_name, severity, progress in ailment_rows:
part.ailments.append(Ailment(id=ailment_id, name=ailment_name, severity=severity, progress=progress))
organ_rows = cur.execute(
"SELECT organ_def_id, integrity, removed FROM library_organs"
" WHERE account_id = ? AND character_id = ? AND is_default = ? AND slot = ?",
(account_id, character_id, is_default, slot),
).fetchall()
for organ_def_id, organ_integrity, organ_removed in organ_rows:
part.organs.append(Organ(organ_def_id=organ_def_id, integrity=organ_integrity, removed=bool(organ_removed)))
(default_body_parts if is_default else body_parts).append(part)
clothing: list[ClothingPiece] = []
default_clothing: list[ClothingPiece] = []
clothing_rows = cur.execute(
"SELECT is_default, slot, item_def_id, removed FROM library_clothing"
" WHERE account_id = ? AND character_id = ?",
(account_id, character_id),
).fetchall()
for is_default, slot, item_def_id, removed in clothing_rows:
piece = ClothingPiece(slot=slot, item_def_id=item_def_id, removed=bool(removed))
inv_row = cur.execute(
"SELECT inventory_id, width, height FROM library_inventories"
" WHERE account_id = ? AND character_id = ? AND owner_clothing_is_default = ? AND owner_clothing_slot = ?",
(account_id, character_id, is_default, slot),
).fetchone()
if inv_row is not None:
inventory_id, width, height = inv_row
piece.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
(default_clothing if is_default else clothing).append(piece)
held_items: list[HeldItem] = []
held_rows = cur.execute(
"SELECT hand_slot, item_def_id, loaded_ammo_type, loaded_ammo_count FROM library_held_items"
" WHERE account_id = ? AND character_id = ?",
(account_id, character_id),
).fetchall()
for hand_slot, item_def_id, loaded_ammo_type, loaded_ammo_count in held_rows:
attachment_rows = cur.execute(
"SELECT attachment_item_def_id FROM library_held_item_attachments"
" WHERE account_id = ? AND character_id = ? AND hand_slot = ?",
(account_id, character_id, hand_slot),
).fetchall()
held_items.append(
HeldItem(
hand_slot=hand_slot,
item_def_id=item_def_id,
attachments=[a for (a,) in attachment_rows],
loaded_ammo_type=loaded_ammo_type,
loaded_ammo_count=loaded_ammo_count,
)
)
implants = [
item_def_id
for (item_def_id,) in cur.execute(
"SELECT item_def_id FROM library_implants WHERE account_id = ? AND character_id = ?",
(account_id, character_id),
)
]
mental_stats = {
stat: value
for stat, value in cur.execute(
"SELECT stat, value FROM library_mental_stats WHERE account_id = ? AND character_id = ?",
(account_id, character_id),
)
} or None
bio = {
skill: level
for skill, level in cur.execute(
"SELECT skill, level FROM library_bio WHERE account_id = ? AND character_id = ?",
(account_id, character_id),
)
}
character = Character(
entity_id=character_id,
name=name,
def_id=None,
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,
default_body_parts=default_body_parts,
clothing=clothing,
default_clothing=default_clothing,
held_items=held_items,
implants=implants,
mental_stats=mental_stats,
bio=bio,
)
inv_row = cur.execute(
"SELECT inventory_id, width, height FROM library_inventories"
" WHERE account_id = ? AND character_id = ? AND owner_clothing_slot IS NULL",
(account_id, character_id),
).fetchone()
if inv_row is not None:
inventory_id, width, height = inv_row
character.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
return character
def _load_inventory(
self, cur: sqlite3.Cursor, inventory_id: str, width: int, height: int, registry: DefRegistry
) -> Inventory:
inventory = Inventory(width, height)
rows = cur.execute(
"SELECT item_id, def_id, quantity, x, y FROM library_inventory_items WHERE inventory_id = ?",
(inventory_id,),
).fetchall()
for item_id, def_id, quantity, ix, iy in rows:
item_def = registry.get_item_def(def_id)
inventory.place(ItemInstance(item_id, def_id, quantity), item_def, ix, iy)
return inventory