merge: master into codex/bark-battle

This commit is contained in:
kdletters
2026-05-19 17:04:32 +08:00
307 changed files with 40711 additions and 26022 deletions

View File

@@ -33,8 +33,8 @@ pub(crate) fn build_ai_task_snapshot_from_row(
let mut stages = ctx
.db
.ai_task_stage()
.iter()
.filter(|stage| stage.task_id == row.task_id)
.by_ai_task_stage_task_id()
.filter(&row.task_id)
.map(|stage| build_ai_task_stage_snapshot_from_row(&stage))
.collect::<Vec<_>>();
stages.sort_by_key(|stage| stage.order);
@@ -42,8 +42,8 @@ pub(crate) fn build_ai_task_snapshot_from_row(
let mut result_references = ctx
.db
.ai_result_reference()
.iter()
.filter(|reference| reference.task_id == row.task_id)
.by_ai_result_reference_task_id()
.filter(&row.task_id)
.map(|reference| build_ai_result_reference_snapshot_from_row(&reference))
.collect::<Vec<_>>();
result_references.sort_by_key(|reference| reference.created_at_micros);

View File

@@ -318,8 +318,8 @@ pub(crate) fn replace_ai_task_stages(
let stage_ids = ctx
.db
.ai_task_stage()
.iter()
.filter(|row| row.task_id == task_id)
.by_ai_task_stage_task_id()
.filter(task_id)
.map(|row| row.task_stage_id.clone())
.collect::<Vec<_>>();
for stage_id in stage_ids {
@@ -341,7 +341,8 @@ pub(crate) fn collect_ai_stage_text_output(
let mut chunks = ctx
.db
.ai_text_chunk()
.iter()
.by_ai_text_chunk_task_id()
.filter(task_id)
.filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)
.map(|row| build_ai_text_chunk_snapshot_from_row(&row))
.collect::<Vec<_>>();

View File

@@ -66,12 +66,16 @@ fn upsert_asset_entity_binding(
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
}
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
let current = ctx.db.asset_entity_binding().iter().find(|row| {
row.entity_kind == input.entity_kind
&& row.entity_id == input.entity_id
&& row.slot == input.slot
});
let current = ctx
.db
.asset_entity_binding()
.by_entity_slot()
.filter((
input.entity_kind.as_str(),
input.entity_id.as_str(),
input.slot.as_str(),
))
.next();
let snapshot = match current {
Some(existing) => {

View File

@@ -128,12 +128,12 @@ pub(crate) fn upsert_asset_object(
)
.map_err(|error| error.to_string())?;
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
let current = ctx
.db
.asset_object()
.iter()
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
.by_bucket_object_key()
.filter((input.bucket.as_str(), input.object_key.as_str()))
.next();
let snapshot = match current {
Some(existing) => {
@@ -196,8 +196,9 @@ pub(crate) fn upsert_asset_object(
pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> bool {
ctx.db
.asset_object()
.iter()
.any(|row| row.asset_object_id == asset_object_id)
.asset_object_id()
.find(&asset_object_id.to_string())
.is_some()
}
fn list_asset_history(
@@ -224,8 +225,8 @@ fn list_asset_history(
let mut entries = ctx
.db
.asset_object()
.iter()
.filter(|row| row.asset_kind == asset_kind)
.asset_kind()
.filter(&asset_kind.to_string())
.map(|row| AssetHistoryEntrySnapshot {
asset_object_id: row.asset_object_id,
asset_kind: row.asset_kind,

View File

@@ -1,6 +1,5 @@
use crate::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde::{Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256};
pub(crate) mod tables;
@@ -15,7 +14,7 @@ pub fn create_bark_battle_draft(
input: BarkBattleDraftCreateInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| create_bark_battle_draft_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_draft_config_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -26,7 +25,7 @@ pub fn update_bark_battle_draft_config(
input: BarkBattleDraftConfigUpsertInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| update_bark_battle_draft_config_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_draft_config_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -37,7 +36,7 @@ pub fn publish_bark_battle_work(
input: BarkBattleWorkPublishInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| publish_bark_battle_work_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_runtime_config_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -48,7 +47,7 @@ pub fn get_bark_battle_runtime_config(
input: BarkBattleRuntimeConfigGetInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| get_bark_battle_runtime_config_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_runtime_config_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -59,7 +58,7 @@ pub fn start_bark_battle_run(
input: BarkBattleRunStartInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| start_bark_battle_run_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_run_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -70,7 +69,7 @@ pub fn finish_bark_battle_run(
input: BarkBattleRunFinishInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| finish_bark_battle_run_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_run_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -81,7 +80,7 @@ pub fn get_bark_battle_run(
input: BarkBattleRunGetInput,
) -> BarkBattleProcedureResult {
match ctx.try_with_tx(|tx| get_bark_battle_run_tx(tx, input.clone())) {
Ok(snapshot) => bark_battle_json_result(&snapshot),
Ok(snapshot) => bark_battle_run_result(snapshot),
Err(error) => bark_battle_error_result(error),
}
}
@@ -619,10 +618,36 @@ fn validate_json<T: DeserializeOwned>(value: &str, field_name: &str) -> Result<(
.map_err(|error| format!("bark_battle {field_name} JSON 无效: {error}"))
}
fn bark_battle_json_result<T: Serialize>(value: &T) -> BarkBattleProcedureResult {
fn bark_battle_draft_config_result(
draft_config: BarkBattleDraftConfigSnapshot,
) -> BarkBattleProcedureResult {
BarkBattleProcedureResult {
ok: true,
row_json: Some(to_json_string(value)),
draft_config: Some(draft_config),
runtime_config: None,
run: None,
error_message: None,
}
}
fn bark_battle_runtime_config_result(
runtime_config: BarkBattleRuntimeConfigSnapshot,
) -> BarkBattleProcedureResult {
BarkBattleProcedureResult {
ok: true,
draft_config: None,
runtime_config: Some(runtime_config),
run: None,
error_message: None,
}
}
fn bark_battle_run_result(run: BarkBattleRunSnapshot) -> BarkBattleProcedureResult {
BarkBattleProcedureResult {
ok: true,
draft_config: None,
runtime_config: None,
run: Some(run),
error_message: None,
}
}
@@ -630,7 +655,9 @@ fn bark_battle_json_result<T: Serialize>(value: &T) -> BarkBattleProcedureResult
fn bark_battle_error_result(error: String) -> BarkBattleProcedureResult {
BarkBattleProcedureResult {
ok: false,
row_json: None,
draft_config: None,
runtime_config: None,
run: None,
error_message: Some(error),
}
}
@@ -885,7 +912,21 @@ mod tests {
let result = BarkBattleProcedureResult {
ok: true,
row_json: Some(input.config_json.clone()),
draft_config: Some(BarkBattleDraftConfigSnapshot {
draft_id: input.draft_id.clone(),
owner_user_id: input.owner_user_id.clone(),
work_id: input.work_id.clone(),
config_version: input.config_version,
ruleset_version: input.ruleset_version.clone(),
difficulty_preset: input.difficulty_preset.clone(),
leaderboard_enabled: input.leaderboard_enabled,
config_json: input.config_json.clone(),
editor_state_json: "{}".to_string(),
created_at_micros: 1_700_000,
updated_at_micros: input.updated_at_micros,
}),
runtime_config: None,
run: None,
error_message: None,
};

View File

@@ -102,14 +102,16 @@ pub struct BarkBattleRunGetInput {
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct BarkBattleProcedureResult {
pub ok: bool,
pub row_json: Option<String>,
pub draft_config: Option<BarkBattleDraftConfigSnapshot>,
pub runtime_config: Option<BarkBattleRuntimeConfigSnapshot>,
pub run: Option<BarkBattleRunSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleEditorConfigSnapshot {
pub title: String,
@@ -129,7 +131,7 @@ pub struct BarkBattleEditorConfigSnapshot {
pub leaderboard_enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleDraftConfigSnapshot {
pub draft_id: String,
@@ -145,7 +147,7 @@ pub struct BarkBattleDraftConfigSnapshot {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleRuntimeConfigSnapshot {
pub work_id: String,
@@ -161,7 +163,7 @@ pub struct BarkBattleRuntimeConfigSnapshot {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleRunSnapshot {
pub run_id: String,

View File

@@ -222,8 +222,8 @@ pub(crate) fn list_big_fish_asset_slots(
let mut slots = ctx
.db
.big_fish_asset_slot()
.iter()
.filter(|slot| slot.session_id == session_id)
.by_big_fish_asset_session_id()
.filter(&session_id.to_string())
.map(|slot| BigFishAssetSlotSnapshot {
slot_id: slot.slot_id,
session_id: slot.session_id,

View File

@@ -16,12 +16,12 @@ pub fn start_big_fish_run(
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)),
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -35,12 +35,12 @@ pub fn get_big_fish_run(
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)),
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -54,12 +54,12 @@ pub fn submit_big_fish_input(
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)),
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -225,7 +225,3 @@ fn replace_big_fish_runtime_run(
});
Ok(())
}
fn serialize_big_fish_run_json(run: &BigFishRuntimeSnapshot) -> String {
serialize_runtime_snapshot(run).unwrap_or_else(|_| "{}".to_string())
}

View File

@@ -8,9 +8,42 @@ use crate::runtime::{
};
use crate::*;
use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness};
use spacetimedb::AnonymousViewContext;
const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0;
/// 大鱼吃小鱼公开广场列表投影。
///
/// 公开列表从已发布 creation session 生成卡片字段7 日播放数由
/// `api-server` 订阅 `public_work_play_daily_stat` 后在本地聚合。
#[spacetimedb::view(accessor = big_fish_gallery_view, public)]
pub fn big_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec<BigFishWorkSummarySnapshot> {
let mut items = ctx
.db
.big_fish_creation_session()
.by_big_fish_session_stage()
.filter(BigFishCreationStage::Published)
.filter_map(|row| match build_big_fish_gallery_view_row(ctx, &row) {
Ok(snapshot) => Some(snapshot),
Err(error) => {
log::warn!(
"大鱼吃小鱼公开广场 view 跳过损坏的作品投影 session_id={}: {}",
row.session_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.source_session_id.cmp(&right.source_session_id))
});
items
}
#[spacetimedb::procedure]
pub fn create_big_fish_session(
ctx: &mut ProcedureContext,
@@ -55,21 +88,14 @@ pub fn list_big_fish_works(
input: BigFishWorksListInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| list_big_fish_works_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
Ok(items) => BigFishWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -81,21 +107,14 @@ pub fn delete_big_fish_work(
input: BigFishWorkDeleteInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| delete_big_fish_work_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
Ok(items) => BigFishWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -107,21 +126,14 @@ pub fn record_big_fish_play(
input: BigFishPlayRecordInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
Ok(items) => BigFishWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -133,21 +145,14 @@ pub fn record_big_fish_like(
input: BigFishWorkLikeRecordInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| record_big_fish_like_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
Ok(items) => BigFishWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -321,16 +326,20 @@ pub(crate) fn list_big_fish_works_tx(
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
let rows = ctx
.db
.big_fish_creation_session()
.by_big_fish_session_owner_user_id()
.filter(&input.owner_user_id)
.collect::<Vec<_>>();
let mut items = rows
.iter()
.filter(|row| {
if input.published_only {
return row.stage == BigFishCreationStage::Published;
}
row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row)
should_include_big_fish_work(ctx, row)
})
.map(|row| build_big_fish_work_summary(ctx, &row, now_micros))
.collect::<Result<Vec<_>, _>>()?;
@@ -349,10 +358,11 @@ fn should_include_big_fish_work(ctx: &ReducerContext, row: &BigFishCreationSessi
return true;
}
ctx.db.big_fish_agent_message().iter().any(|message| {
message.session_id == row.session_id
&& matches!(message.role, BigFishAgentMessageRole::User)
})
ctx.db
.big_fish_agent_message()
.by_big_fish_message_session_id()
.filter(&row.session_id)
.any(|message| matches!(message.role, BigFishAgentMessageRole::User))
}
fn big_fish_session_has_direct_work_content(row: &BigFishCreationSession) -> bool {
@@ -387,8 +397,8 @@ pub(crate) fn delete_big_fish_work_tx(
for message in ctx
.db
.big_fish_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.by_big_fish_message_session_id()
.filter(&input.session_id)
.collect::<Vec<_>>()
{
ctx.db
@@ -399,8 +409,8 @@ pub(crate) fn delete_big_fish_work_tx(
for slot in ctx
.db
.big_fish_asset_slot()
.iter()
.filter(|row| row.session_id == input.session_id)
.by_big_fish_asset_session_id()
.filter(&input.session_id)
.collect::<Vec<_>>()
{
ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id);
@@ -408,8 +418,8 @@ pub(crate) fn delete_big_fish_work_tx(
for run in ctx
.db
.big_fish_runtime_run()
.iter()
.filter(|row| row.session_id == input.session_id)
.by_big_fish_run_session_id()
.filter(&input.session_id)
.collect::<Vec<_>>()
{
ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id);
@@ -952,8 +962,8 @@ pub(crate) fn build_big_fish_session_snapshot(
let mut messages = ctx
.db
.big_fish_agent_message()
.iter()
.filter(|message| message.session_id == row.session_id)
.by_big_fish_message_session_id()
.filter(&row.session_id)
.map(|message| BigFishAgentMessageSnapshot {
message_id: message.message_id,
session_id: message.session_id,
@@ -988,6 +998,16 @@ pub(crate) fn build_big_fish_work_summary(
ctx: &ReducerContext,
row: &BigFishCreationSession,
now_micros: i64,
) -> Result<BigFishWorkSummarySnapshot, String> {
let mut summary = build_big_fish_work_summary_without_recent_count(ctx, row)?;
summary.recent_play_count_7d =
count_recent_public_work_plays(ctx, "big-fish", &row.session_id, now_micros);
Ok(summary)
}
fn build_big_fish_work_summary_without_recent_count(
ctx: &ReducerContext,
row: &BigFishCreationSession,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
@@ -1052,12 +1072,7 @@ 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,
),
recent_play_count_7d: 0,
published_at_micros: row
.published_at
.or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at))
@@ -1065,6 +1080,113 @@ pub(crate) fn build_big_fish_work_summary(
})
}
fn build_big_fish_gallery_view_row(
ctx: &AnonymousViewContext,
row: &BigFishCreationSession,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let asset_slots = list_big_fish_asset_slots_for_view(ctx, &row.session_id);
let coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
let cover_image_src = asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::StageBackground)
.and_then(|slot| slot.asset_url.clone())
.or_else(|| {
asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::LevelMainImage)
.and_then(|slot| slot.asset_url.clone())
});
let title = draft
.as_ref()
.map(|value| value.title.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "未命名大鱼草稿".to_string());
let subtitle = draft
.as_ref()
.map(|value| value.subtitle.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "等待整理玩法草稿".to_string());
let summary = draft
.as_ref()
.map(|value| value.core_fun.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
row.last_assistant_reply
.clone()
.unwrap_or_else(|| "继续补齐锚点后即可生成玩法草稿。".to_string())
});
Ok(BigFishWorkSummarySnapshot {
work_id: format!("big-fish-work-{}", row.session_id),
source_session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
title,
subtitle,
summary,
cover_image_src,
status: if row.stage == BigFishCreationStage::Published {
"published".to_string()
} else {
"draft".to_string()
},
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
publish_ready: coverage.publish_ready,
level_count: draft
.as_ref()
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT),
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: 0,
published_at_micros: row
.published_at
.or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at))
.map(|value| value.to_micros_since_unix_epoch()),
})
}
fn list_big_fish_asset_slots_for_view(
ctx: &AnonymousViewContext,
session_id: &str,
) -> Vec<BigFishAssetSlotSnapshot> {
let mut slots = ctx
.db
.big_fish_asset_slot()
.by_big_fish_asset_session_id()
.filter(session_id)
.map(|slot| BigFishAssetSlotSnapshot {
slot_id: slot.slot_id,
session_id: slot.session_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
slots.sort_by_key(|slot| {
(
slot.level.unwrap_or(0),
slot.asset_kind.as_str().to_string(),
slot.motion_key.clone().unwrap_or_default(),
slot.slot_id.clone(),
)
});
slots
}
fn build_public_big_fish_gallery_list_input() -> BigFishWorksListInput {
BigFishWorksListInput {
// 中文注释published_only 分支不会按 owner 过滤;非空占位用于兼容旧部署模块的前置校验。

View File

@@ -2,7 +2,8 @@ use crate::*;
#[spacetimedb::table(
accessor = big_fish_creation_session,
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id]))
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_big_fish_session_stage, btree(columns = [stage]))
)]
pub struct BigFishCreationSession {
#[primary_key]

View File

@@ -436,7 +436,8 @@ fn delete_custom_world_agent_session_tx(
let published_profile = ctx
.db
.custom_world_profile()
.iter()
.by_custom_world_profile_owner_user_id()
.filter(&input.owner_user_id)
.find(|row| {
row.owner_user_id == input.owner_user_id
&& row.source_agent_session_id.as_deref() == Some(input.session_id.as_str())
@@ -471,8 +472,8 @@ fn delete_custom_world_agent_session_tx(
for message in ctx
.db
.custom_world_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.by_custom_world_agent_message_session_id()
.filter(&input.session_id)
.collect::<Vec<_>>()
{
ctx.db
@@ -483,8 +484,8 @@ fn delete_custom_world_agent_session_tx(
for operation in ctx
.db
.custom_world_agent_operation()
.iter()
.filter(|row| row.session_id == input.session_id)
.by_custom_world_agent_operation_session_id()
.filter(&input.session_id)
.collect::<Vec<_>>()
{
ctx.db
@@ -495,8 +496,8 @@ fn delete_custom_world_agent_session_tx(
for card in ctx
.db
.custom_world_draft_card()
.iter()
.filter(|row| row.session_id == input.session_id)
.by_custom_world_draft_card_session_id()
.filter(&input.session_id)
.collect::<Vec<_>>()
{
ctx.db
@@ -1184,9 +1185,17 @@ fn upsert_custom_world_profile_record(
.source_agent_session_id
.as_ref()
.and_then(|session_id| {
ctx.db.custom_world_profile().iter().find(|row| {
is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id)
})
ctx.db
.custom_world_profile()
.by_custom_world_profile_owner_user_id()
.filter(&input.owner_user_id)
.find(|row| {
is_same_agent_draft_profile_candidate(
row,
&input.owner_user_id,
session_id,
)
})
})
});
@@ -1534,8 +1543,9 @@ 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())
.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_snapshot(&row))
.collect::<Vec<_>>();
@@ -1676,8 +1686,9 @@ fn get_custom_world_gallery_detail_record_by_code(
let gallery_entry = ctx
.db
.custom_world_gallery_entry()
.iter()
.find(|row| row.public_work_code == normalized_public_work_code);
.by_custom_world_gallery_public_work_code()
.filter(&normalized_public_work_code)
.next();
let profile = gallery_entry.as_ref().and_then(|row| {
ctx.db
@@ -1974,9 +1985,14 @@ fn list_custom_world_work_snapshots(
let mut items = Vec::new();
let mut active_agent_session_ids = HashSet::new();
for session in ctx.db.custom_world_agent_session().iter().filter(|row| {
row.owner_user_id == input.owner_user_id
&& row.stage != RpgAgentStage::Published
let sessions = ctx
.db
.custom_world_agent_session()
.by_custom_world_agent_session_owner_user_id()
.filter(&input.owner_user_id)
.collect::<Vec<_>>();
for session in sessions.iter().filter(|row| {
row.stage != RpgAgentStage::Published
&& should_include_custom_world_agent_session_work(ctx, row)
}) {
active_agent_session_ids.insert(session.session_id.clone());
@@ -2021,8 +2037,9 @@ fn list_custom_world_work_snapshots(
for profile in ctx
.db
.custom_world_profile()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
.by_custom_world_profile_owner_user_id()
.filter(&input.owner_user_id)
.filter(|row| row.deleted_at.is_none())
.filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids))
{
items.push(CustomWorldWorkSummarySnapshot {
@@ -2086,16 +2103,20 @@ fn should_include_custom_world_agent_session_work(
return true;
}
if ctx.db.custom_world_agent_message().iter().any(|message| {
message.session_id == session.session_id
&& matches!(message.role, RpgAgentMessageRole::User)
}) {
if ctx
.db
.custom_world_agent_message()
.by_custom_world_agent_message_session_id()
.filter(&session.session_id)
.any(|message| matches!(message.role, RpgAgentMessageRole::User))
{
return true;
}
ctx.db
.custom_world_draft_card()
.iter()
.by_custom_world_draft_card_session_id()
.filter(&session.session_id)
.any(|card| card.session_id == session.session_id)
}
@@ -3446,10 +3467,12 @@ fn update_role_asset_cards(
label: &str,
updated_at_micros: i64,
) {
for card in
ctx.db.custom_world_draft_card().iter().filter(|row| {
row.session_id == session_id && row.kind == RpgAgentDraftCardKind::Character
})
for card in ctx
.db
.custom_world_draft_card()
.by_custom_world_draft_card_session_id()
.filter(&session_id.to_string())
.filter(|row| row.kind == RpgAgentDraftCardKind::Character)
{
replace_custom_world_draft_card(
ctx,
@@ -4590,8 +4613,8 @@ fn resolve_session_work_counts(
for card in ctx
.db
.custom_world_draft_card()
.iter()
.filter(|row| row.session_id == session.session_id)
.by_custom_world_draft_card_session_id()
.filter(&session.session_id)
{
match card.kind {
RpgAgentDraftCardKind::Character => {
@@ -4827,11 +4850,9 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(),
let published_profiles = ctx
.db
.custom_world_profile()
.iter()
.filter(|profile| {
profile.publication_status == CustomWorldPublicationStatus::Published
&& profile.deleted_at.is_none()
})
.by_custom_world_profile_publication_status()
.filter(CustomWorldPublicationStatus::Published)
.filter(|profile| profile.deleted_at.is_none())
.collect::<Vec<_>>();
for profile in published_profiles {
@@ -4973,8 +4994,8 @@ fn build_custom_world_agent_session_snapshot(
let mut messages = ctx
.db
.custom_world_agent_message()
.iter()
.filter(|message| message.session_id == row.session_id)
.by_custom_world_agent_message_session_id()
.filter(&row.session_id)
.map(|message| build_custom_world_agent_message_snapshot(&message))
.collect::<Vec<_>>();
messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone()));
@@ -4982,8 +5003,8 @@ fn build_custom_world_agent_session_snapshot(
let mut draft_cards = ctx
.db
.custom_world_draft_card()
.iter()
.filter(|card| card.session_id == row.session_id)
.by_custom_world_draft_card_session_id()
.filter(&row.session_id)
.map(|card| build_custom_world_draft_card_snapshot(&card))
.collect::<Vec<_>>();
draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone()));
@@ -4991,8 +5012,8 @@ fn build_custom_world_agent_session_snapshot(
let mut operations = ctx
.db
.custom_world_agent_operation()
.iter()
.filter(|operation| operation.session_id == row.session_id)
.by_custom_world_agent_operation_session_id()
.filter(&row.session_id)
.map(|operation| build_custom_world_agent_operation_snapshot(&operation))
.collect::<Vec<_>>();
operations

View File

@@ -415,11 +415,9 @@ fn apply_inventory_mutation_tx(
let current_slots = ctx
.db
.inventory_slot()
.iter()
.filter(|slot| {
slot.runtime_session_id == input.runtime_session_id
&& slot.actor_user_id == input.actor_user_id
})
.by_inventory_runtime_session_id()
.filter(&input.runtime_session_id)
.filter(|slot| slot.actor_user_id == input.actor_user_id)
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
@@ -587,11 +585,9 @@ fn get_runtime_inventory_state_tx(
let slots = ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == validated_input.runtime_session_id
&& row.actor_user_id == validated_input.actor_user_id
})
.by_inventory_runtime_session_id()
.filter(&validated_input.runtime_session_id)
.filter(|row| row.actor_user_id == validated_input.actor_user_id)
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
@@ -926,8 +922,8 @@ fn get_story_session_state_tx(
let mut events = ctx
.db
.story_event()
.iter()
.filter(|row| row.story_session_id == input.story_session_id)
.by_story_session_id()
.filter(&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()));
@@ -1439,11 +1435,9 @@ fn inventory_reward_source_already_granted(
ctx.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == first_mutation.runtime_session_id
&& row.actor_user_id == first_mutation.actor_user_id
})
.by_inventory_runtime_session_id()
.filter(&first_mutation.runtime_session_id)
.filter(|row| row.actor_user_id == first_mutation.actor_user_id)
.any(|row| row.source_reference_id.as_deref() == Some(source_reference_id))
}

View File

@@ -19,6 +19,62 @@ use module_match3d::{
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use spacetimedb::AnonymousViewContext;
/// 抓大鹅公开广场列表投影。
///
/// `match3d_work_profile` 是玩法源表HTTP gallery 只订阅这个轻量 view
/// 避免每个公开列表请求重新调用 procedure 扫描和组装全量列表。
#[spacetimedb::view(accessor = match3d_gallery_view, public)]
pub fn match3d_gallery_view(ctx: &AnonymousViewContext) -> Vec<Match3DGalleryViewRow> {
let mut items = ctx
.db
.match3d_work_profile()
.by_match3d_work_publication_status()
.filter(MATCH3D_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"抓大鹅公开广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct Match3DGalleryViewRow {
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub summary_text: String,
pub tags: Vec<String>,
pub cover_image_src: String,
pub cover_asset_id: String,
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub generated_item_assets_json: Option<String>,
}
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
@@ -105,12 +161,12 @@ pub fn list_match3d_works(
match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) {
Ok(items) => Match3DWorksProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => Match3DWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -135,12 +191,12 @@ pub fn delete_match3d_work(
match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) {
Ok(items) => Match3DWorksProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => Match3DWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -178,7 +234,7 @@ pub fn click_match3d_item(
Err(message) => Match3DClickItemProcedureResult {
ok: false,
status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(),
run_json: None,
run: None,
accepted_item_instance_id: None,
cleared_item_instance_ids: Vec::new(),
failure_reason: None,
@@ -459,6 +515,11 @@ fn compile_match3d_draft_tx(
config.theme_text.as_str(),
);
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let draft = Match3DDraftSnapshot {
profile_id: input.profile_id.clone(),
game_name: game_name.clone(),
@@ -467,12 +528,9 @@ fn compile_match3d_draft_tx(
tags: tags.clone(),
clear_count: config.clear_count,
difficulty: config.difficulty,
// 中文注释:草稿响应本身也携带生成素材快照,避免 HTTP facade 回读 work 详情失败时丢失背景/容器图。
generated_item_assets_json: generated_item_assets_json.clone(),
};
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
input.generated_item_assets_json.as_deref(),
existing_work.as_ref(),
)?;
let previous_publication_status = existing_work
.as_ref()
.map(|work| work.publication_status.clone())
@@ -632,17 +690,22 @@ fn list_match3d_works_tx(
ctx: &ReducerContext,
input: Match3DWorksListInput,
) -> Result<Vec<Match3DWorkSnapshot>, String> {
let mut items = ctx
.db
.match3d_work_profile()
let rows = if input.published_only {
ctx.db
.match3d_work_profile()
.by_match3d_work_publication_status()
.filter(&MATCH3D_PUBLICATION_PUBLISHED.to_string())
.collect::<Vec<_>>()
} else {
require_non_empty(&input.owner_user_id, "match3d owner_user_id")?;
ctx.db
.match3d_work_profile()
.by_match3d_work_owner_user_id()
.filter(&input.owner_user_id)
.collect::<Vec<_>>()
};
let mut items = rows
.iter()
.filter(|row| {
if input.published_only {
row.publication_status == MATCH3D_PUBLICATION_PUBLISHED
} else {
row.owner_user_id == input.owner_user_id
}
})
.map(|row| build_work_snapshot(&row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| {
@@ -683,10 +746,9 @@ fn delete_match3d_work_tx(
for run in ctx
.db
.match3d_runtime_run()
.iter()
.filter(|row| {
row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id
})
.by_match3d_run_profile_id()
.filter(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.collect::<Vec<_>>()
{
ctx.db.match3d_runtime_run().run_id().delete(&run.run_id);
@@ -929,8 +991,8 @@ fn build_session_snapshot(
let mut messages = ctx
.db
.match3d_agent_message()
.iter()
.filter(|message| message.session_id == row.session_id)
.by_match3d_agent_message_session_id()
.filter(&row.session_id)
.map(|message| Match3DAgentMessageSnapshot {
message_id: message.message_id,
session_id: message.session_id,
@@ -1002,6 +1064,35 @@ fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result<Match3DWorkSnapsho
})
}
fn build_gallery_view_row(row: &Match3DWorkProfileRow) -> Result<Match3DGalleryViewRow, String> {
let config = parse_config(&row.config_json)?;
Ok(Match3DGalleryViewRow {
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
game_name: row.game_name.clone(),
theme_text: row.theme_text.clone(),
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
reference_image_src: config.reference_image_src,
clear_count: row.clear_count,
difficulty: row.difficulty,
publication_status: row.publication_status.clone(),
publish_ready: is_work_publish_ready(row),
play_count: row.play_count,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
generated_item_assets_json: normalize_generated_item_assets_json(
row.generated_item_assets_json.as_deref(),
)?,
})
}
fn build_initial_run_snapshot(
run_id: &str,
work: &Match3DWorkProfileRow,
@@ -1154,10 +1245,10 @@ fn click_result(
Match3DClickItemProcedureResult {
ok: true,
status: status.to_string(),
run_json: Some(to_json_string(&snapshot)),
failure_reason: snapshot.failure_reason.clone(),
run: Some(snapshot),
accepted_item_instance_id,
cleared_item_instance_ids,
failure_reason: snapshot.failure_reason,
error_message: None,
}
}
@@ -1715,7 +1806,7 @@ fn to_json_string<T: Serialize>(value: &T) -> String {
fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult {
Match3DAgentSessionProcedureResult {
ok: true,
session_json: Some(to_json_string(&session)),
session: Some(session),
error_message: None,
}
}
@@ -1723,7 +1814,7 @@ fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionPr
fn session_error(message: String) -> Match3DAgentSessionProcedureResult {
Match3DAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
}
}
@@ -1731,7 +1822,7 @@ fn session_error(message: String) -> Match3DAgentSessionProcedureResult {
fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult {
Match3DWorkProcedureResult {
ok: true,
work_json: Some(to_json_string(&work)),
work: Some(work),
error_message: None,
}
}
@@ -1739,7 +1830,7 @@ fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult {
fn work_error(message: String) -> Match3DWorkProcedureResult {
Match3DWorkProcedureResult {
ok: false,
work_json: None,
work: None,
error_message: Some(message),
}
}
@@ -1747,7 +1838,7 @@ fn work_error(message: String) -> Match3DWorkProcedureResult {
fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult {
Match3DRunProcedureResult {
ok: true,
run_json: Some(to_json_string(&run)),
run: Some(run),
error_message: None,
}
}
@@ -1755,7 +1846,7 @@ fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult {
fn run_error(message: String) -> Match3DRunProcedureResult {
Match3DRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
}
}
@@ -1889,6 +1980,31 @@ mod tests {
);
}
#[test]
fn match3d_draft_snapshot_keeps_generated_item_assets_json() {
let draft = Match3DDraftSnapshot {
profile_id: "profile-1".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags: vec!["水果".to_string()],
clear_count: 3,
difficulty: 3,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","backgroundAsset":{"prompt":"果园背景","imageSrc":"/generated-match3d-assets/session/profile/background/background.png","containerImageSrc":"/generated-match3d-assets/session/profile/ui-container/container.png","status":"image_ready"},"status":"image_ready"}]"#
.to_string(),
),
};
let row_json = to_json_string(&draft);
let restored = parse_json::<Match3DDraftSnapshot>(&row_json, "match3d draft_json").unwrap();
assert_eq!(
restored.generated_item_assets_json.as_deref(),
draft.generated_item_assets_json.as_deref()
);
}
#[test]
fn match3d_work_update_preserves_assets_and_allows_empty_summary() {
let existing = Match3DWorkProfileRow {

View File

@@ -182,43 +182,43 @@ pub struct Match3DRunTimeUpInput {
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct Match3DAgentSessionProcedureResult {
pub ok: bool,
pub session_json: Option<String>,
pub session: Option<Match3DAgentSessionSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct Match3DWorkProcedureResult {
pub ok: bool,
pub work_json: Option<String>,
pub work: Option<Match3DWorkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct Match3DWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub items: Vec<Match3DWorkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct Match3DRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub run: Option<Match3DRunSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct Match3DClickItemProcedureResult {
pub ok: bool,
pub status: String,
pub run_json: Option<String>,
pub run: Option<Match3DRunSnapshot>,
pub accepted_item_instance_id: Option<String>,
pub cleared_item_instance_ids: Vec<String>,
pub failure_reason: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DCreatorConfigSnapshot {
pub theme_text: String,
@@ -235,7 +235,7 @@ pub struct Match3DCreatorConfigSnapshot {
pub generate_click_sound: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DAgentMessageSnapshot {
pub message_id: String,
@@ -246,7 +246,7 @@ pub struct Match3DAgentMessageSnapshot {
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DDraftSnapshot {
pub profile_id: String,
@@ -256,9 +256,11 @@ pub struct Match3DDraftSnapshot {
pub tags: Vec<String>,
pub clear_count: u32,
pub difficulty: u32,
#[serde(default)]
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DAgentSessionSnapshot {
pub session_id: String,
@@ -276,7 +278,7 @@ pub struct Match3DAgentSessionSnapshot {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DWorkSnapshot {
pub profile_id: String,
@@ -300,7 +302,7 @@ pub struct Match3DWorkSnapshot {
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DItemSnapshot {
pub item_instance_id: String,
@@ -314,7 +316,7 @@ pub struct Match3DItemSnapshot {
pub clickable: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DTraySlotSnapshot {
pub slot_index: u32,
@@ -323,7 +325,7 @@ pub struct Match3DTraySlotSnapshot {
pub visual_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct Match3DRunSnapshot {
pub run_id: String,

View File

@@ -31,7 +31,9 @@ use module_runtime::visible_runtime_profile_user_tags;
use serde_json::from_str as json_from_str;
use serde_json::json;
use serde_json::to_string as json_to_string;
use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext};
use spacetimedb::{
AnonymousViewContext, ProcedureContext, SpacetimeType, Table, Timestamp, TxContext,
};
use crate::auth::user_account;
@@ -112,6 +114,93 @@ pub struct PuzzleWorkProfileRow {
point_incentive_claimed_points: u64,
}
/// 拼图广场公开详情兼容投影。
///
/// 该 view 返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段。
/// 公开列表主路径应订阅更轻量的 `puzzle_gallery_card_view`。
#[spacetimedb::view(accessor = puzzle_gallery_view, public)]
pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec<PuzzleWorkProfile> {
let mut items = ctx
.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter_map(
|row| match build_puzzle_work_profile_from_row_without_recent_count(&row) {
Ok(profile) => Some(profile),
Err(error) => {
log::warn!(
"拼图广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
},
)
.collect::<Vec<_>>();
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
items
}
/// 拼图广场公开列表卡片投影。
///
/// 该 view 只暴露前端列表首屏需要的公开卡片字段,不携带 levels / anchor_pack
/// 等详情级载荷,供 api-server 热点缓存订阅和组装列表窗口。
#[spacetimedb::view(accessor = puzzle_gallery_card_view, public)]
pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<PuzzleGalleryCardViewRow> {
let mut items = ctx
.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter_map(|row| match build_puzzle_gallery_card_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"拼图广场卡片 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct PuzzleGalleryCardViewRow {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub publication_status: PuzzlePublicationStatus,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
pub point_incentive_total_half_points: u64,
pub point_incentive_claimed_points: u64,
pub publish_ready: bool,
pub generation_status: Option<String>,
}
/// 拼图创作事件类型。
///
/// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以
@@ -187,12 +276,12 @@ pub fn create_puzzle_agent_session(
match ctx.try_with_tx(|tx| create_puzzle_agent_session_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -206,12 +295,12 @@ pub fn get_puzzle_agent_session(
match ctx.try_with_tx(|tx| get_puzzle_agent_session_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -225,12 +314,12 @@ pub fn submit_puzzle_agent_message(
match ctx.try_with_tx(|tx| submit_puzzle_agent_message_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -244,12 +333,12 @@ pub fn finalize_puzzle_agent_message_turn(
match ctx.try_with_tx(|tx| finalize_puzzle_agent_message_turn_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -263,12 +352,12 @@ pub fn compile_puzzle_agent_draft(
match ctx.try_with_tx(|tx| compile_puzzle_agent_draft_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -284,12 +373,12 @@ pub fn save_puzzle_form_draft(
match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -303,12 +392,12 @@ pub fn save_puzzle_generated_images(
match ctx.try_with_tx(|tx| save_puzzle_generated_images_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -322,12 +411,12 @@ pub fn save_puzzle_ui_background(
match ctx.try_with_tx(|tx| save_puzzle_ui_background_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -341,12 +430,12 @@ pub fn select_puzzle_cover_image(
match ctx.try_with_tx(|tx| select_puzzle_cover_image_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -360,12 +449,12 @@ pub fn publish_puzzle_work(
match ctx.try_with_tx(|tx| publish_puzzle_work_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
item: None,
error_message: Some(message),
},
}
@@ -379,12 +468,12 @@ pub fn list_puzzle_works(
match ctx.try_with_tx(|tx| list_puzzle_works_tx(tx, input.clone())) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items_json: Some(serialize_json(&items)),
items,
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -398,12 +487,12 @@ pub fn get_puzzle_work_detail(
match ctx.try_with_tx(|tx| get_puzzle_work_detail_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
item: None,
error_message: Some(message),
},
}
@@ -417,12 +506,12 @@ pub fn update_puzzle_work(
match ctx.try_with_tx(|tx| update_puzzle_work_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
item: None,
error_message: Some(message),
},
}
@@ -436,12 +525,12 @@ pub fn delete_puzzle_work(
match ctx.try_with_tx(|tx| delete_puzzle_work_tx(tx, input.clone())) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items_json: Some(serialize_json(&items)),
items,
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -452,12 +541,12 @@ pub fn list_puzzle_gallery(ctx: &mut ProcedureContext) -> PuzzleWorksProcedureRe
match ctx.try_with_tx(|tx| list_puzzle_gallery_tx(tx)) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items_json: Some(serialize_json(&items)),
items,
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -471,12 +560,12 @@ pub fn get_puzzle_gallery_detail(
match ctx.try_with_tx(|tx| get_puzzle_gallery_detail_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
item: None,
error_message: Some(message),
},
}
@@ -490,12 +579,12 @@ pub fn record_puzzle_work_like(
match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
item: None,
error_message: Some(message),
},
}
@@ -509,12 +598,12 @@ pub fn remix_puzzle_work(
match ctx.try_with_tx(|tx| remix_puzzle_work_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
},
}
@@ -528,12 +617,12 @@ pub fn start_puzzle_run(
match ctx.try_with_tx(|tx| start_puzzle_run_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -547,12 +636,12 @@ pub fn get_puzzle_run(
match ctx.try_with_tx(|tx| get_puzzle_run_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -566,12 +655,12 @@ pub fn swap_puzzle_pieces(
match ctx.try_with_tx(|tx| swap_puzzle_pieces_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -585,12 +674,12 @@ pub fn drag_puzzle_piece_or_group(
match ctx.try_with_tx(|tx| drag_puzzle_piece_or_group_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -604,12 +693,12 @@ pub fn advance_puzzle_next_level(
match ctx.try_with_tx(|tx| advance_puzzle_next_level_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -623,12 +712,12 @@ pub fn update_puzzle_run_pause(
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)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -642,12 +731,12 @@ pub fn use_puzzle_runtime_prop(
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)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -661,12 +750,12 @@ pub fn claim_puzzle_work_point_incentive(
match ctx.try_with_tx(|tx| claim_puzzle_work_point_incentive_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item_json: Some(serialize_json(&item)),
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item_json: None,
item: None,
error_message: Some(message),
},
}
@@ -680,12 +769,12 @@ pub fn submit_puzzle_leaderboard_entry(
match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
},
}
@@ -974,6 +1063,7 @@ fn save_puzzle_generated_images_tx(
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
draft.levels = levels;
draft = normalize_completed_puzzle_level_generation_status(draft);
module_puzzle::sync_primary_level_fields(&mut draft);
// 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。
sync_generated_primary_level_name_as_default_work_title(
@@ -1003,6 +1093,7 @@ fn save_puzzle_generated_images_tx(
next_level.cover_asset_id = Some(selected.asset_id);
}
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
draft = normalize_completed_puzzle_level_generation_status(draft);
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
@@ -1054,6 +1145,7 @@ fn save_puzzle_ui_background_tx(
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
draft.levels = levels;
draft = normalize_completed_puzzle_level_generation_status(draft);
module_puzzle::sync_primary_level_fields(&mut draft);
}
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
@@ -1066,6 +1158,7 @@ fn save_puzzle_ui_background_tx(
(!trimmed.is_empty()).then_some(trimmed)
});
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let draft = normalize_completed_puzzle_level_generation_status(draft);
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
@@ -1119,6 +1212,52 @@ fn sync_generated_primary_level_name_as_default_work_title(
}
}
fn normalize_completed_puzzle_level_generation_status(
mut draft: PuzzleResultDraft,
) -> PuzzleResultDraft {
draft.levels = normalize_completed_puzzle_levels_generation_status(draft.levels);
module_puzzle::sync_primary_level_fields(&mut draft);
draft
}
fn normalize_completed_puzzle_levels_generation_status(
mut levels: Vec<module_puzzle::PuzzleDraftLevel>,
) -> Vec<module_puzzle::PuzzleDraftLevel> {
for level in &mut levels {
if level.generation_status.trim() == "generating" && has_completed_puzzle_level_image(level)
{
level.generation_status = "ready".to_string();
}
}
levels
}
fn has_completed_puzzle_level_image(level: &module_puzzle::PuzzleDraftLevel) -> bool {
let has_cover = level
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let has_selected_candidate = level
.selected_candidate_id
.as_deref()
.and_then(|candidate_id| {
level
.candidates
.iter()
.find(|candidate| candidate.candidate_id == candidate_id)
})
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
let has_fallback_candidate = level
.candidates
.last()
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
has_cover || has_selected_candidate || has_fallback_candidate
}
fn select_puzzle_cover_image_tx(
ctx: &TxContext,
input: PuzzleSelectCoverImageInput,
@@ -1264,8 +1403,8 @@ fn list_puzzle_works_tx(
let mut items = ctx
.db
.puzzle_work_profile()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id)
.by_puzzle_work_owner_user_id()
.filter(&input.owner_user_id)
.map(|row| build_puzzle_work_profile_from_row(&row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
@@ -1302,12 +1441,13 @@ fn update_puzzle_work_tx(
if theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
return Err("拼图标签数量不合法".to_string());
}
let levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
let mut levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
.map(|levels| {
normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string())
})
.transpose()?
.unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default());
levels = normalize_completed_puzzle_levels_generation_status(levels);
let preview_draft = PuzzleResultDraft {
work_title: input.work_title.clone(),
work_description: input.work_description.clone(),
@@ -1446,8 +1586,8 @@ fn delete_puzzle_work_tx(
for message in ctx
.db
.puzzle_agent_message()
.iter()
.filter(|message| message.session_id == *session_id)
.by_puzzle_agent_message_session_id()
.filter(session_id)
.collect::<Vec<_>>()
{
ctx.db
@@ -1459,10 +1599,9 @@ fn delete_puzzle_work_tx(
for run in ctx
.db
.puzzle_runtime_run()
.iter()
.filter(|run| {
run.owner_user_id == input.owner_user_id && run.entry_profile_id == input.profile_id
})
.by_puzzle_runtime_run_owner_user_id()
.filter(&input.owner_user_id)
.filter(|run| run.entry_profile_id == input.profile_id)
.collect::<Vec<_>>()
{
ctx.db.puzzle_runtime_run().run_id().delete(&run.run_id);
@@ -1481,8 +1620,8 @@ fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, Str
let rows = ctx
.db
.puzzle_work_profile()
.iter()
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.collect::<Vec<_>>();
let profile_ids = rows
.iter()
@@ -2416,6 +2555,72 @@ fn build_puzzle_work_profile_from_row_without_recent_count(
})
}
fn build_puzzle_gallery_card_view_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleGalleryCardViewRow, String> {
let levels = build_profile_levels_from_row(row)?;
Ok(PuzzleGalleryCardViewRow {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
work_title: if row.work_title.trim().is_empty() {
row.level_name.clone()
} else {
row.work_title.clone()
},
work_description: if row.work_description.trim().is_empty() {
row.summary.clone()
} else {
row.work_description.clone()
},
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags: deserialize_theme_tags(&row.theme_tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
publication_status: row.publication_status,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row.point_incentive_claimed_points,
publish_ready: row.publish_ready,
generation_status: resolve_puzzle_gallery_generation_status(&levels),
})
}
fn resolve_puzzle_gallery_generation_status(
levels: &[module_puzzle::PuzzleDraftLevel],
) -> Option<String> {
if levels.iter().any(has_completed_puzzle_level_image) {
return Some("ready".to_string());
}
levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "generating")
.or_else(|| {
levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "ready")
})
.or_else(|| {
levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| !status.is_empty())
})
.map(str::to_string)
}
fn build_profile_levels_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<Vec<module_puzzle::PuzzleDraftLevel>, String> {
@@ -2542,8 +2747,8 @@ fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec<PuzzleAgentMe
let mut items = ctx
.db
.puzzle_agent_message()
.iter()
.filter(|message| message.session_id == session_id)
.by_puzzle_agent_message_session_id()
.filter(&session_id.to_string())
.map(|message| PuzzleAgentMessageSnapshot {
message_id: message.message_id.clone(),
session_id: message.session_id.clone(),
@@ -2672,6 +2877,7 @@ fn replace_puzzle_work_profile(
}
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
let levels = normalize_completed_puzzle_levels_generation_status(profile.levels);
if let Some(existing) = ctx
.db
.puzzle_work_profile()
@@ -2694,7 +2900,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
theme_tags_json: serialize_json(&profile.theme_tags),
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
levels_json: serialize_json(&profile.levels),
levels_json: serialize_json(&levels),
publication_status: profile.publication_status,
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
// 广场消费数据,不能因为重新发布被清零。
@@ -2732,7 +2938,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
theme_tags_json: serialize_json(&profile.theme_tags),
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
levels_json: serialize_json(&profile.levels),
levels_json: serialize_json(&levels),
publication_status: profile.publication_status,
play_count: profile.play_count,
remix_count: profile.remix_count,
@@ -3152,8 +3358,8 @@ fn replace_generated_candidate(
fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
ctx.db
.puzzle_work_profile()
.iter()
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.map(|row| build_puzzle_work_profile_from_row(&row))
.collect()
}
@@ -3319,8 +3525,8 @@ fn list_puzzle_leaderboard_entries(
let mut rows = ctx
.db
.puzzle_leaderboard_entry()
.iter()
.filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)
.by_puzzle_leaderboard_profile_grid()
.filter((profile_id, grid_size))
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.best_elapsed_ms
@@ -3382,7 +3588,9 @@ fn deserialize_levels_json(value: &str) -> Result<Vec<module_puzzle::PuzzleDraft
if value.trim().is_empty() {
return Ok(Vec::new());
}
json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}"))
json_from_str(value)
.map(normalize_completed_puzzle_levels_generation_status)
.map_err(|error| format!("拼图 levels JSON 非法: {error}"))
}
fn deserialize_optional_levels_input(

View File

@@ -95,8 +95,8 @@ fn list_platform_browse_history_rows(
let mut entries = ctx
.db
.user_browse_history()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.by_browse_history_user_id()
.filter(&validated_input.user_id)
.map(|row| build_runtime_browse_history_snapshot_from_row(&row))
.collect::<Vec<_>>();
@@ -165,8 +165,8 @@ fn clear_platform_browse_history_rows(
let row_ids = ctx
.db
.user_browse_history()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.by_browse_history_user_id()
.filter(&validated_input.user_id)
.map(|row| row.browse_history_id.clone())
.collect::<Vec<_>>();

View File

@@ -215,8 +215,7 @@ fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now
&& row.subtitle == "分支叙事体验"
&& row.image_src == "/creation-type-references/visual-novel.webp"
&& row.visible
&& ((row.badge == "可创建" && row.open)
|| (row.badge == "敬请期待" && !row.open))
&& ((row.badge == "可创建" && row.open) || (row.badge == "敬请期待" && !row.open))
&& row.sort_order == 60;
if !still_old_visible_default {
return;

View File

@@ -558,6 +558,33 @@ pub fn record_tracking_event_and_return(
}
}
// 高频 route tracking 由 api-server 本机 outbox 批量写入,减少公开列表热路径上的 procedure 调用次数。
#[spacetimedb::procedure]
pub fn record_tracking_events_and_return(
ctx: &mut ProcedureContext,
inputs: Vec<RuntimeTrackingEventInput>,
) -> RuntimeTrackingEventBatchProcedureResult {
match ctx.try_with_tx(|tx| {
let mut accepted_count = 0u32;
for input in &inputs {
record_tracking_event(tx, input.clone())?;
accepted_count = accepted_count.saturating_add(1);
}
Ok(accepted_count)
}) {
Ok(accepted_count) => RuntimeTrackingEventBatchProcedureResult {
ok: true,
accepted_count,
error_message: None,
},
Err(message) => RuntimeTrackingEventBatchProcedureResult {
ok: false,
accepted_count: 0,
error_message: Some(message),
},
}
}
// 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。
#[spacetimedb::procedure]
pub fn record_daily_login_tracking_event_and_return(
@@ -1079,8 +1106,8 @@ pub(crate) fn list_profile_save_archive_rows(
let mut entries = ctx
.db
.profile_save_archive()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.by_profile_save_archive_user_id()
.filter(&validated_input.user_id)
.map(|row| build_profile_save_archive_snapshot_from_row(&row))
.collect::<Vec<_>>();
@@ -1104,10 +1131,12 @@ pub(crate) fn resume_profile_save_archive_record(
let archive = ctx
.db
.profile_save_archive()
.iter()
.find(|row| {
row.user_id == validated_input.user_id && row.world_key == validated_input.world_key
})
.by_profile_save_archive_user_world_key()
.filter((
validated_input.user_id.as_str(),
validated_input.world_key.as_str(),
))
.next()
.ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?;
let existing_snapshot = ctx
@@ -1537,6 +1566,19 @@ mod tests {
assert!(!should_skip_existing_tracking_event_id(false));
}
#[test]
fn tracking_batch_result_reports_accepted_count() {
let result = RuntimeTrackingEventBatchProcedureResult {
ok: true,
accepted_count: 2,
error_message: None,
};
assert!(result.ok);
assert_eq!(result.accepted_count, 2);
assert!(result.error_message.is_none());
}
#[test]
fn recent_public_work_play_counts_group_requested_profiles_in_window() {
let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10;
@@ -2052,8 +2094,8 @@ fn get_profile_dashboard_snapshot(
let played_world_count = ctx
.db
.profile_played_world()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.by_profile_played_world_user_id()
.filter(&validated_input.user_id)
.count() as u32;
Ok(match state {
@@ -2084,8 +2126,8 @@ fn list_profile_wallet_ledger_entries(
let mut entries = ctx
.db
.profile_wallet_ledger()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.by_profile_wallet_ledger_user_id()
.filter(&validated_input.user_id)
.map(|row| build_profile_wallet_ledger_snapshot_from_row(&row))
.collect::<Vec<_>>();
@@ -2114,8 +2156,8 @@ fn get_profile_play_stats_snapshot(
let mut played_works = ctx
.db
.profile_played_world()
.iter()
.filter(|row| row.user_id == validated_input.user_id)
.by_profile_played_world_user_id()
.filter(&validated_input.user_id)
.map(|row| build_profile_played_world_snapshot_from_row(&row))
.collect::<Vec<_>>();
@@ -2727,17 +2769,16 @@ fn build_profile_referral_invite_center_snapshot(
let code = ensure_profile_invite_code(ctx, user_id);
let today_inviter_reward_count =
count_today_profile_referral_inviter_rewards(ctx, user_id, ctx.timestamp);
let invited_count = ctx
let invited_relations = ctx
.db
.profile_referral_relation()
.by_profile_referral_inviter_user_id()
.filter(user_id)
.collect::<Vec<_>>();
let invited_count = invited_relations.len() as u32;
let rewarded_invite_count = invited_relations
.iter()
.filter(|row| row.inviter_user_id == user_id)
.count() as u32;
let rewarded_invite_count = ctx
.db
.profile_referral_relation()
.iter()
.filter(|row| row.inviter_user_id == user_id && row.inviter_reward_granted)
.filter(|row| row.inviter_reward_granted)
.count() as u32;
let bound_relation = ctx
.db
@@ -2918,7 +2959,8 @@ fn count_today_profile_referral_inviter_rewards(
let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch());
ctx.db
.profile_wallet_ledger()
.iter()
.by_profile_wallet_ledger_user_id()
.filter(user_id)
.filter(|row| {
row.user_id == user_id
&& row.source_type == RuntimeProfileWalletLedgerSourceType::InviteInviterReward
@@ -3422,7 +3464,11 @@ fn query_analytics_metric_buckets(
let stats = ctx
.db
.tracking_daily_stat()
.iter()
.by_tracking_daily_stat_scope_day()
.filter((
validated_input.scope_kind,
validated_input.scope_id.as_str(),
))
.filter(|row| {
row.event_key.trim() == validated_input.event_key
&& row.scope_kind == validated_input.scope_kind
@@ -4023,27 +4069,39 @@ fn apply_profile_wallet_signed_delta(
}
fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db.profile_recharge_order().iter().any(|row| {
row.user_id == user_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
})
ctx.db
.profile_recharge_order()
.by_profile_recharge_order_user_id()
.filter(user_id)
.any(|row| {
row.user_id == user_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
})
}
fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool {
ctx.db.profile_recharge_order().iter().any(|row| {
row.user_id == user_id
&& row.product_id == product_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
})
ctx.db
.profile_recharge_order()
.by_profile_recharge_order_user_id()
.filter(user_id)
.any(|row| {
row.user_id == user_id
&& row.product_id == product_id
&& row.kind == RuntimeProfileRechargeProductKind::Points
&& row.status == RuntimeProfileRechargeOrderStatus::Paid
})
}
fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool {
ctx.db.profile_wallet_ledger().iter().any(|row| {
row.user_id == user_id
&& row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync
})
ctx.db
.profile_wallet_ledger()
.by_profile_wallet_ledger_user_id()
.filter(user_id)
.any(|row| {
row.user_id == user_id
&& row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync
})
}
fn latest_profile_recharge_order(
@@ -4053,8 +4111,8 @@ fn latest_profile_recharge_order(
let mut orders = ctx
.db
.profile_recharge_order()
.iter()
.filter(|row| row.user_id == user_id)
.by_profile_recharge_order_user_id()
.filter(user_id)
.collect::<Vec<_>>();
orders.sort_by(|left, right| {
right

View File

@@ -26,6 +26,65 @@ use module_square_hole::{
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
/// 方洞挑战公开广场列表投影。
///
/// HTTP gallery 通过 `spacetime-client` 订阅该 view 后读本地 cache
/// 不再在每个公开列表请求里调用 `list_square_hole_works` procedure。
#[spacetimedb::view(accessor = square_hole_gallery_view, public)]
pub fn square_hole_gallery_view(ctx: &AnonymousViewContext) -> Vec<SquareHoleGalleryViewRow> {
let mut items = ctx
.db
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"方洞挑战公开广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct SquareHoleGalleryViewRow {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary_text: String,
pub tags: Vec<String>,
pub cover_image_src: String,
pub background_prompt: String,
pub background_image_src: String,
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
pub hole_options: Vec<SquareHoleHoleOptionSnapshot>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[spacetimedb::procedure]
pub fn create_square_hole_agent_session(
@@ -112,12 +171,12 @@ pub fn list_square_hole_works(
match ctx.try_with_tx(|tx| list_square_hole_works_tx(tx, input.clone())) {
Ok(items) => SquareHoleWorksProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => SquareHoleWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -142,12 +201,12 @@ pub fn delete_square_hole_work(
match ctx.try_with_tx(|tx| delete_square_hole_work_tx(tx, input.clone())) {
Ok(items) => SquareHoleWorksProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => SquareHoleWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -185,8 +244,8 @@ pub fn drop_square_hole_shape(
Err(message) => SquareHoleDropShapeProcedureResult {
ok: false,
status: SQUARE_HOLE_DROP_REJECTED.to_string(),
run_json: None,
feedback_json: None,
run: None,
feedback: None,
failure_reason: None,
error_message: Some(message),
},
@@ -743,10 +802,8 @@ fn drop_square_hole_shape_tx(
Ok(SquareHoleDropShapeProcedureResult {
ok: true,
status: status.to_string(),
run_json: Some(to_json_string(&next)),
feedback_json: Some(to_json_string(&feedback_from_domain(
&confirmation.feedback,
))),
run: Some(next),
feedback: Some(feedback_from_domain(&confirmation.feedback)),
failure_reason: confirmation
.feedback
.reject_reason
@@ -880,6 +937,38 @@ fn build_work_snapshot(row: &SquareHoleWorkProfileRow) -> Result<SquareHoleWorkS
})
}
fn build_gallery_view_row(
row: &SquareHoleWorkProfileRow,
) -> Result<SquareHoleGalleryViewRow, String> {
let config = parse_config(&row.config_json)?;
Ok(SquareHoleGalleryViewRow {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: row.source_session_id.clone(),
author_display_name: row.author_display_name.clone(),
game_name: row.game_name.clone(),
theme_text: row.theme_text.clone(),
twist_rule: row.twist_rule.clone(),
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(),
background_prompt: config.background_prompt,
background_image_src: config.background_image_src,
shape_options: config.shape_options,
hole_options: config.hole_options,
shape_count: row.shape_count,
difficulty: row.difficulty,
publication_status: row.publication_status.clone(),
publish_ready: is_work_publish_ready(row),
play_count: row.play_count,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
})
}
fn refresh_run_row(
ctx: &ReducerContext,
row: SquareHoleRuntimeRunRow,
@@ -1502,7 +1591,7 @@ fn session_result(
) -> SquareHoleAgentSessionProcedureResult {
SquareHoleAgentSessionProcedureResult {
ok: true,
session_json: Some(to_json_string(&session)),
session: Some(session),
error_message: None,
}
}
@@ -1510,7 +1599,7 @@ fn session_result(
fn session_error(message: String) -> SquareHoleAgentSessionProcedureResult {
SquareHoleAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
}
}
@@ -1518,7 +1607,7 @@ fn session_error(message: String) -> SquareHoleAgentSessionProcedureResult {
fn work_result(work: SquareHoleWorkSnapshot) -> SquareHoleWorkProcedureResult {
SquareHoleWorkProcedureResult {
ok: true,
work_json: Some(to_json_string(&work)),
work: Some(work),
error_message: None,
}
}
@@ -1526,7 +1615,7 @@ fn work_result(work: SquareHoleWorkSnapshot) -> SquareHoleWorkProcedureResult {
fn work_error(message: String) -> SquareHoleWorkProcedureResult {
SquareHoleWorkProcedureResult {
ok: false,
work_json: None,
work: None,
error_message: Some(message),
}
}
@@ -1534,7 +1623,7 @@ fn work_error(message: String) -> SquareHoleWorkProcedureResult {
fn run_result(run: SquareHoleRunSnapshot) -> SquareHoleRunProcedureResult {
SquareHoleRunProcedureResult {
ok: true,
run_json: Some(to_json_string(&run)),
run: Some(run),
error_message: None,
}
}
@@ -1542,7 +1631,7 @@ fn run_result(run: SquareHoleRunSnapshot) -> SquareHoleRunProcedureResult {
fn run_error(message: String) -> SquareHoleRunProcedureResult {
SquareHoleRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
}
}

View File

@@ -168,42 +168,42 @@ pub struct SquareHoleRunTimeUpInput {
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct SquareHoleAgentSessionProcedureResult {
pub ok: bool,
pub session_json: Option<String>,
pub session: Option<SquareHoleAgentSessionSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct SquareHoleWorkProcedureResult {
pub ok: bool,
pub work_json: Option<String>,
pub work: Option<SquareHoleWorkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct SquareHoleWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub items: Vec<SquareHoleWorkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct SquareHoleRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub run: Option<SquareHoleRunSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct SquareHoleDropShapeProcedureResult {
pub ok: bool,
pub status: String,
pub run_json: Option<String>,
pub feedback_json: Option<String>,
pub run: Option<SquareHoleRunSnapshot>,
pub feedback: Option<SquareHoleDropFeedbackSnapshot>,
pub failure_reason: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleCreatorConfigSnapshot {
pub theme_text: String,
@@ -222,7 +222,7 @@ pub struct SquareHoleCreatorConfigSnapshot {
pub background_image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleShapeOptionSnapshot {
pub option_id: String,
@@ -235,7 +235,7 @@ pub struct SquareHoleShapeOptionSnapshot {
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleHoleOptionSnapshot {
pub hole_id: String,
@@ -247,7 +247,7 @@ pub struct SquareHoleHoleOptionSnapshot {
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleAgentMessageSnapshot {
pub message_id: String,
@@ -258,7 +258,7 @@ pub struct SquareHoleAgentMessageSnapshot {
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleDraftSnapshot {
pub profile_id: String,
@@ -281,7 +281,7 @@ pub struct SquareHoleDraftSnapshot {
pub difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleAgentSessionSnapshot {
pub session_id: String,
@@ -299,7 +299,7 @@ pub struct SquareHoleAgentSessionSnapshot {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleWorkSnapshot {
pub work_id: String,
@@ -331,7 +331,7 @@ pub struct SquareHoleWorkSnapshot {
pub published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleShapeSnapshot {
pub shape_id: String,
@@ -344,7 +344,7 @@ pub struct SquareHoleShapeSnapshot {
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleHoleSnapshot {
pub hole_id: String,
@@ -356,7 +356,7 @@ pub struct SquareHoleHoleSnapshot {
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleDropFeedbackSnapshot {
pub accepted: bool,
@@ -364,7 +364,7 @@ pub struct SquareHoleDropFeedbackSnapshot {
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleRunSnapshot {
pub run_id: String,

View File

@@ -1,6 +1,7 @@
use crate::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea";
pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document";
@@ -166,6 +167,58 @@ pub struct VisualNovelRuntimeEvent {
pub(crate) occurred_at: Timestamp,
}
/// 视觉小说公开广场列表投影。
///
/// 该 view 只暴露已发布作品卡片需要的公开字段HTTP gallery 订阅后
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {
let mut items = ctx
.db
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"视觉小说公开广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelGalleryViewRow {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_asset_ids: Vec<String>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelAgentSessionCreateInput {
pub session_id: String,
@@ -326,49 +379,65 @@ pub struct VisualNovelRuntimeEventRecordInput {
pub occurred_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct VisualNovelAgentSessionProcedureResult {
pub ok: bool,
pub session_json: Option<String>,
pub session: Option<VisualNovelAgentSessionSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct VisualNovelWorkProcedureResult {
pub ok: bool,
pub work_json: Option<String>,
pub work: Option<VisualNovelWorkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct VisualNovelWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub items: Vec<VisualNovelWorkSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct VisualNovelRunProcedureResult {
pub ok: bool,
pub run_json: Option<String>,
pub run: Option<VisualNovelRunSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct VisualNovelHistoryProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub items: Vec<VisualNovelRuntimeHistoryEntrySnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, SpacetimeType)]
pub struct VisualNovelRuntimeEventProcedureResult {
pub ok: bool,
pub event_json: Option<String>,
pub event: Option<VisualNovelRuntimeEventSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
pub struct VisualNovelJsonField {
pub key: String,
pub value: VisualNovelJsonValue,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
pub enum VisualNovelJsonValue {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<VisualNovelJsonValue>),
Object(Vec<VisualNovelJsonField>),
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelAgentMessageSnapshot {
pub message_id: String,
@@ -379,7 +448,7 @@ pub struct VisualNovelAgentMessageSnapshot {
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelAgentSessionSnapshot {
pub session_id: String,
@@ -391,15 +460,15 @@ pub struct VisualNovelAgentSessionSnapshot {
pub current_turn: u32,
pub progress_percent: u32,
pub messages: Vec<VisualNovelAgentMessageSnapshot>,
pub draft: Option<JsonValue>,
pub pending_action: Option<JsonValue>,
pub draft: Option<VisualNovelJsonValue>,
pub pending_action: Option<VisualNovelJsonValue>,
pub last_assistant_reply: Option<String>,
pub published_profile_id: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelWorkSnapshot {
pub work_id: String,
@@ -412,7 +481,7 @@ pub struct VisualNovelWorkSnapshot {
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_asset_ids: Vec<String>,
pub draft: JsonValue,
pub draft: VisualNovelJsonValue,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
@@ -421,7 +490,7 @@ pub struct VisualNovelWorkSnapshot {
pub published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeHistoryEntrySnapshot {
pub entry_id: String,
@@ -431,13 +500,13 @@ pub struct VisualNovelRuntimeHistoryEntrySnapshot {
pub turn_index: u32,
pub source: String,
pub action_text: Option<String>,
pub steps: JsonValue,
pub steps: VisualNovelJsonValue,
pub snapshot_before_hash: Option<String>,
pub snapshot_after_hash: Option<String>,
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRunSnapshot {
pub run_id: String,
@@ -448,16 +517,16 @@ pub struct VisualNovelRunSnapshot {
pub current_scene_id: Option<String>,
pub current_phase_id: Option<String>,
pub visible_character_ids: Vec<String>,
pub flags: JsonValue,
pub metrics: JsonValue,
pub flags: VisualNovelJsonValue,
pub metrics: VisualNovelJsonValue,
pub history: Vec<VisualNovelRuntimeHistoryEntrySnapshot>,
pub available_choices: JsonValue,
pub available_choices: VisualNovelJsonValue,
pub text_mode_enabled: bool,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeEventSnapshot {
pub event_id: String,
@@ -467,7 +536,7 @@ pub struct VisualNovelRuntimeEventSnapshot {
pub event_kind: String,
pub client_event_id: Option<String>,
pub history_entry_id: Option<String>,
pub payload: JsonValue,
pub payload: VisualNovelJsonValue,
pub occurred_at_micros: i64,
}
@@ -556,12 +625,12 @@ pub fn list_visual_novel_works(
match ctx.try_with_tx(|tx| list_visual_novel_works_tx(tx, input.clone())) {
Ok(items) => VisualNovelWorksProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => VisualNovelWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -586,12 +655,12 @@ pub fn delete_visual_novel_work(
match ctx.try_with_tx(|tx| delete_visual_novel_work_tx(tx, input.clone())) {
Ok(items) => VisualNovelWorksProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => VisualNovelWorksProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -638,12 +707,12 @@ pub fn append_visual_novel_runtime_history_entry(
match ctx.try_with_tx(|tx| append_visual_novel_runtime_history_entry_tx(tx, input.clone())) {
Ok(items) => VisualNovelHistoryProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => VisualNovelHistoryProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -657,12 +726,12 @@ pub fn list_visual_novel_runtime_history(
match ctx.try_with_tx(|tx| list_visual_novel_runtime_history_tx(tx, input.clone())) {
Ok(items) => VisualNovelHistoryProcedureResult {
ok: true,
items_json: Some(to_json_string(&items)),
items,
error_message: None,
},
Err(message) => VisualNovelHistoryProcedureResult {
ok: false,
items_json: None,
items: Vec::new(),
error_message: Some(message),
},
}
@@ -676,12 +745,12 @@ pub fn record_visual_novel_runtime_event(
match ctx.try_with_tx(|tx| record_visual_novel_runtime_event_tx(tx, input.clone())) {
Ok(event) => VisualNovelRuntimeEventProcedureResult {
ok: true,
event_json: Some(to_json_string(&event)),
event: Some(event),
error_message: None,
},
Err(message) => VisualNovelRuntimeEventProcedureResult {
ok: false,
event_json: None,
event: None,
error_message: Some(message),
},
}
@@ -1052,17 +1121,22 @@ fn list_visual_novel_works_tx(
ctx: &ReducerContext,
input: VisualNovelWorksListInput,
) -> Result<Vec<VisualNovelWorkSnapshot>, String> {
let mut items = ctx
.db
.visual_novel_work_profile()
let rows = if input.published_only {
ctx.db
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(&VISUAL_NOVEL_PUBLICATION_PUBLISHED.to_string())
.collect::<Vec<_>>()
} else {
require_non_empty(&input.owner_user_id, "visual_novel owner_user_id")?;
ctx.db
.visual_novel_work_profile()
.by_visual_novel_work_owner_user_id()
.filter(&input.owner_user_id)
.collect::<Vec<_>>()
};
let mut items = rows
.iter()
.filter(|row| {
if input.published_only {
row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED
} else {
row.owner_user_id == input.owner_user_id
}
})
.map(|row| build_work_snapshot(&row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| {
@@ -1103,10 +1177,9 @@ fn delete_visual_novel_work_tx(
for run in ctx
.db
.visual_novel_runtime_run()
.iter()
.filter(|row| {
row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id
})
.by_visual_novel_run_profile_id()
.filter(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.collect::<Vec<_>>()
{
delete_run_children(ctx, &run.run_id, &input.owner_user_id);
@@ -1385,8 +1458,8 @@ fn build_session_snapshot(
let mut messages = ctx
.db
.visual_novel_agent_message()
.iter()
.filter(|message| message.session_id == row.session_id)
.by_visual_novel_agent_message_session_id()
.filter(&row.session_id)
.map(|message| VisualNovelAgentMessageSnapshot {
message_id: message.message_id,
session_id: message.session_id,
@@ -1412,8 +1485,9 @@ fn build_session_snapshot(
current_turn: row.current_turn,
progress_percent: row.progress_percent,
messages,
draft: parse_optional_json_value(&row.draft_json)?,
pending_action: parse_optional_json_value(&row.pending_action_json)?,
draft: parse_optional_json_value(&row.draft_json)?.map(visual_novel_json_from_serde),
pending_action: parse_optional_json_value(&row.pending_action_json)?
.map(visual_novel_json_from_serde),
last_assistant_reply: empty_to_none(&row.last_assistant_reply),
published_profile_id: empty_to_none(&row.published_profile_id),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
@@ -1433,7 +1507,32 @@ fn build_work_snapshot(row: &VisualNovelWorkProfileRow) -> Result<VisualNovelWor
tags: parse_string_vec_or_empty(&row.tags_json)?,
cover_image_src: empty_to_none(&row.cover_image_src),
source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?,
draft: parse_json_value(&row.draft_json)?,
draft: visual_novel_json_from_serde(parse_json_value(&row.draft_json)?),
publication_status: row.publication_status.clone(),
publish_ready: row.publish_ready,
play_count: row.play_count,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
})
}
fn build_gallery_view_row(
row: &VisualNovelWorkProfileRow,
) -> Result<VisualNovelGalleryViewRow, String> {
Ok(VisualNovelGalleryViewRow {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: empty_to_none(&row.source_session_id),
author_display_name: row.author_display_name.clone(),
work_title: row.work_title.clone(),
work_description: row.work_description.clone(),
tags: parse_string_vec_or_empty(&row.tags_json)?,
cover_image_src: empty_to_none(&row.cover_image_src),
source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?,
publication_status: row.publication_status.clone(),
publish_ready: row.publish_ready,
play_count: row.play_count,
@@ -1458,10 +1557,12 @@ fn build_run_snapshot(
current_scene_id: empty_to_none(&row.current_scene_id),
current_phase_id: empty_to_none(&row.current_phase_id),
visible_character_ids: parse_string_vec_or_empty(&row.visible_character_ids_json)?,
flags: parse_json_value_or_object(&row.flags_json)?,
metrics: parse_json_value_or_object(&row.metrics_json)?,
flags: visual_novel_json_from_serde(parse_json_value_or_object(&row.flags_json)?),
metrics: visual_novel_json_from_serde(parse_json_value_or_object(&row.metrics_json)?),
history: build_history_snapshots(ctx, &row.run_id, &row.owner_user_id)?,
available_choices: parse_json_value_or_array(&row.available_choices_json)?,
available_choices: visual_novel_json_from_serde(parse_json_value_or_array(
&row.available_choices_json,
)?),
text_mode_enabled: row.text_mode_enabled,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
@@ -1476,8 +1577,9 @@ fn build_history_snapshots(
let mut items = ctx
.db
.visual_novel_runtime_history_entry()
.iter()
.filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id)
.by_visual_novel_history_run_id()
.filter(&run_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.map(|row| build_history_snapshot(&row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| {
@@ -1500,7 +1602,7 @@ fn build_history_snapshot(
turn_index: row.turn_index,
source: row.source.clone(),
action_text: empty_to_none(&row.action_text),
steps: parse_json_value_or_array(&row.steps_json)?,
steps: visual_novel_json_from_serde(parse_json_value_or_array(&row.steps_json)?),
snapshot_before_hash: empty_to_none(&row.snapshot_before_hash),
snapshot_after_hash: empty_to_none(&row.snapshot_after_hash),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
@@ -1518,7 +1620,7 @@ fn build_event_snapshot(
event_kind: row.event_kind.clone(),
client_event_id: empty_to_none(&row.client_event_id),
history_entry_id: empty_to_none(&row.history_entry_id),
payload: parse_json_value_or_object(&row.payload_json)?,
payload: visual_novel_json_from_serde(parse_json_value_or_object(&row.payload_json)?),
occurred_at_micros: row.occurred_at.to_micros_since_unix_epoch(),
})
}
@@ -1579,8 +1681,9 @@ fn delete_run_children(ctx: &ReducerContext, run_id: &str, owner_user_id: &str)
for history in ctx
.db
.visual_novel_runtime_history_entry()
.iter()
.filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id)
.by_visual_novel_history_run_id()
.filter(&run_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.collect::<Vec<_>>()
{
ctx.db
@@ -1758,6 +1861,30 @@ fn parse_json_value_or_array(value: &str) -> Result<JsonValue, String> {
parse_json_value(value)
}
fn visual_novel_json_from_serde(value: JsonValue) -> VisualNovelJsonValue {
match value {
JsonValue::Null => VisualNovelJsonValue::Null,
JsonValue::Bool(value) => VisualNovelJsonValue::Bool(value),
JsonValue::Number(value) => VisualNovelJsonValue::Number(value.as_f64().unwrap_or(0.0)),
JsonValue::String(value) => VisualNovelJsonValue::String(value),
JsonValue::Array(items) => VisualNovelJsonValue::Array(
items
.into_iter()
.map(visual_novel_json_from_serde)
.collect(),
),
JsonValue::Object(object) => VisualNovelJsonValue::Object(
object
.into_iter()
.map(|(key, value)| VisualNovelJsonField {
key,
value: visual_novel_json_from_serde(value),
})
.collect(),
),
}
}
fn draft_string_field(draft: &JsonValue, key: &str) -> Option<String> {
draft
.get(key)
@@ -1853,7 +1980,7 @@ fn session_result(
) -> VisualNovelAgentSessionProcedureResult {
VisualNovelAgentSessionProcedureResult {
ok: true,
session_json: Some(to_json_string(&session)),
session: Some(session),
error_message: None,
}
}
@@ -1861,7 +1988,7 @@ fn session_result(
fn session_error(message: String) -> VisualNovelAgentSessionProcedureResult {
VisualNovelAgentSessionProcedureResult {
ok: false,
session_json: None,
session: None,
error_message: Some(message),
}
}
@@ -1869,7 +1996,7 @@ fn session_error(message: String) -> VisualNovelAgentSessionProcedureResult {
fn work_result(work: VisualNovelWorkSnapshot) -> VisualNovelWorkProcedureResult {
VisualNovelWorkProcedureResult {
ok: true,
work_json: Some(to_json_string(&work)),
work: Some(work),
error_message: None,
}
}
@@ -1877,7 +2004,7 @@ fn work_result(work: VisualNovelWorkSnapshot) -> VisualNovelWorkProcedureResult
fn work_error(message: String) -> VisualNovelWorkProcedureResult {
VisualNovelWorkProcedureResult {
ok: false,
work_json: None,
work: None,
error_message: Some(message),
}
}
@@ -1885,7 +2012,7 @@ fn work_error(message: String) -> VisualNovelWorkProcedureResult {
fn run_result(run: VisualNovelRunSnapshot) -> VisualNovelRunProcedureResult {
VisualNovelRunProcedureResult {
ok: true,
run_json: Some(to_json_string(&run)),
run: Some(run),
error_message: None,
}
}
@@ -1893,7 +2020,7 @@ fn run_result(run: VisualNovelRunSnapshot) -> VisualNovelRunProcedureResult {
fn run_error(message: String) -> VisualNovelRunProcedureResult {
VisualNovelRunProcedureResult {
ok: false,
run_json: None,
run: None,
error_message: Some(message),
}
}