Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -0,0 +1,86 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub(super) fn sanitize_identity_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect::<String>();
sanitized.trim_matches('_').to_string()
}
#[derive(Deserialize, Serialize)]
pub(super) struct PersistentAuthStoreSnapshot {
#[serde(default = "default_next_user_id")]
pub(super) next_user_id: u64,
pub(super) users_by_username: std::collections::HashMap<String, StoredPasswordUserSnapshot>,
#[serde(default)]
pub(super) phone_to_user_id: std::collections::HashMap<String, String>,
pub(super) sessions_by_id: std::collections::HashMap<String, StoredRefreshSessionSnapshot>,
#[serde(default)]
pub(super) session_id_by_refresh_token_hash: std::collections::HashMap<String, String>,
pub(super) wechat_identity_by_provider_uid:
std::collections::HashMap<String, StoredWechatIdentitySnapshot>,
#[serde(default)]
pub(super) user_id_by_provider_union_id: std::collections::HashMap<String, String>,
}
fn default_next_user_id() -> u64 {
1
}
#[derive(Deserialize, Serialize)]
pub(super) struct StoredPasswordUserSnapshot {
pub(super) user: AuthUserSnapshot,
pub(super) password_hash: String,
#[serde(default)]
pub(super) password_login_enabled: bool,
pub(super) phone_number: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub(super) struct AuthUserSnapshot {
pub(super) id: String,
pub(super) public_user_code: String,
pub(super) username: String,
pub(super) display_name: String,
pub(super) phone_number_masked: Option<String>,
pub(super) login_method: String,
pub(super) binding_status: String,
pub(super) wechat_bound: bool,
pub(super) token_version: u64,
}
#[derive(Deserialize, Serialize)]
pub(super) struct StoredWechatIdentitySnapshot {
pub(super) user_id: String,
pub(super) provider_uid: String,
pub(super) provider_union_id: Option<String>,
pub(super) display_name: Option<String>,
pub(super) avatar_url: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub(super) struct StoredRefreshSessionSnapshot {
pub(super) session: RefreshSessionSnapshot,
}
#[derive(Deserialize, Serialize)]
pub(super) struct RefreshSessionSnapshot {
pub(super) session_id: String,
pub(super) user_id: String,
pub(super) refresh_token_hash: String,
pub(super) issued_by_provider: String,
pub(super) client_info: Value,
pub(super) expires_at: String,
pub(super) revoked_at: Option<String>,
pub(super) created_at: String,
pub(super) updated_at: String,
pub(super) last_seen_at: String,
}

View File

@@ -0,0 +1,6 @@
mod mapper;
mod procedures;
mod tables;
pub use procedures::*;
pub use tables::*;

View File

@@ -1,6 +1,16 @@
use crate::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
use super::{
mapper::{
AuthUserSnapshot, PersistentAuthStoreSnapshot, RefreshSessionSnapshot,
StoredPasswordUserSnapshot, StoredRefreshSessionSnapshot, StoredWechatIdentitySnapshot,
sanitize_identity_component,
},
tables::{
AuthIdentity, AuthStoreSnapshot, RefreshSession, UserAccount, auth_identity,
auth_store_snapshot, refresh_session, user_account,
},
};
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
@@ -37,71 +47,6 @@ pub struct AuthStoreSnapshotImportProcedureResult {
pub error_message: Option<String>,
}
#[spacetimedb::table(accessor = auth_store_snapshot)]
pub struct AuthStoreSnapshot {
#[primary_key]
pub(crate) snapshot_id: String,
pub(crate) snapshot_json: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = user_account,
index(accessor = by_user_account_username, btree(columns = [username])),
index(accessor = by_user_account_public_code, btree(columns = [public_user_code]))
)]
pub struct UserAccount {
#[primary_key]
pub(crate) user_id: String,
pub(crate) public_user_code: String,
pub(crate) username: String,
pub(crate) display_name: String,
pub(crate) phone_number_masked: Option<String>,
pub(crate) phone_number_e164: Option<String>,
pub(crate) login_method: String,
pub(crate) binding_status: String,
pub(crate) wechat_bound: bool,
pub(crate) password_hash: String,
pub(crate) password_login_enabled: bool,
pub(crate) token_version: u64,
}
#[spacetimedb::table(
accessor = auth_identity,
index(accessor = by_auth_identity_user_id, btree(columns = [user_id])),
index(accessor = by_auth_identity_provider_uid, btree(columns = [provider, provider_uid]))
)]
pub struct AuthIdentity {
#[primary_key]
pub(crate) identity_id: String,
pub(crate) user_id: String,
pub(crate) provider: String,
pub(crate) provider_uid: String,
pub(crate) provider_union_id: Option<String>,
pub(crate) phone_e164: Option<String>,
pub(crate) display_name: Option<String>,
pub(crate) avatar_url: Option<String>,
}
#[spacetimedb::table(
accessor = refresh_session,
index(accessor = by_refresh_session_user_id, btree(columns = [user_id])),
index(accessor = by_refresh_session_token_hash, btree(columns = [refresh_token_hash]))
)]
pub struct RefreshSession {
#[primary_key]
pub(crate) session_id: String,
pub(crate) user_id: String,
pub(crate) refresh_token_hash: String,
pub(crate) issued_by_provider: String,
pub(crate) client_info_json: String,
pub(crate) expires_at: String,
pub(crate) revoked_at: Option<String>,
pub(crate) created_at: String,
pub(crate) updated_at: String,
pub(crate) last_seen_at: String,
}
// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态。
#[spacetimedb::procedure]
pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult {
@@ -157,7 +102,7 @@ pub fn import_auth_store_snapshot(
}
}
// Axum ??????????????? module-auth ????????????????
// Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。
#[spacetimedb::procedure]
pub fn export_auth_store_snapshot_from_tables(
ctx: &mut ProcedureContext,
@@ -409,8 +354,8 @@ fn export_auth_store_snapshot_from_tables_tx(
let mut sessions_by_id = std::collections::HashMap::new();
let mut session_id_by_refresh_token_hash = std::collections::HashMap::new();
for session in sessions {
let client_info = serde_json::from_str::<Value>(&session.client_info_json)
.map_err(|error| format!("refresh session ????? JSON ?????{error}"))?;
let client_info = serde_json::from_str::<serde_json::Value>(&session.client_info_json)
.map_err(|error| format!("refresh session 客户端信息 JSON 解析失败:{error}"))?;
session_id_by_refresh_token_hash.insert(
session.refresh_token_hash.clone(),
session.session_id.clone(),
@@ -443,8 +388,8 @@ fn export_auth_store_snapshot_from_tables_tx(
wechat_identity_by_provider_uid,
user_id_by_provider_union_id,
};
let snapshot_json =
serde_json::to_string_pretty(&snapshot).map_err(|error| format!("?????????????{error}"))?;
let snapshot_json = serde_json::to_string_pretty(&snapshot)
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
Ok(AuthStoreSnapshotRecord {
snapshot_json: Some(snapshot_json),
@@ -469,87 +414,3 @@ fn clear_auth_target_tables(ctx: &ReducerContext) {
ctx.db.user_account().user_id().delete(&row.user_id);
}
}
fn sanitize_identity_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect::<String>();
sanitized.trim_matches('_').to_string()
}
#[derive(Deserialize, Serialize)]
struct PersistentAuthStoreSnapshot {
#[serde(default = "default_next_user_id")]
next_user_id: u64,
users_by_username: std::collections::HashMap<String, StoredPasswordUserSnapshot>,
#[serde(default)]
phone_to_user_id: std::collections::HashMap<String, String>,
sessions_by_id: std::collections::HashMap<String, StoredRefreshSessionSnapshot>,
#[serde(default)]
session_id_by_refresh_token_hash: std::collections::HashMap<String, String>,
wechat_identity_by_provider_uid:
std::collections::HashMap<String, StoredWechatIdentitySnapshot>,
#[serde(default)]
user_id_by_provider_union_id: std::collections::HashMap<String, String>,
}
fn default_next_user_id() -> u64 {
1
}
#[derive(Deserialize, Serialize)]
struct StoredPasswordUserSnapshot {
user: AuthUserSnapshot,
password_hash: String,
#[serde(default)]
password_login_enabled: bool,
phone_number: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct AuthUserSnapshot {
id: String,
public_user_code: String,
username: String,
display_name: String,
phone_number_masked: Option<String>,
login_method: String,
binding_status: String,
wechat_bound: bool,
token_version: u64,
}
#[derive(Deserialize, Serialize)]
struct StoredWechatIdentitySnapshot {
user_id: String,
provider_uid: String,
provider_union_id: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct StoredRefreshSessionSnapshot {
session: RefreshSessionSnapshot,
}
#[derive(Deserialize, Serialize)]
struct RefreshSessionSnapshot {
session_id: String,
user_id: String,
refresh_token_hash: String,
issued_by_provider: String,
client_info: Value,
expires_at: String,
revoked_at: Option<String>,
created_at: String,
updated_at: String,
last_seen_at: String,
}

View File

@@ -0,0 +1,66 @@
use crate::Timestamp;
#[spacetimedb::table(accessor = auth_store_snapshot)]
pub struct AuthStoreSnapshot {
#[primary_key]
pub(crate) snapshot_id: String,
pub(crate) snapshot_json: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = user_account,
index(accessor = by_user_account_username, btree(columns = [username])),
index(accessor = by_user_account_public_code, btree(columns = [public_user_code]))
)]
pub struct UserAccount {
#[primary_key]
pub(crate) user_id: String,
pub(crate) public_user_code: String,
pub(crate) username: String,
pub(crate) display_name: String,
pub(crate) phone_number_masked: Option<String>,
pub(crate) phone_number_e164: Option<String>,
pub(crate) login_method: String,
pub(crate) binding_status: String,
pub(crate) wechat_bound: bool,
pub(crate) password_hash: String,
pub(crate) password_login_enabled: bool,
pub(crate) token_version: u64,
}
#[spacetimedb::table(
accessor = auth_identity,
index(accessor = by_auth_identity_user_id, btree(columns = [user_id])),
index(accessor = by_auth_identity_provider_uid, btree(columns = [provider, provider_uid]))
)]
pub struct AuthIdentity {
#[primary_key]
pub(crate) identity_id: String,
pub(crate) user_id: String,
pub(crate) provider: String,
pub(crate) provider_uid: String,
pub(crate) provider_union_id: Option<String>,
pub(crate) phone_e164: Option<String>,
pub(crate) display_name: Option<String>,
pub(crate) avatar_url: Option<String>,
}
#[spacetimedb::table(
accessor = refresh_session,
index(accessor = by_refresh_session_user_id, btree(columns = [user_id])),
index(accessor = by_refresh_session_token_hash, btree(columns = [refresh_token_hash]))
)]
pub struct RefreshSession {
#[primary_key]
pub(crate) session_id: String,
pub(crate) user_id: String,
pub(crate) refresh_token_hash: String,
pub(crate) issued_by_provider: String,
pub(crate) client_info_json: String,
pub(crate) expires_at: String,
pub(crate) revoked_at: Option<String>,
pub(crate) created_at: String,
pub(crate) updated_at: String,
pub(crate) last_seen_at: String,
}

View File

@@ -37,7 +37,10 @@ pub(crate) fn emit_big_fish_publish_readiness_event(
publish_ready,
blockers,
occurred_at_micros,
} = event;
} = event
else {
return Ok(());
};
let blockers_json = serde_json::to_string(&blockers)
.map_err(|error| format!("big_fish.publish_readiness.blockers 序列化失败: {error}"))?;

