feat: complete M3 runtime snapshot and profile save archive
This commit is contained in:
@@ -10,6 +10,7 @@ spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use shared_kernel::{
|
||||
format_rfc3339 as format_shared_rfc3339, normalize_optional_string, normalize_required_string,
|
||||
parse_rfc3339 as parse_shared_rfc3339,
|
||||
@@ -14,6 +15,8 @@ pub const DEFAULT_PLATFORM_THEME: RuntimePlatformTheme = RuntimePlatformTheme::L
|
||||
pub const DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME: &str = "玩家";
|
||||
pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
|
||||
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
|
||||
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
|
||||
// 运行时设置目前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -29,6 +32,94 @@ pub struct RuntimeSettings {
|
||||
pub platform_theme: RuntimePlatformTheme,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSnapshot {
|
||||
pub user_id: String,
|
||||
pub version: u32,
|
||||
pub saved_at_micros: i64,
|
||||
pub bottom_tab: String,
|
||||
pub game_state_json: String,
|
||||
pub current_story_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSnapshotProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSnapshotGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSnapshotUpsertInput {
|
||||
pub user_id: String,
|
||||
pub saved_at_micros: i64,
|
||||
pub bottom_tab: String,
|
||||
pub game_state_json: String,
|
||||
pub current_story_json: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeSnapshotDeleteInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileSaveArchiveSnapshot {
|
||||
pub archive_id: String,
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub bottom_tab: String,
|
||||
pub game_state_json: String,
|
||||
pub current_story_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileSaveArchiveProcedureResult {
|
||||
pub ok: bool,
|
||||
pub entries: Vec<RuntimeProfileSaveArchiveSnapshot>,
|
||||
pub record: Option<RuntimeProfileSaveArchiveSnapshot>,
|
||||
pub current_snapshot: Option<RuntimeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileSaveArchiveListInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileSaveArchiveResumeInput {
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
}
|
||||
|
||||
// 浏览历史沿用平台已有的六种世界主题,但独立冻结在 runtime 领域内,避免反向耦合创作域 crate。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -242,6 +333,10 @@ pub enum RuntimeBrowseHistoryFieldError {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeProfileFieldError {
|
||||
MissingUserId,
|
||||
MissingWorldKey,
|
||||
MissingBottomTab,
|
||||
InvalidGameStateJson,
|
||||
InvalidCurrentStoryJson,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -444,6 +539,44 @@ pub struct RuntimeProfilePlayStatsRecord {
|
||||
pub updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeSnapshotRecord {
|
||||
pub user_id: String,
|
||||
pub version: u32,
|
||||
pub saved_at: String,
|
||||
pub saved_at_micros: i64,
|
||||
pub bottom_tab: String,
|
||||
pub game_state: Value,
|
||||
pub current_story: Option<Value>,
|
||||
pub game_state_json: String,
|
||||
pub current_story_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileSaveArchiveRecord {
|
||||
pub archive_id: String,
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub saved_at: String,
|
||||
pub saved_at_micros: i64,
|
||||
pub bottom_tab: String,
|
||||
pub game_state: Value,
|
||||
pub current_story: Option<Value>,
|
||||
pub game_state_json: String,
|
||||
pub current_story_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_list_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeBrowseHistoryListInput, RuntimeBrowseHistoryFieldError> {
|
||||
@@ -472,6 +605,37 @@ pub fn build_runtime_profile_play_stats_get_input(
|
||||
Ok(RuntimeProfilePlayStatsGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_snapshot_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeSnapshotGetInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeSnapshotGetInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_snapshot_delete_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeSnapshotDeleteInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeSnapshotDeleteInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_list_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfileSaveArchiveListInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
Ok(RuntimeProfileSaveArchiveListInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_resume_input(
|
||||
user_id: String,
|
||||
world_key: String,
|
||||
) -> Result<RuntimeProfileSaveArchiveResumeInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
let world_key =
|
||||
normalize_required_string(world_key).ok_or(RuntimeProfileFieldError::MissingWorldKey)?;
|
||||
Ok(RuntimeProfileSaveArchiveResumeInput { user_id, world_key })
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_clear_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeBrowseHistoryClearInput, RuntimeBrowseHistoryFieldError> {
|
||||
@@ -479,6 +643,31 @@ pub fn build_runtime_browse_history_clear_input(
|
||||
Ok(RuntimeBrowseHistoryClearInput { user_id })
|
||||
}
|
||||
|
||||
pub fn build_runtime_snapshot_upsert_input(
|
||||
user_id: String,
|
||||
saved_at_micros: i64,
|
||||
bottom_tab: String,
|
||||
game_state: Value,
|
||||
current_story: Option<Value>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeSnapshotUpsertInput, RuntimeProfileFieldError> {
|
||||
let user_id = normalize_runtime_profile_user_id(user_id)?;
|
||||
let bottom_tab = normalize_bottom_tab(bottom_tab)
|
||||
.ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
|
||||
let game_state_json =
|
||||
serde_json::to_string(&game_state).map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story_json = normalize_current_story_json(current_story)?;
|
||||
|
||||
Ok(RuntimeSnapshotUpsertInput {
|
||||
user_id,
|
||||
saved_at_micros,
|
||||
bottom_tab,
|
||||
game_state_json,
|
||||
current_story_json,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_sync_input(
|
||||
user_id: String,
|
||||
entries: Vec<RuntimeBrowseHistoryWriteInput>,
|
||||
@@ -669,6 +858,64 @@ pub fn build_runtime_profile_play_stats_record(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_snapshot_record(
|
||||
snapshot: RuntimeSnapshot,
|
||||
) -> Result<RuntimeSnapshotRecord, RuntimeProfileFieldError> {
|
||||
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story = parse_optional_json_value(
|
||||
snapshot.current_story_json.as_deref(),
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeSnapshotRecord {
|
||||
user_id: snapshot.user_id,
|
||||
version: snapshot.version,
|
||||
saved_at: format_utc_micros(snapshot.saved_at_micros),
|
||||
saved_at_micros: snapshot.saved_at_micros,
|
||||
bottom_tab: snapshot.bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: snapshot.game_state_json,
|
||||
current_story_json: snapshot.current_story_json,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_save_archive_record(
|
||||
snapshot: RuntimeProfileSaveArchiveSnapshot,
|
||||
) -> Result<RuntimeProfileSaveArchiveRecord, RuntimeProfileFieldError> {
|
||||
let game_state = serde_json::from_str::<Value>(&snapshot.game_state_json)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
|
||||
let current_story = parse_optional_json_value(
|
||||
snapshot.current_story_json.as_deref(),
|
||||
RuntimeProfileFieldError::InvalidCurrentStoryJson,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeProfileSaveArchiveRecord {
|
||||
archive_id: snapshot.archive_id,
|
||||
user_id: snapshot.user_id,
|
||||
world_key: snapshot.world_key,
|
||||
owner_user_id: snapshot.owner_user_id,
|
||||
profile_id: snapshot.profile_id,
|
||||
world_type: snapshot.world_type,
|
||||
world_name: snapshot.world_name,
|
||||
subtitle: snapshot.subtitle,
|
||||
summary_text: snapshot.summary_text,
|
||||
cover_image_src: snapshot.cover_image_src,
|
||||
saved_at: format_utc_micros(snapshot.saved_at_micros),
|
||||
saved_at_micros: snapshot.saved_at_micros,
|
||||
bottom_tab: snapshot.bottom_tab,
|
||||
game_state,
|
||||
current_story,
|
||||
game_state_json: snapshot.game_state_json,
|
||||
current_story_json: snapshot.current_story_json,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_browse_history_id(
|
||||
user_id: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -693,6 +940,43 @@ fn parse_utc_rfc3339_to_micros(value: &str) -> Option<i64> {
|
||||
i64::try_from(nanos / 1_000).ok()
|
||||
}
|
||||
|
||||
fn normalize_bottom_tab(value: String) -> Option<String> {
|
||||
let trimmed = normalize_required_string(value)?;
|
||||
let normalized = match trimmed.as_str() {
|
||||
"character" | "inventory" => trimmed,
|
||||
_ => "adventure".to_string(),
|
||||
};
|
||||
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn normalize_current_story_json(
|
||||
current_story: Option<Value>,
|
||||
) -> Result<Option<String>, RuntimeProfileFieldError> {
|
||||
let Some(current_story) = current_story else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !current_story.is_object() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
serde_json::to_string(¤t_story)
|
||||
.map(Some)
|
||||
.map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson)
|
||||
}
|
||||
|
||||
fn parse_optional_json_value(
|
||||
raw: Option<&str>,
|
||||
error: RuntimeProfileFieldError,
|
||||
) -> Result<Option<Value>, RuntimeProfileFieldError> {
|
||||
match raw.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(value) => serde_json::from_str::<Value>(value)
|
||||
.map(Some)
|
||||
.map_err(|_| error),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeSettingsFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -726,6 +1010,12 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
|
||||
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
|
||||
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
|
||||
Self::InvalidGameStateJson => f.write_str("runtime_snapshot.game_state 必须是合法 JSON"),
|
||||
Self::InvalidCurrentStoryJson => {
|
||||
f.write_str("runtime_snapshot.current_story 必须是合法 JSON object 或 null")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user