This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -56,6 +56,7 @@ pub struct UserAccount {
pub(crate) public_user_code: String,
pub(crate) username: String,
pub(crate) display_name: String,
pub(crate) avatar_url: Option<String>,
pub(crate) phone_number_masked: Option<String>,
pub(crate) phone_number_e164: Option<String>,
pub(crate) login_method: String,
@@ -256,6 +257,7 @@ fn import_auth_store_snapshot_tx(
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number_masked: user.phone_number_masked,
phone_number_e164: stored_user.phone_number.clone(),
login_method: user.login_method,
@@ -387,6 +389,7 @@ fn export_auth_store_snapshot_from_tables_tx(
public_user_code: user.public_user_code,
username: user.username.clone(),
display_name: user.display_name,
avatar_url: user.avatar_url,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method,
binding_status: user.binding_status,
@@ -519,6 +522,8 @@ struct AuthUserSnapshot {
public_user_code: String,
username: String,
display_name: String,
#[serde(default)]
avatar_url: Option<String>,
phone_number_masked: Option<String>,
login_method: String,
binding_status: String,

View File

@@ -1,6 +1,7 @@
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
use crate::runtime::{
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
};
use crate::*;
@@ -288,6 +289,7 @@ pub(crate) fn list_big_fish_works_tx(
input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_works_list_input(&input).map_err(|error| error.to_string())?;
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut items = ctx
.db
@@ -300,7 +302,7 @@ pub(crate) fn list_big_fish_works_tx(
row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row)
})
.map(|row| build_big_fish_work_summary(ctx, &row))
.map(|row| build_big_fish_work_summary(ctx, &row, now_micros))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| {
@@ -676,6 +678,15 @@ pub(crate) fn record_big_fish_play_tx(
input.elapsed_ms,
input.played_at_micros,
)?;
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "big-fish".to_string(),
owner_user_id: session.owner_user_id.clone(),
profile_id: session.session_id.clone(),
played_at_micros: input.played_at_micros,
},
)?;
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
@@ -698,13 +709,7 @@ pub(crate) fn record_big_fish_play_tx(
};
replace_big_fish_session(ctx, &session, next_session);
list_big_fish_works_tx(
ctx,
BigFishWorksListInput {
owner_user_id: String::new(),
published_only: true,
},
)
list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input())
}
fn remix_big_fish_work_tx(
@@ -876,6 +881,7 @@ pub(crate) fn build_big_fish_session_snapshot(
pub(crate) fn build_big_fish_work_summary(
ctx: &ReducerContext,
row: &BigFishCreationSession,
now_micros: i64,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
@@ -940,6 +946,12 @@ pub(crate) fn build_big_fish_work_summary(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: count_recent_public_work_plays(
ctx,
"big-fish",
&row.session_id,
now_micros,
),
published_at_micros: row
.published_at
.or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at))
@@ -947,6 +959,14 @@ pub(crate) fn build_big_fish_work_summary(
})
}
fn build_public_big_fish_gallery_list_input() -> BigFishWorksListInput {
BigFishWorksListInput {
// 中文注释published_only 分支不会按 owner 过滤;非空占位用于兼容旧部署模块的前置校验。
owner_user_id: PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.to_string(),
published_only: true,
}
}
pub(crate) fn replace_big_fish_session(
ctx: &ReducerContext,
current: &BigFishCreationSession,

View File

@@ -17,10 +17,40 @@ pub struct BigFishCreationSession {
pub(crate) asset_coverage_json: String,
pub(crate) last_assistant_reply: Option<String>,
pub(crate) publish_ready: bool,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
#[default(0)]
pub(crate) play_count: u32,
#[default(0)]
pub(crate) remix_count: u32,
#[default(0)]
pub(crate) like_count: u32,
#[default(None::<Timestamp>)]
pub(crate) published_at: Option<Timestamp>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)]
pub enum BigFishRuntimeSnapshot {
Running,
Won,
Failed,
}
#[spacetimedb::table(
accessor = big_fish_runtime_run,
index(accessor = by_big_fish_run_session_id, btree(columns = [session_id])),
index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_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: BigFishRuntimeSnapshot,
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,
}