View File

@@ -1,9 +1,11 @@
mod assets;
mod events;
mod runtime;
mod session;
mod tables;
pub use assets::*;
pub(crate) use events::*;
pub use runtime::*;
pub use session::*;
pub use tables::*;

View File

@@ -0,0 +1,231 @@
use crate::big_fish::tables::{
BigFishCreationSession, BigFishRuntimeRun, big_fish_creation_session, big_fish_runtime_run,
};
use crate::*;
use module_big_fish::{
StartBigFishRunCommand, SubmitBigFishInputCommand, deserialize_runtime_snapshot,
serialize_runtime_snapshot, start_big_fish_run as start_big_fish_run_domain,
submit_big_fish_input as submit_big_fish_input_domain,
};
#[spacetimedb::procedure]
pub fn start_big_fish_run(
ctx: &mut ProcedureContext,
input: BigFishRunStartInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run_json: Some(serialize_big_fish_run_json(&run)),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_big_fish_run(
ctx: &mut ProcedureContext,
input: BigFishRunGetInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run_json: Some(serialize_big_fish_run_json(&run)),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_big_fish_input(
ctx: &mut ProcedureContext,
input: BigFishInputSubmitInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run_json: Some(serialize_big_fish_run_json(&run)),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
pub(crate) fn start_big_fish_run_tx(
ctx: &ReducerContext,
input: BigFishRunStartInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_start_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.is_some()
{
return Err("big_fish_runtime_run.run_id 已存在".to_string());
}
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
ensure_big_fish_session_playable(&session, &input.owner_user_id)?;
let draft = session
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let work_level_count = draft
.as_ref()
.map(|value| value.runtime_params.level_count)
.or_else(|| Some(BIG_FISH_DEFAULT_LEVEL_COUNT));
let result = start_big_fish_run_domain(StartBigFishRunCommand {
run_id: input.run_id,
session_id: input.session_id,
owner_user_id: input.owner_user_id.clone(),
draft,
work_level_count,
started_at_micros: input.started_at_micros,
})
.map_err(|error| error.to_string())?;
insert_big_fish_runtime_run(
ctx,
&result.snapshot,
&input.owner_user_id,
input.started_at_micros,
)?;
Ok(result.snapshot)
}
pub(crate) fn get_big_fish_run_tx(
ctx: &ReducerContext,
input: BigFishRunGetInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_get_input(&input).map_err(|error| error.to_string())?;
let row = get_owned_big_fish_run_row(ctx, &input.run_id, &input.owner_user_id)?;
deserialize_runtime_snapshot(&row.snapshot_json)
.map_err(|error| format!("big_fish.runtime_snapshot_json 非法: {error}"))
}
pub(crate) fn submit_big_fish_input_tx(
ctx: &ReducerContext,
input: BigFishInputSubmitInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_input_submit_input(&input).map_err(|error| error.to_string())?;
let row = get_owned_big_fish_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_snapshot = deserialize_runtime_snapshot(&row.snapshot_json)
.map_err(|error| format!("big_fish.runtime_snapshot_json 非法: {error}"))?;
let result = submit_big_fish_input_domain(SubmitBigFishInputCommand {
owner_user_id: input.owner_user_id,
x: input.x,
y: input.y,
submitted_at_micros: input.submitted_at_micros,
current_snapshot,
})
.map_err(|error| error.to_string())?;
replace_big_fish_runtime_run(ctx, &row, &result.snapshot, input.submitted_at_micros)?;
Ok(result.snapshot)
}
fn ensure_big_fish_session_playable(
session: &BigFishCreationSession,
player_user_id: &str,
) -> Result<(), String> {
if session.owner_user_id == player_user_id {
return Ok(());
}
if session.stage == BigFishCreationStage::Published {
return Ok(());
}
Err("未发布的大鱼吃小鱼作品不允许非作者启动运行态".to_string())
}
fn get_owned_big_fish_run_row(
ctx: &ReducerContext,
run_id: &str,
owner_user_id: &str,
) -> Result<BigFishRuntimeRun, String> {
let row = ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&run_id.to_string())
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
if row.owner_user_id != owner_user_id {
return Err("无权访问该 big_fish_runtime_run".to_string());
}
Ok(row)
}
fn insert_big_fish_runtime_run(
ctx: &ReducerContext,
run: &BigFishRuntimeSnapshot,
owner_user_id: &str,
created_at_micros: i64,
) -> Result<(), String> {
let timestamp = Timestamp::from_micros_since_unix_epoch(created_at_micros);
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
run_id: run.run_id.clone(),
session_id: run.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
status: run.status,
snapshot_json: serialize_runtime_snapshot(run)
.map_err(|error| format!("big_fish.runtime_snapshot 序列化失败: {error}"))?,
last_input_x: run.last_input.x,
last_input_y: run.last_input.y,
tick: run.tick,
created_at: timestamp,
updated_at: timestamp,
});
Ok(())
}
fn replace_big_fish_runtime_run(
ctx: &ReducerContext,
current: &BigFishRuntimeRun,
run: &BigFishRuntimeSnapshot,
updated_at_micros: i64,
) -> Result<(), String> {
ctx.db
.big_fish_runtime_run()
.run_id()
.delete(&current.run_id);
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
run_id: run.run_id.clone(),
session_id: run.session_id.clone(),
owner_user_id: current.owner_user_id.clone(),
status: run.status,
snapshot_json: serialize_runtime_snapshot(run)
.map_err(|error| format!("big_fish.runtime_snapshot 序列化失败: {error}"))?,
last_input_x: run.last_input.x,
last_input_y: run.last_input.y,
tick: run.tick,
created_at: current.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
Ok(())
}
fn serialize_big_fish_run_json(run: &BigFishRuntimeSnapshot) -> String {
serialize_runtime_snapshot(run).unwrap_or_else(|_| "{}".to_string())
}

