429 lines
17 KiB
Python
429 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,
|
|
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, 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.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, 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:
|
|
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
|
|
)
|
|
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, 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,
|
|
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,
|
|
)
|