Spaces:
Sleeping
Sleeping
# app.py | |
import streamlit as st | |
import streamlit.components.v1 as components | |
import os | |
import json | |
import sqlite3 # Use SQLite for robust state management | |
import uuid | |
import math | |
import time # For adding slight delays if needed for debugging FS issues | |
from streamlit_js_eval import streamlit_js_eval # For JS communication | |
# --- Constants --- | |
DB_FILE = "world_state.db" # SQLite database file | |
PLOT_WIDTH = 50.0 | |
PLOT_DEPTH = 50.0 | |
# --- Database Setup --- | |
def init_db(): | |
"""Initializes the SQLite database and tables.""" | |
try: | |
# Use WAL mode for potentially better concurrency, though might have visibility delays on some systems | |
# conn = sqlite3.connect(DB_FILE, isolation_level=None) # Auto-commit mode | |
# cursor.execute('PRAGMA journal_mode=WAL;') | |
# conn.close() | |
with sqlite3.connect(DB_FILE) as conn: # Use context manager | |
cursor = conn.cursor() | |
cursor.execute(''' | |
CREATE TABLE IF NOT EXISTS plots ( | |
grid_x INTEGER NOT NULL, grid_z INTEGER NOT NULL, name TEXT, | |
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |
PRIMARY KEY (grid_x, grid_z) | |
) | |
''') | |
cursor.execute(''' | |
CREATE TABLE IF NOT EXISTS objects ( | |
obj_id TEXT PRIMARY KEY, plot_grid_x INTEGER NOT NULL, plot_grid_z INTEGER NOT NULL, | |
type TEXT NOT NULL, pos_x REAL NOT NULL, pos_y REAL NOT NULL, pos_z REAL NOT NULL, | |
rot_x REAL DEFAULT 0.0, rot_y REAL DEFAULT 0.0, rot_z REAL DEFAULT 0.0, rot_order TEXT DEFAULT 'XYZ', | |
FOREIGN KEY (plot_grid_x, plot_grid_z) REFERENCES plots(grid_x, grid_z) ON DELETE CASCADE | |
) | |
''') | |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_objects_plot ON objects (plot_grid_x, plot_grid_z)') | |
# No explicit commit needed with 'with' statement unless changes made | |
print(f"Database {os.path.abspath(DB_FILE)} initialized/checked.") # Print to console | |
except sqlite3.Error as e: | |
# Use st.exception for full traceback in the app UI | |
st.exception(f"CRITICAL DATABASE INIT ERROR: {e}") | |
st.stop() | |
init_db() # Ensure DB exists and has tables | |
# --- Helper Functions (Database Operations - Using 'with') --- | |
# No Caching - always load fresh from DB | |
def load_world_state_from_db(): | |
"""Loads all plot metadata and object data fresh from the SQLite DB.""" | |
st.write(f"Executing load_world_state_from_db for session {st.runtime.scriptrunner.get_script_run_ctx().session_id[:5]}...") # Debug session ID | |
plots_metadata = [] | |
all_objects_by_plot = {} | |
all_initial_objects_world = [] | |
try: | |
with sqlite3.connect(DB_FILE) as conn: # Auto-closes connection | |
conn.row_factory = sqlite3.Row | |
cursor = conn.cursor() | |
st.write("DB Read: Loading plot metadata...") | |
cursor.execute("SELECT grid_x, grid_z, name, strftime('%Y-%m-%d %H:%M:%S', last_updated) as updated_ts FROM plots ORDER BY grid_x, grid_z") | |
plot_rows = cursor.fetchall() | |
st.write(f"DB Read: Found {len(plot_rows)} plot rows.") | |
for row in plot_rows: | |
gx, gz = row['grid_x'], row['grid_z'] | |
plot_meta = { | |
'id': f"plot_X{gx}_Z{gz}", 'grid_x': gx, 'grid_z': gz, | |
'name': row['name'] or f"Plot ({gx},{gz})", | |
'x_offset': gx * PLOT_WIDTH, 'z_offset': gz * PLOT_DEPTH, | |
'last_updated': row['updated_ts'] # Add timestamp for debugging | |
} | |
plots_metadata.append(plot_meta) | |
all_objects_by_plot[(gx, gz)] = [] | |
st.write("DB Read: Loading all objects...") | |
cursor.execute("SELECT * FROM objects") | |
object_rows = cursor.fetchall() | |
st.write(f"DB Read: Found {len(object_rows)} object rows.") | |
# Group objects by plot | |
for row in object_rows: | |
plot_key = (row['plot_grid_x'], row['plot_grid_z']) | |
if plot_key in all_objects_by_plot: | |
all_objects_by_plot[plot_key].append(dict(row)) | |
# Combine and calculate world coordinates (outside DB connection) | |
for plot_meta in plots_metadata: | |
plot_key = (plot_meta['grid_x'], plot_meta['grid_z']) | |
for obj_data in all_objects_by_plot[plot_key]: | |
world_obj_data = obj_data.copy() | |
world_obj_data['pos_x'] = obj_data['pos_x'] + plot_meta['x_offset'] | |
world_obj_data['pos_z'] = obj_data['pos_z'] + plot_meta['z_offset'] | |
world_obj_data['pos_y'] = obj_data['pos_y'] | |
all_initial_objects_world.append(world_obj_data) | |
st.write(f"DB Load Complete: {len(plots_metadata)} plots, {len(all_initial_objects_world)} total objects processed.") | |
except sqlite3.Error as e: | |
st.error(f"Database load error: {e}") | |
return [], [] # Return empty on error | |
return plots_metadata, all_initial_objects_world | |
def save_plot_data_to_db(target_grid_x, target_grid_z, objects_data_list): | |
"""Saves object data list to DB for a specific plot. Overwrites existing objects.""" | |
st.write(f"Executing save_plot_data_to_db for plot ({target_grid_x},{target_grid_z})...") | |
plot_x_offset = target_grid_x * PLOT_WIDTH | |
plot_z_offset = target_grid_z * PLOT_DEPTH | |
plot_name = f"Plot ({target_grid_x},{target_grid_z})" # Simple default name for upsert | |
if not isinstance(objects_data_list, list): | |
st.error("Save Error: Invalid object data format (expected list).") | |
return False | |
try: | |
with sqlite3.connect(DB_FILE) as conn: # Auto commit/rollback/close | |
cursor = conn.cursor() | |
# Use savepoint for finer transaction control within 'with' if needed, but default is fine | |
# cursor.execute('BEGIN') # Implicitly handled by 'with' unless isolation_level=None | |
# 1. Upsert Plot - ensure plot exists and update timestamp | |
st.write(f"DB Save: Upserting plot ({target_grid_x},{target_grid_z})...") | |
cursor.execute(''' | |
INSERT INTO plots (grid_x, grid_z, name) VALUES (?, ?, ?) | |
ON CONFLICT(grid_x, grid_z) DO UPDATE SET name = excluded.name, last_updated = CURRENT_TIMESTAMP | |
''', (target_grid_x, target_grid_z, plot_name)) | |
st.write(f"DB Save: Plot upserted.") | |
# 2. Delete ALL existing objects for this specific plot | |
st.write(f"DB Save: Deleting old objects for plot ({target_grid_x},{target_grid_z})...") | |
cursor.execute("DELETE FROM objects WHERE plot_grid_x = ? AND plot_grid_z = ?", (target_grid_x, target_grid_z)) | |
st.write(f"DB Save: Deleted {cursor.rowcount} old objects.") | |
# 3. Insert the new objects | |
insert_count = 0 | |
objects_to_insert = [] | |
for obj in objects_data_list: | |
pos = obj.get('position', {}) | |
rot = obj.get('rotation', {}) | |
obj_type = obj.get('type', 'Unknown') | |
obj_id = obj.get('obj_id', str(uuid.uuid4())) | |
if not all(k in pos for k in ['x', 'y', 'z']) or obj_type == 'Unknown': | |
print(f"Skipping malformed object during DB save prep: {obj}") | |
continue | |
rel_x = pos.get('x', 0.0) - plot_x_offset | |
rel_z = pos.get('z', 0.0) - plot_z_offset | |
rel_y = pos.get('y', 0.0) # Y is absolute | |
objects_to_insert.append(( | |
obj_id, target_grid_x, target_grid_z, obj_type, | |
rel_x, rel_y, rel_z, | |
rot.get('_x', 0.0), rot.get('_y', 0.0), rot.get('_z', 0.0), | |
rot.get('_order', 'XYZ') | |
)) | |
insert_count += 1 | |
if objects_to_insert: | |
st.write(f"DB Save: Inserting {insert_count} new objects...") | |
cursor.executemany(''' | |
INSERT OR REPLACE INTO objects (obj_id, plot_grid_x, plot_grid_z, type, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_order) | |
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
''', objects_to_insert) # Use INSERT OR REPLACE to handle potential obj_id conflicts cleanly | |
st.write(f"DB Save: Objects inserted.") | |
else: | |
st.write(f"DB Save: No new objects to insert.") | |
# 'with' statement handles commit on success, rollback on error | |
st.success(f"DB Save Commit: Plot ({target_grid_x},{target_grid_z}) saved with {insert_count} objects.") | |
# Add a tiny delay AFTER commit, maybe helps filesystem cache? (Experimental) | |
# time.sleep(0.1) | |
return True | |
except sqlite3.Error as e: | |
st.error(f"Database save error for plot ({target_grid_x},{target_grid_z}): {e}") | |
st.exception(e) # Show full traceback | |
return False | |
# --- Page Config --- | |
st.set_page_config( page_title="DB Synced World Builder v2", layout="wide") | |
# --- Initialize Session State --- | |
if 'selected_object' not in st.session_state: st.session_state.selected_object = 'None' | |
if 'js_save_data_result' not in st.session_state: st.session_state.js_save_data_result = None | |
# --- Load World State From DB (Runs on every script execution/rerun) --- | |
plots_metadata, all_initial_objects = load_world_state_from_db() | |
# --- Sidebar --- | |
with st.sidebar: | |
st.title("🏗️ World Controls") | |
# Refresh Button (Just triggers rerun, load function always hits DB) | |
if st.button("🔄 Refresh World View", key="refresh_button"): | |
st.info("Reloading world state from database...") | |
st.rerun() | |
st.header("Navigation (Plots)") | |
# ... (Navigation button code remains the same as previous version) ... | |
st.caption("Click to teleport player to a plot.") | |
max_cols = 2 | |
cols = st.columns(max_cols) | |
col_idx = 0 | |
sorted_plots_for_nav = sorted(plots_metadata, key=lambda p: (p['grid_x'], p['grid_z'])) | |
for plot in sorted_plots_for_nav: | |
button_label = f"➡️ {plot.get('name', plot['id'])} ({plot['grid_x']},{plot['grid_z']})" | |
if cols[col_idx].button(button_label, key=f"nav_{plot['id']}"): | |
target_x = plot['x_offset'] + PLOT_WIDTH / 2 | |
target_z = plot['z_offset'] + PLOT_DEPTH / 2 | |
try: | |
js_code = f"teleportPlayer({target_x}, {target_z});" | |
streamlit_js_eval(js_code=js_code, key=f"teleport_{plot['id']}") | |
except Exception as e: st.error(f"Teleport command failed: {e}") | |
col_idx = (col_idx + 1) % max_cols | |
st.markdown("---") | |
# --- Object Placement --- | |
st.header("Place Objects") | |
# ... (Object selection code remains the same) ... | |
object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"] | |
current_object_index = object_types.index(st.session_state.selected_object) if st.session_state.selected_object in object_types else 0 | |
selected_object_type_widget = st.selectbox( | |
"Select Object:", options=object_types, index=current_object_index, key="selected_object_widget" | |
) | |
if selected_object_type_widget != st.session_state.selected_object: | |
st.session_state.selected_object = selected_object_type_widget | |
# Rerun updates injection, sessionStorage persists JS side | |
st.markdown("---") | |
# --- Saving --- | |
st.header("Save Work") | |
st.caption("Saves ALL objects currently within the player's plot to the central database.") | |
if st.button("💾 Save Current Plot", key="save_button"): | |
js_get_data_code = "getSaveDataAndPosition();" | |
streamlit_js_eval(js_code=js_get_data_code, key="js_save_processor") | |
st.rerun() | |
# --- Process Save Data --- | |
save_data_from_js = st.session_state.get("js_save_processor", None) | |
if save_data_from_js is not None: | |
st.info("Processing save request...") | |
save_processed_successfully = False | |
try: | |
payload = json.loads(save_data_from_js) if isinstance(save_data_from_js, str) else save_data_from_js | |
if isinstance(payload, dict) and 'playerPosition' in payload and 'objectsToSave' in payload: | |
player_pos = payload['playerPosition'] | |
objects_to_save = payload['objectsToSave'] # World coords from JS | |
if isinstance(objects_to_save, list): # Allow saving empty list (clears plot) | |
target_grid_x = math.floor(player_pos.get('x', 0.0) / PLOT_WIDTH) | |
target_grid_z = math.floor(player_pos.get('z', 0.0) / PLOT_DEPTH) | |
st.write(f"Attempting DB save for plot ({target_grid_x},{target_grid_z})") | |
# --- Save the data to SQLite DB --- | |
save_ok = save_plot_data_to_db(target_grid_x, target_grid_z, objects_to_save) | |
if save_ok: | |
# No cache clear needed. Success message is inside save_plot_data_to_db | |
save_processed_successfully = True | |
else: | |
st.error(f"Failed DB save for plot ({target_grid_x},{target_grid_z}).") | |
else: st.error("Invalid 'objectsToSave' format (expected list).") | |
else: st.error("Invalid save payload structure received.") | |
except json.JSONDecodeError: | |
st.error("Failed to decode save data from client.") | |
print("Received raw data:", save_data_from_js) # Log raw data | |
except Exception as e: | |
st.error(f"Error processing save: {e}") | |
st.exception(e) | |
# Clear the trigger data from session state ALWAYS after attempting processing | |
st.session_state.js_save_processor = None | |
# Rerun after processing save to reload world state from DB | |
if save_processed_successfully: | |
st.rerun() | |
# --- Main Area --- | |
st.header("Database Synced 3D World") | |
st.caption(f"DB: '{os.path.abspath(DB_FILE)}'. Plots loaded: {len(plots_metadata)}. Use 'Refresh' to see updates.") | |
# --- Load and Prepare HTML --- | |
html_file_path = 'index.html' | |
html_content_with_state = None | |
try: | |
with open(html_file_path, 'r', encoding='utf-8') as f: | |
html_template = f.read() | |
js_injection_script = f""" | |
<script> | |
// Inject data loaded fresh from DB | |
window.ALL_INITIAL_OBJECTS = {json.dumps(all_initial_objects)}; | |
window.PLOTS_METADATA = {json.dumps(plots_metadata)}; // Still useful for JS ground generation | |
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)}; | |
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)}; | |
window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)}; | |
console.log("Streamlit State Injected:", {{ /* ... logging ... */ }}); | |
</script> | |
""" | |
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1) | |
# Embed HTML Component | |
components.html( html_content_with_state, height=750, scrolling=False ) | |
except FileNotFoundError: | |
st.error(f"CRITICAL ERROR: Could not find the file '{html_file_path}'.") | |
except Exception as e: | |
st.error(f"An critical error occurred during HTML prep/render: {e}") | |
st.exception(e) |