View File

@@ -1,4 +1,6 @@
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
use crate::big_fish::tables::{
big_fish_agent_message, big_fish_creation_session, big_fish_runtime_run,
};
use crate::runtime::{
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
};
@@ -326,7 +328,7 @@ pub(crate) fn delete_big_fish_work_tx(
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
// 删除作品时同步清理 Agent 消息素材槽;最终游玩模拟已经迁到前端,不再写后端运行快照。
// 中文注释:删除作品时同步清理 Agent 消息素材槽后端运行快照,避免失去来源会话的 run 残留
ctx.db
.big_fish_creation_session()
.session_id()
@@ -352,6 +354,15 @@ pub(crate) fn delete_big_fish_work_tx(
{
ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id);
}
for run in ctx
.db
.big_fish_runtime_run()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id);
}
list_big_fish_works_tx(
ctx,
BigFishWorksListInput {

View File

@@ -52,3 +52,22 @@ pub struct BigFishAssetSlot {
pub(crate) prompt_snapshot: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_runtime_run,
index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_big_fish_run_session_id, btree(columns = [session_id]))
)]
pub struct BigFishRuntimeRun {
#[primary_key]
pub(crate) run_id: String,
pub(crate) session_id: String,
pub(crate) owner_user_id: String,
pub(crate) status: BigFishRunStatus,
pub(crate) snapshot_json: String,
pub(crate) last_input_x: f32,
pub(crate) last_input_y: f32,
pub(crate) tick: u64,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,12 @@
use crate::*;
use module_combat::resolve_combat_action as resolve_battle_state_action;
use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation;
use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain;
use module_quest::{
acknowledge_quest_completion as acknowledge_quest_record_completion,
apply_quest_signal as apply_quest_record_signal,
};
#[spacetimedb::table(accessor = player_progression)]
pub struct PlayerProgression {
#[primary_key]
@@ -874,6 +883,79 @@ pub fn get_story_session_state(
}
}
fn continue_story_tx(
ctx: &ReducerContext,
input: StoryContinueInput,
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> {
validate_story_continue_input(&input).map_err(|error| error.to_string())?;
let current = ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?;
let current_snapshot = build_story_session_snapshot_from_row(&current);
let (next_snapshot, event_snapshot) =
apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?;
ctx.db
.story_session()
.story_session_id()
.delete(&current.story_session_id);
ctx.db.story_session().insert(StorySession {
story_session_id: next_snapshot.story_session_id.clone(),
runtime_session_id: next_snapshot.runtime_session_id.clone(),
actor_user_id: next_snapshot.actor_user_id.clone(),
world_profile_id: next_snapshot.world_profile_id.clone(),
initial_prompt: next_snapshot.initial_prompt.clone(),
opening_summary: next_snapshot.opening_summary.clone(),
latest_narrative_text: next_snapshot.latest_narrative_text.clone(),
latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(),
status: next_snapshot.status,
version: next_snapshot.version,
created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros),
});
ctx.db.story_event().insert(StoryEvent {
event_id: event_snapshot.event_id.clone(),
story_session_id: event_snapshot.story_session_id.clone(),
event_kind: event_snapshot.event_kind,
narrative_text: event_snapshot.narrative_text.clone(),
choice_function_id: event_snapshot.choice_function_id.clone(),
created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros),
});
Ok((next_snapshot, event_snapshot))
}
fn get_story_session_state_tx(
ctx: &ReducerContext,
input: StorySessionStateInput,
) -> Result<(StorySessionSnapshot, Vec<StoryEventSnapshot>), String> {
validate_story_session_state_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.ok_or_else(|| "story_session 不存在".to_string())?;
let session_snapshot = build_story_session_snapshot_from_row(&session);
let mut events = ctx
.db
.story_event()
.iter()
.filter(|row| row.story_session_id == input.story_session_id)
.map(|row| build_story_event_snapshot_from_row(&row))
.collect::<Vec<_>>();
events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone()));
Ok((session_snapshot, events))
}
// 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。
#[spacetimedb::reducer]

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
use spacetimedb_lib::sats::ser::serde::SerializeWrapper;
use std::collections::HashSet;
use crate::big_fish::big_fish_runtime_run;
use crate::puzzle::{
puzzle_agent_message, puzzle_agent_session, puzzle_event, puzzle_leaderboard_entry,
puzzle_runtime_run, puzzle_work_profile,
@@ -147,6 +148,7 @@ macro_rules! migration_tables {
big_fish_creation_session,
big_fish_agent_message,
big_fish_asset_slot,
big_fish_runtime_run,
big_fish_event
}
};

