Mi2dRPGamEng/engine/db.py

438 lines
17 KiB
Python

from __future__ import annotations
import sqlite3
from pathlib import Path
from engine.character import Ailment, BodyPart, Character, ClothingPiece
from engine.defs import DefRegistry
from engine.entity import Entity, EntityPosition
from engine.inventory import Inventory
from engine.item import ItemInstance
from engine.map import Map
from engine.map_loader import MapLoader
from engine.tile import TILE_LAYERS, Tile, TileLayerInstance
from engine.world import World
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS maps (
map_id TEXT PRIMARY KEY,
source_def TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS map_embeddings (
parent_map_id TEXT NOT NULL REFERENCES maps(map_id),
child_map_id TEXT NOT NULL REFERENCES maps(map_id),
anchor_x INTEGER NOT NULL,
anchor_y INTEGER NOT NULL,
anchor_z INTEGER NOT NULL,
PRIMARY KEY (parent_map_id, child_map_id)
);
CREATE TABLE IF NOT EXISTS tile_overrides (
map_id TEXT NOT NULL REFERENCES maps(map_id),
x INTEGER NOT NULL,
y INTEGER NOT NULL,
z INTEGER NOT NULL,
subfloor TEXT,
subfloor_integrity REAL,
flooring TEXT,
flooring_integrity REAL,
room TEXT,
room_integrity REAL,
roof TEXT,
roof_integrity REAL,
PRIMARY KEY (map_id, x, y, z)
);
CREATE TABLE IF NOT EXISTS entities (
entity_id TEXT PRIMARY KEY,
def_id TEXT,
kind TEXT NOT NULL,
name TEXT,
map_id TEXT NOT NULL REFERENCES maps(map_id),
x INTEGER NOT NULL,
y INTEGER NOT NULL,
z INTEGER NOT NULL,
species_id TEXT,
gender TEXT,
body_type TEXT,
hair_color TEXT,
blood_percentage REAL
);
CREATE TABLE IF NOT EXISTS character_body_parts (
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
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 (entity_id, is_default, slot)
);
CREATE TABLE IF NOT EXISTS character_body_part_ailments (
entity_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,
FOREIGN KEY (entity_id, is_default, slot) REFERENCES character_body_parts(entity_id, is_default, slot)
);
CREATE TABLE IF NOT EXISTS character_clothing (
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
is_default INTEGER NOT NULL,
slot TEXT NOT NULL,
item_def_id TEXT NOT NULL,
removed INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (entity_id, is_default, slot)
);
CREATE TABLE IF NOT EXISTS character_mental_stats (
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
stat TEXT NOT NULL,
value REAL NOT NULL,
PRIMARY KEY (entity_id, stat)
);
CREATE TABLE IF NOT EXISTS character_bio (
entity_id TEXT NOT NULL REFERENCES entities(entity_id),
skill TEXT NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY (entity_id, skill)
);
CREATE TABLE IF NOT EXISTS inventories (
inventory_id TEXT PRIMARY KEY,
owner_entity_id TEXT NOT NULL REFERENCES entities(entity_id),
owner_clothing_is_default INTEGER,
owner_clothing_slot TEXT,
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS inventory_items (
item_id TEXT PRIMARY KEY,
inventory_id TEXT NOT NULL REFERENCES inventories(inventory_id),
def_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
x INTEGER NOT NULL,
y INTEGER NOT NULL
);
"""
_SAVE_CLEAR_TABLES = (
"inventory_items",
"inventories",
"character_bio",
"character_mental_stats",
"character_body_part_ailments",
"character_body_parts",
"character_clothing",
"entities",
"tile_overrides",
"map_embeddings",
"maps",
"meta",
)
class WorldRepository:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
self.conn.executescript(SCHEMA)
self.conn.commit()
@classmethod
def open(cls, path: str | Path) -> "WorldRepository":
conn = sqlite3.connect(str(path))
return cls(conn)
def close(self) -> None:
self.conn.close()
def save_world(self, world: World) -> None:
cur = self.conn.cursor()
for table in _SAVE_CLEAR_TABLES:
cur.execute(f"DELETE FROM {table}")
for map_ in world.maps.values():
cur.execute(
"INSERT INTO maps (map_id, source_def) VALUES (?, ?)",
(map_.map_id, map_.source_def),
)
for map_ in world.maps.values():
for embedding in map_.embeddings:
cur.execute(
"INSERT INTO map_embeddings (parent_map_id, child_map_id, anchor_x, anchor_y, anchor_z)"
" VALUES (?, ?, ?, ?, ?)",
(map_.map_id, embedding.child_map.map_id, *embedding.anchor),
)
for map_ in world.maps.values():
self._save_tiles(cur, map_)
for entity in world.entities.values():
self._save_entity(cur, entity)
if world.player is not None:
cur.execute("INSERT INTO meta (key, value) VALUES ('player_entity_id', ?)", (world.player.entity_id,))
self.conn.commit()
def _save_tiles(self, cur: sqlite3.Cursor, map_: Map) -> None:
"""Full snapshot of every tile (not a sparse diff) - simple and correct for base-building
style runtime edits: any change to any layer of any tile survives a save/reload.
"""
for z in range(map_.depth):
for y in range(map_.height):
for x in range(map_.width):
tile = map_.get_tile(x, y, z)
values: list = [map_.map_id, x, y, z]
for layer in TILE_LAYERS:
instance = tile.get_layer(layer)
values.append(instance.def_id if instance else None)
values.append(instance.integrity if instance else None)
cur.execute(
"INSERT INTO tile_overrides"
" (map_id, x, y, z, subfloor, subfloor_integrity, flooring, flooring_integrity,"
" room, room_integrity, roof, roof_integrity)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
values,
)
def _save_entity(self, cur: sqlite3.Cursor, entity: Entity) -> None:
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,"
" body_type, hair_color, blood_percentage) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
entity.entity_id,
entity.def_id,
"character" if is_character else "entity",
entity.name,
entity.position.map_id,
entity.position.x,
entity.position.y,
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,
),
)
if isinstance(entity, Character):
for is_default, parts in ((0, entity.body_parts), (1, entity.default_body_parts)):
for part in parts:
cur.execute(
"INSERT INTO character_body_parts (entity_id, is_default, slot, part_def_id, integrity, removed)"
" VALUES (?, ?, ?, ?, ?, ?)",
(entity.entity_id, is_default, part.slot, part.part_def_id, part.integrity, int(part.removed)),
)
for ailment in part.ailments:
cur.execute(
"INSERT INTO character_body_part_ailments"
" (entity_id, is_default, slot, ailment_id, name, severity) VALUES (?, ?, ?, ?, ?, ?)",
(entity.entity_id, is_default, part.slot, ailment.id, ailment.name, ailment.severity),
)
for is_default, pieces in ((0, entity.clothing), (1, entity.default_clothing)):
for piece in pieces:
cur.execute(
"INSERT INTO character_clothing (entity_id, is_default, slot, item_def_id, removed)"
" VALUES (?, ?, ?, ?, ?)",
(entity.entity_id, is_default, piece.slot, piece.item_def_id, int(piece.removed)),
)
if piece.inventory is not None:
self._save_inventory(
cur,
f"inv-{entity.entity_id}-clothing-{is_default}-{piece.slot}",
entity.entity_id,
piece.inventory,
owner_clothing=(is_default, piece.slot),
)
if entity.mental_stats is not None:
for stat, value in entity.mental_stats.items():
cur.execute(
"INSERT INTO character_mental_stats (entity_id, stat, value) VALUES (?, ?, ?)",
(entity.entity_id, stat, value),
)
for skill, level in entity.bio.items():
cur.execute(
"INSERT INTO character_bio (entity_id, skill, level) VALUES (?, ?, ?)",
(entity.entity_id, skill, level),
)
if entity.inventory is not None:
self._save_inventory(cur, f"inv-{entity.entity_id}", entity.entity_id, entity.inventory)
def _save_inventory(
self,
cur: sqlite3.Cursor,
inventory_id: str,
owner_entity_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 inventories (inventory_id, owner_entity_id, owner_clothing_is_default, owner_clothing_slot, width, height)"
" VALUES (?, ?, ?, ?, ?, ?)",
(inventory_id, owner_entity_id, owner_clothing_is_default, owner_clothing_slot, inventory.width, inventory.height),
)
for placed in inventory.items():
cur.execute(
"INSERT INTO inventory_items (item_id, inventory_id, def_id, quantity, x, y)"
" VALUES (?, ?, ?, ?, ?, ?)",
(placed.item.item_id, inventory_id, placed.item.def_id, placed.item.quantity, placed.x, placed.y),
)
def load_world(self, registry: DefRegistry, map_loader: MapLoader) -> World | None:
cur = self.conn.cursor()
map_rows = cur.execute("SELECT map_id, source_def FROM maps").fetchall()
if not map_rows:
return None
world = World(registry=registry)
for map_id, source_def in map_rows:
m = map_loader.load(source_def)
assert m.map_id == map_id
world.add_map(m)
for map_id, x, y, z, subfloor, subfloor_i, flooring, flooring_i, room, room_i, roof, roof_i in cur.execute(
"SELECT map_id, x, y, z, subfloor, subfloor_integrity, flooring, flooring_integrity,"
" room, room_integrity, roof, roof_integrity FROM tile_overrides"
):
m = world.maps[map_id]
m.set_tile(
x,
y,
z,
Tile(
subfloor=TileLayerInstance(subfloor, subfloor_i) if subfloor else None,
flooring=TileLayerInstance(flooring, flooring_i) if flooring else None,
room=TileLayerInstance(room, room_i) if room else None,
roof=TileLayerInstance(roof, roof_i) if roof else None,
),
)
entity_rows = cur.execute(
"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, 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, body_type,
hair_color, blood_percentage,
)
else:
entity = Entity(entity_id=entity_id, name=name, def_id=def_id, position=position)
inv_row = cur.execute(
"SELECT inventory_id, width, height FROM inventories"
" WHERE owner_entity_id = ? AND owner_clothing_slot IS NULL",
(entity_id,),
).fetchone()
if inv_row is not None:
inventory_id, width, height = inv_row
entity.inventory = self._load_inventory(cur, inventory_id, width, height, registry)
world.add_entity(entity)
player_row = cur.execute("SELECT value FROM meta WHERE key = 'player_entity_id'").fetchone()
if player_row is not None:
world.player = world.entities.get(player_row[0])
return world
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 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
def _load_character(
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] = []
part_rows = cur.execute(
"SELECT is_default, slot, part_def_id, integrity, removed FROM character_body_parts WHERE entity_id = ?",
(entity_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 FROM character_body_part_ailments"
" WHERE entity_id = ? AND is_default = ? AND slot = ?",
(entity_id, is_default, slot),
).fetchall()
for ailment_id, ailment_name, severity in ailment_rows:
part.ailments.append(Ailment(id=ailment_id, name=ailment_name, severity=severity))
(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 character_clothing WHERE entity_id = ?",
(entity_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 inventories"
" WHERE owner_entity_id = ? AND owner_clothing_is_default = ? AND owner_clothing_slot = ?",
(entity_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)
mental_stats = {
stat: value
for stat, value in cur.execute(
"SELECT stat, value FROM character_mental_stats WHERE entity_id = ?", (entity_id,)
)
} or None
bio = {
skill: level
for skill, level in cur.execute(
"SELECT skill, level FROM character_bio WHERE entity_id = ?", (entity_id,)
)
}
return Character(
entity_id=entity_id,
name=name,
def_id=def_id,
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,
clothing=clothing,
default_clothing=default_clothing,
mental_stats=mental_stats,
bio=bio,
)