View File

@@ -2344,7 +2344,7 @@ fn execute_publish_world_action(
draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?,
legacy_result_profile_json,
setting_text,
author_display_name: "创作者".to_string(),
author_display_name: "陶泥主".to_string(),
published_at_micros: input.submitted_at_micros,
},
)?;

View File

@@ -325,7 +325,7 @@ pub struct CustomWorldProfile {
owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
// 作者公开叙世号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,
@@ -337,16 +337,19 @@ pub struct CustomWorldProfile {
profile_payload_json: String,
playable_npc_count: u32,
landmark_count: u32,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
play_count: u32,
remix_count: u32,
like_count: u32,
author_display_name: String,
published_at: Option<Timestamp>,
// 软删除后保留 profile 真相,供审计与幂等删除使用。
deleted_at: Option<Timestamp>,
created_at: Timestamp,
updated_at: Timestamp,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
#[default(0)]
play_count: u32,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
#[spacetimedb::table(
@@ -488,12 +491,15 @@ pub struct CustomWorldGalleryEntry {
theme_mode: CustomWorldThemeMode,
playable_npc_count: u32,
landmark_count: u32,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
play_count: u32,
remix_count: u32,
like_count: u32,
published_at: Timestamp,
updated_at: Timestamp,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
#[default(0)]
play_count: u32,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
// 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。
@@ -2839,9 +2845,10 @@ fn list_custom_world_profile_snapshots(
let mut entries = ctx
.db
.custom_world_profile()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
.map(|row| build_custom_world_profile_snapshot(&row))
.by_custom_world_profile_owner_user_id()
.filter(&input.owner_user_id)
.filter(|row| row.deleted_at.is_none())
.map(|row| build_custom_world_profile_list_snapshot(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
@@ -2849,6 +2856,86 @@ fn list_custom_world_profile_snapshots(
Ok(entries)
}
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
let mut snapshot = build_custom_world_profile_snapshot(row);
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
snapshot
}
fn build_custom_world_profile_list_payload_json(row: &CustomWorldProfile) -> String {
let source_profile = serde_json::from_str::<JsonValue>(&row.profile_payload_json).ok();
let source_object = source_profile.as_ref().and_then(JsonValue::as_object);
let empty_roles = JsonValue::Array(Vec::new());
let empty_landmarks = JsonValue::Array(Vec::new());
// 中文注释:首屏作品列表只需要卡片摘要,不能继续把完整 profile 大 JSON 随列表搬回 Axum。
let payload = json!({
"id": row.profile_id,
"name": row.world_name,
"subtitle": row.subtitle,
"summary": row.summary_text,
"tone": source_object
.and_then(|object| object.get("tone"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"playerGoal": source_object
.and_then(|object| object.get("playerGoal"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"settingText": source_object
.and_then(|object| object.get("settingText"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"themeMode": row.theme_mode.as_str(),
"templateWorldType": source_object
.and_then(|object| object.get("templateWorldType"))
.and_then(JsonValue::as_str)
.unwrap_or("WUXIA"),
"compatibilityTemplateWorldType": source_object
.and_then(|object| object.get("compatibilityTemplateWorldType"))
.cloned()
.unwrap_or(JsonValue::Null),
"cover": row.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": source_object
.and_then(|object| object.get("majorFactions"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"coreConflicts": source_object
.and_then(|object| object.get("coreConflicts"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"playableNpcs": source_object
.and_then(|object| object.get("playableNpcs"))
.cloned()
.unwrap_or_else(|| empty_roles.clone()),
"storyNpcs": source_object
.and_then(|object| object.get("storyNpcs"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"items": source_object
.and_then(|object| object.get("items"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"camp": source_object
.and_then(|object| object.get("camp"))
.cloned()
.unwrap_or(JsonValue::Null),
"landmarks": source_object
.and_then(|object| object.get("landmarks"))
.cloned()
.unwrap_or_else(|| empty_landmarks.clone()),
"ownedSettingLayers": source_object
.and_then(|object| object.get("ownedSettingLayers"))
.cloned()
.unwrap_or(JsonValue::Null),
});
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string())
}
fn list_custom_world_gallery_snapshots(
ctx: &ReducerContext,
) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
@@ -2858,7 +2945,7 @@ fn list_custom_world_gallery_snapshots(
.db
.custom_world_gallery_entry()
.iter()
.map(|row| build_custom_world_gallery_entry_snapshot(&row))
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
@@ -2905,7 +2992,7 @@ fn get_custom_world_library_detail_record(
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(build_custom_world_gallery_entry_snapshot),
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
@@ -2943,7 +3030,7 @@ fn get_custom_world_gallery_detail_record(
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(build_custom_world_gallery_entry_snapshot),
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
@@ -2985,7 +3072,7 @@ fn get_custom_world_gallery_detail_record_by_code(
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(build_custom_world_gallery_entry_snapshot),
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
@@ -3123,6 +3210,15 @@ fn record_custom_world_profile_play_record(
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
record_public_work_play(
ctx,
crate::runtime::PublicWorkPlayRecordInput {
source_type: "custom-world".to_string(),
owner_user_id: existing.owner_user_id.clone(),
profile_id: existing.profile_id.clone(),
played_at_micros: input.played_at_micros,
},
)?;
// 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
@@ -3790,7 +3886,7 @@ fn execute_publish_world_action(
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
.unwrap_or_else(|| "创作者".to_string());
.unwrap_or_else(|| "陶泥主".to_string());
let publish_result = publish_custom_world_world_record(
ctx,
CustomWorldPublishWorldInput {
@@ -5299,7 +5395,7 @@ fn sync_custom_world_gallery_entry_from_profile(
let inserted = ctx.db.custom_world_gallery_entry().insert(row);
Ok(build_custom_world_gallery_entry_snapshot(&inserted))
Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted))
}
fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> {
@@ -5570,6 +5666,7 @@ fn build_custom_world_draft_card_snapshot(
}
fn build_custom_world_gallery_entry_snapshot(
ctx: &ReducerContext,
row: &CustomWorldGalleryEntry,
) -> CustomWorldGalleryEntrySnapshot {
CustomWorldGalleryEntrySnapshot {
@@ -5588,6 +5685,12 @@ fn build_custom_world_gallery_entry_snapshot(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: count_recent_public_work_plays(
ctx,
"custom-world",
&row.profile_id,
ctx.timestamp.to_micros_since_unix_epoch(),
),
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}

View File

@@ -122,6 +122,7 @@ macro_rules! migration_tables {
profile_invite_code,
profile_referral_relation,
profile_played_world,
public_work_play_daily_stat,
profile_membership,
profile_recharge_order,
profile_save_archive,
@@ -742,6 +743,14 @@ where
fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde_json::Value {
let mut next_value = value.clone();
if table_name == "user_account" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:头像字段晚于认证拆表加入,旧迁移包按未设置头像兼容。
object
.entry("avatar_url".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。

View File

@@ -1,5 +1,6 @@
use crate::runtime::{
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
};
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
@@ -8,13 +9,14 @@ use module_puzzle::{
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput,
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot,
PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -77,13 +79,15 @@ pub struct PuzzleWorkProfileRow {
cover_asset_id: Option<String>,
publication_status: PuzzlePublicationStatus,
play_count: u32,
remix_count: u32,
like_count: u32,
anchor_pack_json: String,
publish_ready: bool,
created_at: Timestamp,
updated_at: Timestamp,
published_at: Option<Timestamp>,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
/// 运行态 run 快照表。
@@ -503,6 +507,44 @@ pub fn advance_puzzle_next_level(
}
}
#[spacetimedb::procedure]
pub fn update_puzzle_run_pause(
ctx: &mut ProcedureContext,
input: PuzzleRunPauseInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn use_puzzle_runtime_prop(
ctx: &mut ProcedureContext,
input: PuzzleRunPropInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_puzzle_leaderboard_entry(
ctx: &mut ProcedureContext,
@@ -755,7 +797,7 @@ fn save_puzzle_generated_images_tx(
}
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -804,7 +846,7 @@ fn select_puzzle_cover_image_tx(
let draft =
apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?;
let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros);
let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -1029,12 +1071,13 @@ fn delete_puzzle_work_tx(
}
fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut items = ctx
.db
.puzzle_work_profile()
.iter()
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.map(|row| build_puzzle_work_profile_from_row(&row))
.map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(items)
@@ -1053,7 +1096,11 @@ fn get_puzzle_gallery_detail_tx(
if row.publication_status != PuzzlePublicationStatus::Published {
return Err("拼图作品尚未发布".to_string());
}
build_puzzle_work_profile_from_row(&row)
build_puzzle_work_profile_from_row_with_recent_count(
ctx,
&row,
ctx.timestamp.to_micros_since_unix_epoch(),
)
}
fn remix_puzzle_work_tx(
@@ -1213,8 +1260,9 @@ fn start_puzzle_run_tx(
return Err("入口拼图作品未发布".to_string());
}
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
let mut run =
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
let started_at_ms = micros_to_millis(input.started_at_micros);
let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
.map_err(|error| error.to_string())?;
let current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
@@ -1231,6 +1279,15 @@ fn start_puzzle_run_tx(
)
.map(|value| value.profile_id.clone());
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: entry_profile_row.owner_user_id.clone(),
profile_id: entry_profile_row.profile_id.clone(),
played_at_micros: input.started_at_micros,
},
)?;
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
upsert_puzzle_profile_played_work(
ctx,
@@ -1247,7 +1304,14 @@ fn get_puzzle_run_tx(
input: PuzzleRunGetInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let mut run = deserialize_run(&row.snapshot_json)?;
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut run = module_puzzle::resolve_puzzle_run_timer_at(
deserialize_run(&row.snapshot_json)?,
micros_to_millis(now_micros),
);
if serialize_json(&run) != row.snapshot_json {
replace_puzzle_runtime_run(ctx, &row, &run, now_micros);
}
if let Some((profile_id, grid_size)) = run
.current_level
.as_ref()
@@ -1270,9 +1334,27 @@ fn swap_puzzle_pieces_tx(
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let mut next_run = swap_pieces(&current_run, &input.first_piece_id, &input.second_piece_id)
.map_err(|error| error.to_string())?;
let mut next_run = module_puzzle::swap_pieces_at(
&current_run,
&input.first_piece_id,
&input.second_piece_id,
micros_to_millis(input.swapped_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros);
Ok(next_run)
}
@@ -1283,14 +1365,28 @@ fn drag_puzzle_piece_or_group_tx(
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let mut next_run = module_puzzle::drag_piece_or_group(
let mut next_run = module_puzzle::drag_piece_or_group_at(
&current_run,
&input.piece_id,
input.target_row,
input.target_col,
micros_to_millis(input.dragged_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.dragged_at_micros);
Ok(next_run)
}
@@ -1323,8 +1419,12 @@ fn advance_puzzle_next_level_tx(
)
.ok_or_else(|| "没有可用的下一关候选".to_string())?
.clone();
let mut next_run = module_puzzle::advance_next_level(&current_run, &next_profile)
.map_err(|error| error.to_string())?;
let mut next_run = module_puzzle::advance_next_level_at(
&current_run,
&next_profile,
micros_to_millis(input.advanced_at_micros),
)
.map_err(|error| error.to_string())?;
let next_grid_size = next_run.current_grid_size;
let next_profile_id = next_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
@@ -1344,6 +1444,15 @@ fn advance_puzzle_next_level_tx(
.profile_id()
.find(&next_profile.profile_id)
{
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: next_profile_row.owner_user_id.clone(),
profile_id: next_profile_row.profile_id.clone(),
played_at_micros: input.advanced_at_micros,
},
)?;
increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros);
upsert_puzzle_profile_played_work(
ctx,
@@ -1356,6 +1465,82 @@ fn advance_puzzle_next_level_tx(
Ok(next_run)
}
fn update_puzzle_run_pause_tx(
ctx: &TxContext,
input: PuzzleRunPauseInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = module_puzzle::set_puzzle_run_paused_at(
&current_run,
input.paused,
micros_to_millis(input.updated_at_micros),
)
.map_err(|error| error.to_string())?;
replace_puzzle_runtime_run(ctx, &row, &next_run, input.updated_at_micros);
let mut hydrated_run = next_run;
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut hydrated_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(hydrated_run)
}
fn use_puzzle_runtime_prop_tx(
ctx: &TxContext,
input: PuzzleRunPropInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = match input.prop_kind.as_str() {
"freezeTime" | "freeze_time" => {
module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?
}
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
false,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"reference" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
true,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
_ => return Err("未知拼图道具".to_string()),
};
replace_puzzle_runtime_run(ctx, &row, &next_run, input.used_at_micros);
let mut hydrated_run = next_run;
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut hydrated_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(hydrated_run)
}
fn submit_puzzle_leaderboard_entry_tx(
ctx: &TxContext,
input: PuzzleLeaderboardSubmitInput,
@@ -1424,7 +1609,7 @@ fn build_puzzle_agent_session_snapshot(
let messages = list_session_messages(ctx, &row.session_id);
let result_preview = draft
.as_ref()
.map(|value| build_result_preview(value, Some("创作者")));
.map(|value| build_result_preview(value, Some("陶泥主")));
Ok(PuzzleAgentSessionSnapshot {
session_id: row.session_id.clone(),
@@ -1447,6 +1632,23 @@ fn build_puzzle_agent_session_snapshot(
fn build_puzzle_work_profile_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
build_puzzle_work_profile_from_row_without_recent_count(row)
}
fn build_puzzle_work_profile_from_row_with_recent_count(
ctx: &TxContext,
row: &PuzzleWorkProfileRow,
now_micros: i64,
) -> Result<PuzzleWorkProfile, String> {
let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?;
profile.recent_play_count_7d =
count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros);
Ok(profile)
}
fn build_puzzle_work_profile_from_row_without_recent_count(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
Ok(PuzzleWorkProfile {
work_id: row.work_id.clone(),
@@ -1467,6 +1669,7 @@ fn build_puzzle_work_profile_from_row(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: 0,
publish_ready: row.publish_ready,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
})
@@ -1482,6 +1685,13 @@ fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) {
)
}
fn micros_to_millis(value: i64) -> u64 {
if value <= 0 {
return 0;
}
(value as u64).saturating_div(1_000)
}
fn upsert_puzzle_draft_work_profile(
ctx: &TxContext,
session_id: &str,
@@ -1500,7 +1710,7 @@ fn upsert_puzzle_draft_work_profile(
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
"创作者".to_string(),
"陶泥主".to_string(),
draft,
updated_at_micros,
)
@@ -2095,6 +2305,9 @@ mod tests {
updated_at_micros: 1,
published_at_micros: Some(1),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
};
@@ -2110,6 +2323,9 @@ mod tests {
updated_at_micros: 2,
published_at_micros: Some(2),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
source_session_id: None,

View File

@@ -1,5 +1,8 @@
use crate::*;
const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
#[spacetimedb::table(accessor = profile_dashboard_state)]
pub struct ProfileDashboardState {
#[primary_key]
@@ -116,6 +119,26 @@ pub struct ProfilePlayedWorld {
pub(crate) last_observed_play_time_ms: u64,
}
#[spacetimedb::table(
accessor = public_work_play_daily_stat,
index(
accessor = by_public_work_play_daily_stat_work_day,
btree(columns = [source_type, profile_id, played_day])
)
)]
pub struct PublicWorkPlayDailyStat {
#[primary_key]
pub(crate) stat_id: String,
// 中文注释source_type 区分 custom-world / puzzle / big-fish避免不同玩法 profile_id 撞桶。
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
// 中文注释UTC 自 Unix 纪元起的自然日桶,用于快速聚合近 7 日新增游玩次数。
pub(crate) played_day: i64,
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
}
pub(crate) struct ProfilePlayedWorkUpsertInput {
pub(crate) user_id: String,
pub(crate) world_key: String,
@@ -127,6 +150,13 @@ pub(crate) struct ProfilePlayedWorkUpsertInput {
pub(crate) played_at_micros: i64,
}
pub(crate) struct PublicWorkPlayRecordInput {
pub(crate) source_type: String,
pub(crate) owner_user_id: String,
pub(crate) profile_id: String,
pub(crate) played_at_micros: i64,
}
#[spacetimedb::table(accessor = profile_membership)]
pub struct ProfileMembership {
#[primary_key]
@@ -420,7 +450,7 @@ pub fn get_profile_referral_invite_center(
}
}
// 填码绑定、每日邀请者奖励上限和双方叙世币发放都在同一事务内完成。
// 填码绑定、每日邀请者奖励上限和双方陶泥币发放都在同一事务内完成。
#[spacetimedb::procedure]
pub fn redeem_profile_referral_invite_code(
ctx: &mut ProcedureContext,
@@ -705,6 +735,88 @@ pub(crate) fn add_profile_observed_play_time(
Ok(())
}
pub(crate) fn record_public_work_play(
ctx: &ReducerContext,
input: PublicWorkPlayRecordInput,
) -> Result<(), String> {
let source_type = input.source_type.trim();
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
if source_type.is_empty() || owner_user_id.is_empty() || profile_id.is_empty() {
return Err("public_work_play_daily_stat 参数不能为空".to_string());
}
let played_day = public_work_play_day_from_micros(input.played_at_micros);
let stat_id = build_public_work_play_daily_stat_id(source_type, profile_id, played_day);
let updated_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
let next_count = ctx
.db
.public_work_play_daily_stat()
.stat_id()
.find(&stat_id)
.map(|existing| {
ctx.db
.public_work_play_daily_stat()
.stat_id()
.delete(&existing.stat_id);
existing.play_count.saturating_add(1)
})
.unwrap_or(1);
ctx.db
.public_work_play_daily_stat()
.insert(PublicWorkPlayDailyStat {
stat_id,
source_type: source_type.to_string(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
played_day,
play_count: next_count,
updated_at,
});
Ok(())
}
pub(crate) fn count_recent_public_work_plays(
ctx: &ReducerContext,
source_type: &str,
profile_id: &str,
now_micros: i64,
) -> u32 {
let source_type = source_type.trim();
let profile_id = profile_id.trim();
if source_type.is_empty() || profile_id.is_empty() {
return 0;
}
let current_day = public_work_play_day_from_micros(now_micros);
let first_day = current_day.saturating_sub(PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1);
ctx.db
.public_work_play_daily_stat()
.iter()
.filter(|row| {
row.source_type == source_type
&& row.profile_id == profile_id
&& row.played_day >= first_day
&& row.played_day <= current_day
})
.fold(0u32, |total, row| total.saturating_add(row.play_count))
}
fn public_work_play_day_from_micros(value: i64) -> i64 {
value.div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS)
}
fn build_public_work_play_daily_stat_id(
source_type: &str,
profile_id: &str,
played_day: i64,
) -> String {
format!("{source_type}:{profile_id}:{played_day}")
}
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
if ctx
.db
@@ -1954,7 +2066,7 @@ fn apply_profile_wallet_signed_delta(
} else {
previous_balance
.checked_sub(amount_delta.unsigned_abs())
.ok_or_else(|| "叙世币余额不足".to_string())?
.ok_or_else(|| "陶泥币余额不足".to_string())?
};
let created_state_at = current
.as_ref()