View File

@@ -590,7 +590,7 @@ pub(crate) fn sync_profile_projections_from_snapshot(
let game_state_object = game_state.as_object();
let saved_at = Timestamp::from_micros_since_unix_epoch(snapshot.saved_at_micros);
if is_non_persistent_runtime_snapshot(&game_state) {
if module_runtime::is_non_persistent_runtime_snapshot(&game_state) {
return Ok(());
}
@@ -614,7 +614,7 @@ pub(crate) fn upsert_profile_played_work(
}
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
let played_world_id = format!("{user_id}:{world_key}");
let played_world_id = build_runtime_profile_played_world_id(user_id, world_key);
let existing = ctx
.db
.profile_played_world()
@@ -673,7 +673,7 @@ pub(crate) fn add_profile_observed_play_time(
}
let observed_at = Timestamp::from_micros_since_unix_epoch(observed_at_micros);
let played_world_id = format!("{user_id}:{world_key}");
let played_world_id = build_runtime_profile_played_world_id(user_id, world_key);
if let Some(existing) = ctx
.db
.profile_played_world()
@@ -785,15 +785,17 @@ fn sync_profile_dashboard_from_snapshot(
.as_ref()
.map(|row| row.total_play_time_ms)
.unwrap_or(0);
let next_wallet_balance =
read_non_negative_u64(game_state.and_then(|state| state.get("playerCurrency")));
let next_wallet_balance = module_runtime::read_runtime_json_non_negative_u64(
game_state.and_then(|state| state.get("playerCurrency")),
);
let mut next_total_play_time_ms = previous_total_play_time_ms;
if next_wallet_balance != previous_wallet_balance {
ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger {
wallet_ledger_id: format!(
"{}:{}:{}",
snapshot.user_id, snapshot.saved_at_micros, next_wallet_balance
wallet_ledger_id: build_runtime_profile_snapshot_wallet_ledger_id(
&snapshot.user_id,
snapshot.saved_at_micros,
next_wallet_balance,
),
user_id: snapshot.user_id.clone(),
amount_delta: next_wallet_balance as i64 - previous_wallet_balance as i64,
@@ -803,14 +805,17 @@ fn sync_profile_dashboard_from_snapshot(
});
}
if let Some(world_meta) = resolve_profile_world_snapshot_meta(game_state) {
let current_play_time_ms = read_non_negative_u64(
if let Some(world_meta) =
module_runtime::resolve_runtime_profile_world_snapshot_meta(game_state)
{
let current_play_time_ms = module_runtime::read_runtime_json_non_negative_u64(
game_state
.and_then(|state| state.get("runtimeStats"))
.and_then(JsonValue::as_object)
.and_then(|stats| stats.get("playTimeMs")),
);
let played_world_id = format!("{}:{}", snapshot.user_id, world_meta.world_key);
let played_world_id =
build_runtime_profile_played_world_id(&snapshot.user_id, &world_meta.world_key);
let existing = ctx
.db
.profile_played_world()
@@ -893,13 +898,15 @@ fn sync_profile_save_archive_from_snapshot(
game_state: &JsonValue,
saved_at: Timestamp,
) -> Result<(), String> {
let Some(archive_meta) =
resolve_profile_save_archive_meta(game_state, snapshot.current_story_json.as_deref())
else {
let Some(archive_meta) = module_runtime::resolve_runtime_profile_save_archive_meta(
game_state,
snapshot.current_story_json.as_deref(),
) else {
return Ok(());
};
let archive_id = format!("{}:{}", snapshot.user_id, archive_meta.world_key);
let archive_id =
build_runtime_profile_save_archive_id(&snapshot.user_id, &archive_meta.world_key);
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
let created_at = existing
.as_ref()
@@ -935,28 +942,6 @@ fn sync_profile_save_archive_from_snapshot(
Ok(())
}
#[derive(Clone, Debug)]
struct ProfileWorldSnapshotMeta {
world_key: String,
owner_user_id: Option<String>,
profile_id: Option<String>,
world_type: Option<String>,
world_title: String,
world_subtitle: String,
}
#[derive(Clone, Debug)]
struct ProfileSaveArchiveMeta {
world_key: String,
owner_user_id: Option<String>,
profile_id: Option<String>,
world_type: Option<String>,
world_name: String,
subtitle: String,
summary_text: String,
cover_image_src: Option<String>,
}
pub(crate) fn build_profile_save_archive_snapshot_from_row(
row: &ProfileSaveArchive,
) -> RuntimeProfileSaveArchiveSnapshot {
@@ -980,196 +965,6 @@ pub(crate) fn build_profile_save_archive_snapshot_from_row(
}
}
fn read_non_negative_u64(value: Option<&JsonValue>) -> u64 {
match value {
Some(JsonValue::Number(number)) => {
if let Some(raw) = number.as_u64() {
raw
} else if let Some(raw) = number.as_i64() {
raw.max(0) as u64
} else if let Some(raw) = number.as_f64() {
if raw.is_finite() && raw > 0.0 {
raw.floor() as u64
} else {
0
}
} else {
0
}
}
Some(JsonValue::String(raw)) => raw.trim().parse::<u64>().ok().unwrap_or(0),
_ => 0,
}
}
fn read_string_from_json(value: Option<&JsonValue>) -> Option<String> {
value
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn resolve_profile_world_snapshot_meta(
game_state: Option<&serde_json::Map<String, JsonValue>>,
) -> Option<ProfileWorldSnapshotMeta> {
let game_state = game_state?;
let custom_world_profile = game_state
.get("customWorldProfile")
.and_then(JsonValue::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let profile_id = read_string_from_json(custom_world_profile.get("id"));
let world_title = read_string_from_json(custom_world_profile.get("name"))
.or_else(|| read_string_from_json(custom_world_profile.get("title")));
if profile_id.is_some() || world_title.is_some() {
let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string());
return Some(ProfileWorldSnapshotMeta {
world_key: profile_id
.as_ref()
.map(|profile_id| format!("custom:{profile_id}"))
.unwrap_or_else(|| format!("custom:{world_title}")),
owner_user_id: None,
profile_id,
world_type: Some("CUSTOM".to_string()),
world_title,
world_subtitle: read_string_from_json(custom_world_profile.get("summary"))
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
.unwrap_or_default(),
});
}
}
let world_type = read_string_from_json(game_state.get("worldType"))?;
let current_scene_preset = game_state
.get("currentScenePreset")
.and_then(JsonValue::as_object);
Some(ProfileWorldSnapshotMeta {
world_key: format!("builtin:{world_type}"),
owner_user_id: None,
profile_id: None,
world_type: Some(world_type.clone()),
world_title: current_scene_preset
.and_then(|preset| read_string_from_json(preset.get("name")))
.unwrap_or_else(|| build_builtin_world_title(&world_type)),
world_subtitle: current_scene_preset
.and_then(|preset| {
read_string_from_json(preset.get("summary"))
.or_else(|| read_string_from_json(preset.get("description")))
})
.unwrap_or_default(),
})
}
fn resolve_profile_save_archive_meta(
game_state: &JsonValue,
current_story_json: Option<&str>,
) -> Option<ProfileSaveArchiveMeta> {
if is_non_persistent_runtime_snapshot(game_state) {
return None;
}
let game_state_object = game_state.as_object();
let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?;
let story_engine_memory = game_state_object
.and_then(|state| state.get("storyEngineMemory"))
.and_then(JsonValue::as_object);
let continue_game_digest = story_engine_memory
.and_then(|memory| read_string_from_json(memory.get("continueGameDigest")));
let current_story_text = parse_optional_json_str(current_story_json)
.ok()
.flatten()
.and_then(|story| story.as_object().cloned())
.and_then(|story| read_string_from_json(story.get("text")));
let custom_world_profile = game_state_object
.and_then(|state| state.get("customWorldProfile"))
.and_then(JsonValue::as_object);
if let Some(custom_world_profile) = custom_world_profile {
let world_name = read_string_from_json(custom_world_profile.get("name"))
.or_else(|| read_string_from_json(custom_world_profile.get("title")))
.unwrap_or_else(|| world_meta.world_title.clone());
let subtitle = read_string_from_json(custom_world_profile.get("summary"))
.or_else(|| read_string_from_json(custom_world_profile.get("settingText")))
.unwrap_or_else(|| world_meta.world_subtitle.clone());
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if subtitle.is_empty() {
None
} else {
Some(subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
return Some(ProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name,
subtitle,
summary_text,
cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")),
});
}
let summary_text = continue_game_digest
.or(current_story_text)
.or_else(|| {
if world_meta.world_subtitle.is_empty() {
None
} else {
Some(world_meta.world_subtitle.clone())
}
})
.unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string());
let current_scene_preset = game_state_object
.and_then(|state| state.get("currentScenePreset"))
.and_then(JsonValue::as_object);
Some(ProfileSaveArchiveMeta {
world_key: world_meta.world_key,
owner_user_id: world_meta.owner_user_id,
profile_id: world_meta.profile_id,
world_type: world_meta.world_type,
world_name: world_meta.world_title,
subtitle: world_meta.world_subtitle.clone(),
summary_text,
cover_image_src: current_scene_preset
.and_then(|preset| read_string_from_json(preset.get("imageSrc"))),
})
}
fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(JsonValue::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
read_string_from_json(game_state.get("runtimeMode")).as_deref(),
Some("preview") | Some("test")
)
}
fn build_builtin_world_title(world_type: &str) -> String {
match world_type {
"WUXIA" => "武侠世界".to_string(),
"XIANXIA" => "仙侠世界".to_string(),
_ => "叙事世界".to_string(),
}
}
fn get_profile_dashboard_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileDashboardGetInput,
@@ -1307,20 +1102,17 @@ fn create_profile_recharge_order_record(
let (points_delta, membership_expires_at) = match product.kind {
RuntimeProfileRechargeProductKind::Points => {
let has_recharged = has_profile_points_recharged(ctx, &validated_input.user_id);
let bonus_points = if has_recharged {
0
} else {
product.bonus_points
};
let points_delta = product.points_amount.saturating_add(bonus_points);
let points_delta =
resolve_runtime_profile_points_recharge_delta(&product, has_recharged);
apply_profile_wallet_delta(
ctx,
&validated_input.user_id,
points_delta,
RuntimeProfileWalletLedgerSourceType::PointsRecharge,
&format!(
"{}:{}:{}",
validated_input.user_id, validated_input.created_at_micros, product.product_id
&build_runtime_profile_recharge_wallet_ledger_id(
&validated_input.user_id,
validated_input.created_at_micros,
&product.product_id,
),
created_at,
)?;
@@ -1339,9 +1131,10 @@ fn create_profile_recharge_order_record(
};
let order = ProfileRechargeOrder {
order_id: format!(
"recharge:{}:{}:{}",
validated_input.user_id, validated_input.created_at_micros, product.product_id
order_id: build_runtime_profile_recharge_order_id(
&validated_input.user_id,
validated_input.created_at_micros,
&product.product_id,
),
user_id: validated_input.user_id.clone(),
product_id: product.product_id.clone(),
@@ -1416,25 +1209,25 @@ fn redeem_profile_referral_invite_code_record(
&invitee_user_id,
PROFILE_REFERRAL_REWARD_POINTS,
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward,
&format!(
"invitee:{}:{}",
invitee_user_id, validated_input.updated_at_micros
&build_runtime_profile_referral_invitee_ledger_id(
&invitee_user_id,
validated_input.updated_at_micros,
),
bound_at,
)?;
let today_inviter_reward_count =
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at);
let inviter_reward_granted =
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT;
should_grant_runtime_profile_inviter_reward(today_inviter_reward_count);
let inviter_balance_after = if inviter_reward_granted {
apply_profile_wallet_delta(
ctx,
&inviter_code.user_id,
PROFILE_REFERRAL_REWARD_POINTS,
RuntimeProfileWalletLedgerSourceType::InviteInviterReward,
&format!(
"inviter:{}:{}",
inviter_code.user_id, validated_input.updated_at_micros
&build_runtime_profile_referral_inviter_ledger_id(
&inviter_code.user_id,
validated_input.updated_at_micros,
),
bound_at,
)?
@@ -1482,45 +1275,21 @@ fn redeem_profile_reward_code_record(
.find(&code)
.ok_or_else(|| "兑换码不存在".to_string())?;
if !redeem_code.enabled {
return Err("兑换码已停用".to_string());
}
if redeem_code.reward_points == 0 {
return Err("兑换码奖励无效".to_string());
}
let user_used_count = count_profile_redeem_code_user_usage(ctx, &code, &user_id);
match redeem_code.mode {
RuntimeProfileRedeemCodeMode::Public if user_used_count >= redeem_code.max_uses => {
return Err("兑换次数已用完".to_string());
}
RuntimeProfileRedeemCodeMode::Unique
if redeem_code.global_used_count >= redeem_code.max_uses =>
{
return Err("兑换次数已用完".to_string());
}
RuntimeProfileRedeemCodeMode::Private => {
if !redeem_code
.allowed_user_ids
.iter()
.any(|item| item == &user_id)
{
return Err("该兑换码不适用于当前账号".to_string());
}
if redeem_code.global_used_count >= redeem_code.max_uses {
return Err("兑换次数已用完".to_string());
}
}
_ => {}
}
validate_runtime_profile_redeem_code_usage(
&build_profile_redeem_code_snapshot_from_row(&redeem_code),
&user_id,
user_used_count,
)
.map_err(|error| error.to_string())?;
let usage_id = build_profile_redeem_code_usage_id(
ctx,
let usage_id = build_runtime_profile_redeem_code_usage_id(
&code,
&user_id,
validated_input.redeemed_at_micros,
user_used_count,
);
let wallet_ledger_id = format!("{}:ledger", usage_id);
let wallet_ledger_id = build_runtime_profile_redeem_code_ledger_id(&usage_id);
let wallet_balance = apply_profile_wallet_delta(
ctx,
&user_id,
@@ -1669,7 +1438,7 @@ fn build_profile_referral_invite_center_snapshot(
RuntimeReferralInviteCenterSnapshot {
user_id: user_id.to_string(),
invite_code: code.invite_code.clone(),
invite_link_path: format!("/?inviteCode={}", code.invite_code),
invite_link_path: build_runtime_profile_invite_link_path(&code.invite_code),
invited_count,
rewarded_invite_count,
today_inviter_reward_count,
@@ -1697,7 +1466,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
return row;
}
let mut invite_code = build_profile_invite_code(user_id, 0);
let mut invite_code = build_runtime_profile_invite_code(user_id, 0);
let mut salt = 1;
while ctx
.db
@@ -1706,7 +1475,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
.find(&invite_code)
.is_some()
{
invite_code = build_profile_invite_code(user_id, salt);
invite_code = build_runtime_profile_invite_code(user_id, salt);
salt += 1;
}
@@ -1718,21 +1487,12 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
})
}
fn build_profile_invite_code(user_id: &str, salt: u32) -> String {
let mut hash = 14_695_981_039_346_656_037u64;
for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) {
hash ^= byte as u64;
hash = hash.wrapping_mul(1_099_511_628_211);
}
format!("SY{:08X}", hash as u32)
}
fn count_today_profile_referral_inviter_rewards(
ctx: &ReducerContext,
user_id: &str,
now: Timestamp,
) -> u32 {
let day_start_micros = (now.to_micros_since_unix_epoch() / 86_400_000_000) * 86_400_000_000;
let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch());
ctx.db
.profile_wallet_ledger()
.iter()
@@ -1831,24 +1591,25 @@ fn apply_profile_membership_purchase(
.user_id()
.find(&user_id.to_string());
let purchased_at_micros = purchased_at.to_micros_since_unix_epoch();
let start_at_micros = current
.as_ref()
.map(|row| row.expires_at.to_micros_since_unix_epoch())
.filter(|expires_at_micros| *expires_at_micros > purchased_at_micros)
.unwrap_or(purchased_at_micros);
let expires_at = Timestamp::from_micros_since_unix_epoch(
start_at_micros.saturating_add(duration_days as i64 * 86_400_000_000),
let purchase_update = resolve_runtime_profile_membership_purchase_update(
current
.as_ref()
.map(|row| row.started_at.to_micros_since_unix_epoch()),
current
.as_ref()
.map(|row| row.expires_at.to_micros_since_unix_epoch()),
purchased_at_micros,
duration_days,
);
let created_at = current
.as_ref()
.map(|row| row.started_at)
.unwrap_or(purchased_at);
let expires_at = Timestamp::from_micros_since_unix_epoch(purchase_update.expires_at_micros);
let created_at = Timestamp::from_micros_since_unix_epoch(purchase_update.started_at_micros);
let current = current.map(|row| row.user_id);
if let Some(existing) = current {
if let Some(existing_user_id) = current {
ctx.db
.profile_membership()
.user_id()
.delete(&existing.user_id);
.delete(&existing_user_id);
}
ctx.db.profile_membership().insert(ProfileMembership {
@@ -1871,8 +1632,8 @@ fn apply_profile_wallet_delta(
ledger_id: &str,
created_at: Timestamp,
) -> Result<u64, String> {
let amount_delta =
i64::try_from(amount_delta).map_err(|_| "profile.wallet_amount 超出上限".to_string())?;
let amount_delta = convert_runtime_profile_wallet_unsigned_delta(amount_delta)
.map_err(|error| error.to_string())?;
apply_profile_wallet_signed_delta(
ctx,
user_id,
@@ -1898,10 +1659,12 @@ fn apply_profile_wallet_adjustment(
)
.map_err(|error| error.to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let unsigned_delta = convert_runtime_profile_wallet_unsigned_delta(validated_input.amount)
.map_err(|error| error.to_string())?;
let amount_delta = if consume {
-(validated_input.amount as i64)
-unsigned_delta
} else {
validated_input.amount as i64
unsigned_delta
};
apply_profile_wallet_signed_delta(
@@ -1947,15 +1710,8 @@ fn apply_profile_wallet_signed_delta(
.user_id()
.find(&user_id.to_string());
let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0);
let next_balance = if amount_delta >= 0 {
previous_balance
.checked_add(amount_delta as u64)
.ok_or_else(|| "profile.wallet_balance 超出上限".to_string())?
} else {
previous_balance
.checked_sub(amount_delta.unsigned_abs())
.ok_or_else(|| "叙世币余额不足".to_string())?
};
let next_balance = calculate_runtime_profile_wallet_balance(previous_balance, amount_delta)
.map_err(|error| error.to_string())?;
let created_state_at = current
.as_ref()
.map(|row| row.created_at)
@@ -2034,19 +1790,6 @@ fn count_profile_redeem_code_user_usage(ctx: &ReducerContext, code: &str, user_i
.count() as u32
}
fn build_profile_redeem_code_usage_id(
ctx: &ReducerContext,
code: &str,
user_id: &str,
redeemed_at_micros: i64,
) -> String {
let sequence = count_profile_redeem_code_user_usage(ctx, code, user_id);
format!(
"redeem:{}:{}:{}:{}",
code, user_id, redeemed_at_micros, sequence
)
}
fn resolve_profile_redeem_code_allowed_user_ids(
ctx: &ReducerContext,
input: &RuntimeProfileRedeemCodeAdminUpsertInput,