use crate::*; #[spacetimedb::table(accessor = runtime_snapshot)] pub struct RuntimeSnapshotRow { #[primary_key] pub(crate) user_id: String, pub(crate) version: u32, pub(crate) saved_at: Timestamp, pub(crate) bottom_tab: String, pub(crate) game_state_json: String, pub(crate) current_story_json: Option, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, } // 当前快照读取保持旧 Node 语义:无快照时返回 ok=true + record=None,而不是默认空对象。 #[spacetimedb::procedure] pub fn get_runtime_snapshot( ctx: &mut ProcedureContext, input: RuntimeSnapshotGetInput, ) -> RuntimeSnapshotProcedureResult { match ctx.try_with_tx(|tx| get_runtime_snapshot_record(tx, input.clone())) { Ok(record) => RuntimeSnapshotProcedureResult { ok: true, record, error_message: None, }, Err(message) => RuntimeSnapshotProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // PUT snapshot 主链会同步刷新 dashboard / wallet / played_world / save_archive 四类 projection。 #[spacetimedb::procedure] pub fn upsert_runtime_snapshot_and_return( ctx: &mut ProcedureContext, input: RuntimeSnapshotUpsertInput, ) -> RuntimeSnapshotProcedureResult { match ctx.try_with_tx(|tx| upsert_runtime_snapshot_record(tx, input.clone())) { Ok(record) => RuntimeSnapshotProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => RuntimeSnapshotProcedureResult { ok: false, record: None, error_message: Some(message), }, } } // 删除当前快照只影响 runtime_snapshot 主表,不联动清理 profile projection。 #[spacetimedb::procedure] pub fn delete_runtime_snapshot_and_return( ctx: &mut ProcedureContext, input: RuntimeSnapshotDeleteInput, ) -> RuntimeSnapshotProcedureResult { match ctx.try_with_tx(|tx| delete_runtime_snapshot_record(tx, input.clone())) { Ok(record) => RuntimeSnapshotProcedureResult { ok: true, record, error_message: None, }, Err(message) => RuntimeSnapshotProcedureResult { ok: false, record: None, error_message: Some(message), }, } } pub(crate) fn get_runtime_snapshot_record( ctx: &ReducerContext, input: RuntimeSnapshotGetInput, ) -> Result, String> { let validated_input = build_runtime_snapshot_get_input(input.user_id).map_err(|error| error.to_string())?; Ok(ctx .db .runtime_snapshot() .user_id() .find(&validated_input.user_id) .map(|row| build_runtime_snapshot_from_row(&row))) } pub(crate) fn upsert_runtime_snapshot_record( ctx: &ReducerContext, input: RuntimeSnapshotUpsertInput, ) -> Result { let current_story_value = parse_optional_json_str(input.current_story_json.as_deref())?; let game_state = parse_json_str(&input.game_state_json)?; let prepared = build_runtime_snapshot_upsert_input( input.user_id, input.saved_at_micros, input.bottom_tab, game_state.clone(), current_story_value.clone(), input.updated_at_micros, ) .map_err(|error| error.to_string())?; let updated_at = Timestamp::from_micros_since_unix_epoch(prepared.updated_at_micros); let saved_at = Timestamp::from_micros_since_unix_epoch(prepared.saved_at_micros); let snapshot = match ctx.db.runtime_snapshot().user_id().find(&prepared.user_id) { Some(existing) => { ctx.db .runtime_snapshot() .user_id() .delete(&existing.user_id); ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { user_id: existing.user_id.clone(), version: SAVE_SNAPSHOT_VERSION, saved_at, bottom_tab: prepared.bottom_tab.clone(), game_state_json: prepared.game_state_json.clone(), current_story_json: prepared.current_story_json.clone(), created_at: existing.created_at, updated_at, }); RuntimeSnapshot { user_id: existing.user_id, version: SAVE_SNAPSHOT_VERSION, saved_at_micros: prepared.saved_at_micros, bottom_tab: prepared.bottom_tab, game_state_json: prepared.game_state_json, current_story_json: prepared.current_story_json, created_at_micros: existing.created_at.to_micros_since_unix_epoch(), updated_at_micros: prepared.updated_at_micros, } } None => { ctx.db.runtime_snapshot().insert(RuntimeSnapshotRow { user_id: prepared.user_id.clone(), version: SAVE_SNAPSHOT_VERSION, saved_at, bottom_tab: prepared.bottom_tab.clone(), game_state_json: prepared.game_state_json.clone(), current_story_json: prepared.current_story_json.clone(), created_at: updated_at, updated_at, }); RuntimeSnapshot { user_id: prepared.user_id, version: SAVE_SNAPSHOT_VERSION, saved_at_micros: prepared.saved_at_micros, bottom_tab: prepared.bottom_tab, game_state_json: prepared.game_state_json, current_story_json: prepared.current_story_json, created_at_micros: prepared.updated_at_micros, updated_at_micros: prepared.updated_at_micros, } } }; sync_profile_projections_from_snapshot(ctx, &snapshot)?; Ok(snapshot) } pub(crate) fn delete_runtime_snapshot_record( ctx: &ReducerContext, input: RuntimeSnapshotDeleteInput, ) -> Result, String> { let validated_input = build_runtime_snapshot_delete_input(input.user_id).map_err(|error| error.to_string())?; let existing = ctx .db .runtime_snapshot() .user_id() .find(&validated_input.user_id); if let Some(existing) = existing { let snapshot = build_runtime_snapshot_from_row(&existing); ctx.db .runtime_snapshot() .user_id() .delete(&existing.user_id); return Ok(Some(snapshot)); } Ok(None) } pub(crate) fn build_runtime_snapshot_from_row(row: &RuntimeSnapshotRow) -> RuntimeSnapshot { RuntimeSnapshot { user_id: row.user_id.clone(), version: row.version, saved_at_micros: row.saved_at.to_micros_since_unix_epoch(), bottom_tab: row.bottom_tab.clone(), game_state_json: row.game_state_json.clone(), current_story_json: row.current_story_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } pub(crate) fn parse_json_str(raw: &str) -> Result { serde_json::from_str::(raw) .map_err(|error| format!("game_state_json 解析失败: {error}")) } pub(crate) fn parse_optional_json_str(raw: Option<&str>) -> Result, String> { match raw.map(str::trim).filter(|value| !value.is_empty()) { Some(value) => serde_json::from_str::(value) .map(Some) .map_err(|error| format!("current_story_json 解析失败: {error}")), None => Ok(None), } }