440 lines
18 KiB
Python
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
|