Files
Genarrative/server-rs/crates/spacetime-module/src/lib.rs

7147 lines
257 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
pub use module_ai::*;
pub use module_assets::*;
pub use module_big_fish::*;
pub use module_combat::*;
pub use module_custom_world::*;
pub use module_inventory::*;
pub use module_npc::*;
pub use module_progression::*;
pub use module_quest::*;
pub use module_runtime::*;
pub use module_runtime_item::*;
pub use module_story::*;
use module_combat::resolve_combat_action as resolve_battle_state_action;
use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation;
use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain;
use module_quest::{
acknowledge_quest_completion as acknowledge_quest_record_completion,
apply_quest_signal as apply_quest_record_signal,
};
pub(crate) use serde_json::{Map as JsonMap, Value as JsonValue, json};
pub(crate) use shared_kernel::format_timestamp_micros;
pub use spacetimedb::{
Identity, ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp,
};
use std::collections::HashSet;
mod ai;
mod asset_metadata;
mod auth;
mod big_fish;
mod domain_types;
mod entry;
mod match3d;
mod migration;
mod puzzle;
mod runtime;
pub use ai::*;
pub use asset_metadata::*;
pub use auth::*;
pub use big_fish::*;
pub use domain_types::*;
pub use entry::*;
pub use match3d::*;
pub use migration::*;
pub use runtime::*;
#[spacetimedb::table(accessor = player_progression)]
pub struct PlayerProgression {
#[primary_key]
user_id: String,
level: u32,
current_level_xp: u32,
total_xp: u32,
xp_to_next_level: u32,
pending_level_ups: u32,
last_granted_source: Option<PlayerProgressionGrantSource>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = chapter_progression,
index(accessor = by_chapter_progression_user_id, btree(columns = [user_id])),
index(accessor = by_chapter_progression_chapter_id, btree(columns = [chapter_id])),
index(accessor = by_chapter_progression_user_chapter, btree(columns = [user_id, chapter_id]))
)]
pub struct ChapterProgression {
#[primary_key]
chapter_progression_id: String,
user_id: String,
chapter_id: String,
chapter_index: u32,
total_chapters: u32,
entry_pseudo_level_millis: u32,
exit_pseudo_level_millis: u32,
entry_level: u32,
exit_level: u32,
planned_total_xp: u32,
planned_quest_xp: u32,
planned_hostile_xp: u32,
actual_quest_xp: u32,
actual_hostile_xp: u32,
expected_hostile_defeat_count: u32,
actual_hostile_defeat_count: u32,
level_at_entry: u32,
level_at_exit: Option<u32>,
pace_band: ChapterPaceBand,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = npc_state,
index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_npc_id, btree(columns = [npc_id])),
index(accessor = by_runtime_session_npc, btree(columns = [runtime_session_id, npc_id]))
)]
pub struct NpcState {
#[primary_key]
npc_state_id: String,
runtime_session_id: String,
npc_id: String,
npc_name: String,
affinity: i32,
relation_state: NpcRelationState,
help_used: bool,
chatted_count: u32,
gifts_given: u32,
recruited: bool,
trade_stock_signature: Option<String>,
revealed_facts: Vec<String>,
known_attribute_rumors: Vec<String>,
first_meaningful_contact_resolved: bool,
seen_backstory_chapter_ids: Vec<String>,
stance_profile: NpcStanceProfile,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = story_session,
index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_actor_user_id, btree(columns = [actor_user_id]))
)]
pub struct StorySession {
#[primary_key]
story_session_id: String,
runtime_session_id: String,
actor_user_id: String,
world_profile_id: String,
initial_prompt: String,
opening_summary: Option<String>,
latest_narrative_text: String,
latest_choice_function_id: Option<String>,
status: StorySessionStatus,
version: u32,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = story_event,
index(accessor = by_story_session_id, btree(columns = [story_session_id]))
)]
pub struct StoryEvent {
#[primary_key]
event_id: String,
story_session_id: String,
event_kind: StoryEventKind,
narrative_text: String,
choice_function_id: Option<String>,
created_at: Timestamp,
}
#[spacetimedb::table(
accessor = inventory_slot,
index(accessor = by_inventory_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_inventory_actor_user_id, btree(columns = [actor_user_id])),
index(accessor = by_inventory_container_slot, btree(columns = [container_kind, slot_key])),
index(accessor = by_inventory_item_id, btree(columns = [item_id]))
)]
pub struct InventorySlot {
#[primary_key]
slot_id: String,
runtime_session_id: String,
story_session_id: Option<String>,
actor_user_id: String,
container_kind: InventoryContainerKind,
slot_key: String,
item_id: String,
category: String,
name: String,
description: Option<String>,
quantity: u32,
rarity: InventoryItemRarity,
tags: Vec<String>,
stackable: bool,
stack_key: String,
equipment_slot_id: Option<InventoryEquipmentSlot>,
source_kind: InventoryItemSourceKind,
source_reference_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = battle_state,
index(accessor = by_battle_story_session_id, btree(columns = [story_session_id])),
index(accessor = by_battle_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_battle_actor_user_id, btree(columns = [actor_user_id]))
)]
pub struct BattleState {
#[primary_key]
battle_state_id: String,
story_session_id: String,
runtime_session_id: String,
actor_user_id: String,
chapter_id: Option<String>,
target_npc_id: String,
target_name: String,
battle_mode: BattleMode,
status: BattleStatus,
player_hp: i32,
player_max_hp: i32,
player_mana: i32,
player_max_mana: i32,
target_hp: i32,
target_max_hp: i32,
experience_reward: u32,
reward_items: Vec<RuntimeItemRewardItemSnapshot>,
turn_index: u32,
last_action_function_id: Option<String>,
last_action_text: Option<String>,
last_result_text: Option<String>,
last_damage_dealt: i32,
last_damage_taken: i32,
last_outcome: CombatOutcome,
version: u32,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = treasure_record,
index(accessor = by_treasure_story_session_id, btree(columns = [story_session_id])),
index(accessor = by_treasure_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_treasure_actor_user_id, btree(columns = [actor_user_id])),
index(accessor = by_treasure_encounter_id, btree(columns = [encounter_id]))
)]
pub struct TreasureRecord {
#[primary_key]
treasure_record_id: String,
runtime_session_id: String,
story_session_id: String,
actor_user_id: String,
encounter_id: String,
encounter_name: String,
scene_id: Option<String>,
scene_name: Option<String>,
action: TreasureInteractionAction,
reward_items: Vec<RuntimeItemRewardItemSnapshot>,
reward_hp: u32,
reward_mana: u32,
reward_currency: u32,
story_hint: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = quest_record,
index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_actor_user_id, btree(columns = [actor_user_id])),
index(accessor = by_issuer_npc_id, btree(columns = [issuer_npc_id]))
)]
pub struct QuestRecord {
#[primary_key]
quest_id: String,
runtime_session_id: String,
story_session_id: Option<String>,
actor_user_id: String,
issuer_npc_id: String,
issuer_npc_name: String,
scene_id: Option<String>,
chapter_id: Option<String>,
act_id: Option<String>,
thread_id: Option<String>,
contract_id: Option<String>,
title: String,
description: String,
summary: String,
objective: QuestObjectiveSnapshot,
progress: u32,
status: QuestStatus,
completion_notified: bool,
reward: QuestRewardSnapshot,
reward_text: String,
narrative_binding: QuestNarrativeBindingSnapshot,
steps: Vec<QuestStepSnapshot>,
active_step_id: Option<String>,
visible_stage: u32,
hidden_flags: Vec<String>,
discovered_fact_ids: Vec<String>,
related_carrier_ids: Vec<String>,
consequence_ids: Vec<String>,
created_at: Timestamp,
updated_at: Timestamp,
completed_at: Option<Timestamp>,
turned_in_at: Option<Timestamp>,
}
#[spacetimedb::table(
accessor = quest_log,
index(accessor = by_quest_id, btree(columns = [quest_id])),
index(accessor = by_runtime_session_id, btree(columns = [runtime_session_id])),
index(accessor = by_actor_user_id, btree(columns = [actor_user_id]))
)]
pub struct QuestLog {
#[primary_key]
log_id: String,
quest_id: String,
runtime_session_id: String,
actor_user_id: String,
event_kind: QuestLogEventKind,
status_after: QuestStatus,
signal_kind: Option<QuestSignalKind>,
signal: Option<QuestProgressSignal>,
step_id: Option<String>,
step_progress: Option<u32>,
created_at: Timestamp,
}
#[spacetimedb::table(
accessor = custom_world_profile,
index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])),
index(
accessor = by_custom_world_profile_publication_status,
btree(columns = [publication_status])
)
)]
pub struct CustomWorldProfile {
#[primary_key]
profile_id: String,
// 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。
owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,
world_name: String,
subtitle: String,
summary_text: String,
theme_mode: CustomWorldThemeMode,
cover_image_src: Option<String>,
profile_payload_json: String,
playable_npc_count: u32,
landmark_count: u32,
author_display_name: String,
published_at: Option<Timestamp>,
// 软删除后保留 profile 真相,供审计与幂等删除使用。
deleted_at: Option<Timestamp>,
created_at: Timestamp,
updated_at: Timestamp,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
#[default(0)]
play_count: u32,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
#[spacetimedb::table(
accessor = custom_world_session,
index(accessor = by_custom_world_session_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct CustomWorldSession {
#[primary_key]
session_id: String,
// 这张表只承接旧 custom-world/sessions 传统问答流,不和 agent 会话混存。
owner_user_id: String,
generation_mode: CustomWorldGenerationMode,
status: CustomWorldSessionStatus,
setting_text: String,
creator_intent_json: Option<String>,
question_snapshot_json: String,
result_payload_json: Option<String>,
last_error_message: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = custom_world_agent_session,
index(
accessor = by_custom_world_agent_session_owner_user_id,
btree(columns = [owner_user_id])
),
index(accessor = by_custom_world_agent_session_stage, btree(columns = [stage]))
)]
pub struct CustomWorldAgentSession {
#[primary_key]
session_id: String,
// Agent 会话只保留会话级聚合字段,消息、操作、卡片都拆到独立表。
owner_user_id: String,
seed_text: String,
current_turn: u32,
progress_percent: u32,
stage: RpgAgentStage,
focus_card_id: Option<String>,
anchor_content_json: String,
creator_intent_json: Option<String>,
creator_intent_readiness_json: String,
anchor_pack_json: Option<String>,
lock_state_json: Option<String>,
draft_profile_json: Option<String>,
last_assistant_reply: Option<String>,
publish_gate_json: Option<String>,
result_preview_json: Option<String>,
pending_clarifications_json: String,
quality_findings_json: String,
suggested_actions_json: String,
recommended_replies_json: String,
asset_coverage_json: String,
checkpoints_json: String,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = custom_world_agent_message,
index(accessor = by_custom_world_agent_message_session_id, btree(columns = [session_id]))
)]
pub struct CustomWorldAgentMessage {
#[primary_key]
message_id: String,
// 消息流水单独成表,避免继续塞回 session 大 JSON。
session_id: String,
role: RpgAgentMessageRole,
kind: RpgAgentMessageKind,
text: String,
related_operation_id: Option<String>,
created_at: Timestamp,
}
#[derive(Clone)]
#[spacetimedb::table(
accessor = custom_world_agent_operation,
index(accessor = by_custom_world_agent_operation_session_id, btree(columns = [session_id]))
)]
pub struct CustomWorldAgentOperation {
#[primary_key]
operation_id: String,
// 异步操作单独建表,为 message stream / operation query 提供真相源。
session_id: String,
operation_type: RpgAgentOperationType,
status: RpgAgentOperationStatus,
phase_label: String,
phase_detail: String,
progress: u32,
error_message: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = custom_world_draft_card,
index(accessor = by_custom_world_draft_card_session_id, btree(columns = [session_id])),
index(accessor = by_custom_world_draft_card_kind, btree(columns = [kind]))
)]
pub struct CustomWorldDraftCard {
#[primary_key]
card_id: String,
// 卡片实体从 agent session 拆出,后续 detail / update 都直接对这张表操作。
session_id: String,
kind: RpgAgentDraftCardKind,
status: RpgAgentDraftCardStatus,
title: String,
subtitle: String,
summary: String,
linked_ids_json: String,
warning_count: u32,
asset_status: Option<CustomWorldRoleAssetStatus>,
asset_status_label: Option<String>,
detail_payload_json: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = custom_world_gallery_entry,
public,
index(accessor = by_custom_world_gallery_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_custom_world_gallery_theme_mode, btree(columns = [theme_mode])),
index(accessor = by_custom_world_gallery_public_work_code, btree(columns = [public_work_code]))
)]
pub struct CustomWorldGalleryEntry {
#[primary_key]
profile_id: String,
// 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。
owner_user_id: String,
public_work_code: String,
author_public_user_code: String,
author_display_name: String,
world_name: String,
subtitle: String,
summary_text: String,
cover_image_src: Option<String>,
theme_mode: CustomWorldThemeMode,
playable_npc_count: u32,
landmark_count: u32,
published_at: Timestamp,
updated_at: Timestamp,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
#[default(0)]
play_count: u32,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
// 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。
#[spacetimedb::procedure]
pub fn get_player_progression_or_default(
ctx: &mut ProcedureContext,
input: PlayerProgressionGetInput,
) -> PlayerProgressionProcedureResult {
match ctx.try_with_tx(|tx| get_player_progression_snapshot_tx(tx, input.clone())) {
Ok(record) => PlayerProgressionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => PlayerProgressionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 经验发放统一走 progression reducer避免任务和战斗各自直接写等级字段。
#[spacetimedb::reducer]
pub fn grant_player_progression_experience(
ctx: &ReducerContext,
input: PlayerProgressionGrantInput,
) -> Result<(), String> {
upsert_player_progression_after_grant_tx(ctx, input).map(|_| ())
}
#[spacetimedb::procedure]
pub fn grant_player_progression_experience_and_return(
ctx: &mut ProcedureContext,
input: PlayerProgressionGrantInput,
) -> PlayerProgressionProcedureResult {
match ctx.try_with_tx(|tx| upsert_player_progression_after_grant_tx(tx, input.clone())) {
Ok(record) => PlayerProgressionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => PlayerProgressionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 章节计划在进入章节或编译章节预算时写入;当前先用单表同时承接计划值与实际记账值。
#[spacetimedb::reducer]
pub fn upsert_chapter_progression(
ctx: &ReducerContext,
input: ChapterProgressionInput,
) -> Result<(), String> {
upsert_chapter_progression_snapshot_tx(ctx, input).map(|_| ())
}
#[spacetimedb::procedure]
pub fn upsert_chapter_progression_and_return(
ctx: &mut ProcedureContext,
input: ChapterProgressionInput,
) -> ChapterProgressionProcedureResult {
match ctx.try_with_tx(|tx| upsert_chapter_progression_snapshot_tx(tx, input.clone())) {
Ok(record) => ChapterProgressionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => ChapterProgressionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 章节实际经验与击杀记账后续由 quest/combat 联动调用,这一轮先把真相写入口固定下来。
#[spacetimedb::reducer]
pub fn apply_chapter_progression_ledger_entry(
ctx: &ReducerContext,
input: ChapterProgressionLedgerInput,
) -> Result<(), String> {
update_chapter_progression_ledger_tx(ctx, input).map(|_| ())
}
#[spacetimedb::procedure]
pub fn apply_chapter_progression_ledger_entry_and_return(
ctx: &mut ProcedureContext,
input: ChapterProgressionLedgerInput,
) -> ChapterProgressionProcedureResult {
match ctx.try_with_tx(|tx| update_chapter_progression_ledger_tx(tx, input.clone())) {
Ok(record) => ChapterProgressionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => ChapterProgressionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_chapter_progression(
ctx: &mut ProcedureContext,
input: ChapterProgressionGetInput,
) -> ChapterProgressionProcedureResult {
match ctx.try_with_tx(|tx| get_chapter_progression_snapshot_tx(tx, input.clone())) {
Ok(record) => ChapterProgressionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => ChapterProgressionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 当前阶段先把 inventory_slot 立成显式背包真相表,避免继续由多个 service 各自改 runtime snapshot JSON。
#[spacetimedb::reducer]
pub fn apply_inventory_mutation(
ctx: &ReducerContext,
input: InventoryMutationInput,
) -> Result<(), String> {
apply_inventory_mutation_tx(ctx, input)
}
fn apply_inventory_mutation_tx(
ctx: &ReducerContext,
input: InventoryMutationInput,
) -> Result<(), String> {
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
})
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
let outcome =
apply_inventory_slot_mutation(current_slots, input).map_err(|error| error.to_string())?;
for removed_slot_id in outcome.removed_slot_ids {
ctx.db.inventory_slot().slot_id().delete(&removed_slot_id);
}
for slot in outcome.next_slots {
ctx.db.inventory_slot().slot_id().delete(&slot.slot_id);
ctx.db
.inventory_slot()
.insert(build_inventory_slot_row(slot));
}
Ok(())
}
// procedure 面向 Axum 同步读取当前 runtime_session 下某个玩家的背包真相态。
#[spacetimedb::procedure]
pub fn get_runtime_inventory_state(
ctx: &mut ProcedureContext,
input: RuntimeInventoryStateQueryInput,
) -> RuntimeInventoryStateProcedureResult {
match ctx.try_with_tx(|tx| get_runtime_inventory_state_tx(tx, input.clone())) {
Ok(snapshot) => RuntimeInventoryStateProcedureResult {
ok: true,
snapshot: Some(snapshot),
error_message: None,
},
Err(message) => RuntimeInventoryStateProcedureResult {
ok: false,
snapshot: None,
error_message: Some(message),
},
}
}
// M4 首轮先把 battle_state 作为战斗真相源落到 SpacetimeDB避免继续把战斗状态埋在 runtime JSON 里。
#[spacetimedb::reducer]
pub fn create_battle_state(ctx: &ReducerContext, input: BattleStateInput) -> Result<(), String> {
create_battle_state_record(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步创建 battle_state返回当前最新战斗快照避免 HTTP 层再次读取 private table。
#[spacetimedb::procedure]
pub fn create_battle_state_and_return(
ctx: &mut ProcedureContext,
input: BattleStateInput,
) -> BattleStateProcedureResult {
match ctx.try_with_tx(|tx| create_battle_state_record(tx, input.clone())) {
Ok(snapshot) => BattleStateProcedureResult {
ok: true,
snapshot: Some(snapshot),
error_message: None,
},
Err(message) => BattleStateProcedureResult {
ok: false,
snapshot: None,
error_message: Some(message),
},
}
}
// procedure 面向 Axum 读取单个 battle_state 真相态,当前只返回最新战斗快照。
#[spacetimedb::procedure]
pub fn get_battle_state(
ctx: &mut ProcedureContext,
input: BattleStateQueryInput,
) -> BattleStateProcedureResult {
match ctx.try_with_tx(|tx| get_battle_state_record(tx, input.clone())) {
Ok(snapshot) => BattleStateProcedureResult {
ok: true,
snapshot: Some(snapshot),
error_message: None,
},
Err(message) => BattleStateProcedureResult {
ok: false,
snapshot: None,
error_message: Some(message),
},
}
}
// M4 首轮只承接单行为战斗推进,不提前把 inventory / progression / story AI 续写耦进 reducer。
#[spacetimedb::reducer]
pub fn resolve_combat_action(
ctx: &ReducerContext,
input: ResolveCombatActionInput,
) -> Result<(), String> {
resolve_battle_state_record(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步推进单次战斗动作,返回本次结算结果与 battle_state 最新快照。
#[spacetimedb::procedure]
pub fn resolve_combat_action_and_return(
ctx: &mut ProcedureContext,
input: ResolveCombatActionInput,
) -> ResolveCombatActionProcedureResult {
match ctx.try_with_tx(|tx| resolve_battle_state_record(tx, input.clone())) {
Ok(result) => ResolveCombatActionProcedureResult {
ok: true,
result: Some(result),
error_message: None,
},
Err(message) => ResolveCombatActionProcedureResult {
ok: false,
result: None,
error_message: Some(message),
},
}
}
fn create_battle_state_record(
ctx: &ReducerContext,
input: BattleStateInput,
) -> Result<BattleStateSnapshot, String> {
validate_battle_state_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.battle_state()
.battle_state_id()
.find(&input.battle_state_id)
.is_some()
{
return Err("battle_state.battle_state_id 已存在".to_string());
}
let snapshot = build_battle_state_snapshot(input);
ctx.db
.battle_state()
.insert(build_battle_state_row(snapshot.clone()));
Ok(snapshot)
}
fn get_battle_state_record(
ctx: &ReducerContext,
input: BattleStateQueryInput,
) -> Result<BattleStateSnapshot, String> {
validate_battle_state_query_input(&input).map_err(|error| error.to_string())?;
let row = ctx
.db
.battle_state()
.battle_state_id()
.find(&input.battle_state_id)
.ok_or_else(|| "battle_state 不存在".to_string())?;
Ok(build_battle_state_snapshot_from_row(&row))
}
fn get_runtime_inventory_state_tx(
ctx: &ReducerContext,
input: RuntimeInventoryStateQueryInput,
) -> Result<RuntimeInventoryStateSnapshot, String> {
let validated_input =
build_runtime_inventory_state_query_input(input.runtime_session_id, input.actor_user_id)
.map_err(|error| error.to_string())?;
// 这层只返回 inventory_slot 真相表的最小切片,不混入 story、quest、battle 的额外投影。
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
})
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
Ok(build_runtime_inventory_state_snapshot(
validated_input,
slots,
))
}
fn resolve_battle_state_record(
ctx: &ReducerContext,
input: ResolveCombatActionInput,
) -> Result<module_combat::ResolveCombatActionResult, String> {
let current = ctx
.db
.battle_state()
.battle_state_id()
.find(&input.battle_state_id)
.ok_or_else(|| "battle_state 不存在,无法执行战斗动作".to_string())?;
let result = resolve_battle_state_action(build_battle_state_snapshot_from_row(&current), input)
.map_err(|error| error.to_string())?;
ctx.db
.battle_state()
.battle_state_id()
.delete(&current.battle_state_id);
ctx.db
.battle_state()
.insert(build_battle_state_row(result.snapshot.clone()));
if result.outcome == CombatOutcome::Victory {
grant_battle_reward_items(ctx, &result.snapshot)?;
if result.snapshot.experience_reward > 0 {
let updated_player = upsert_player_progression_after_grant_tx(
ctx,
PlayerProgressionGrantInput {
user_id: result.snapshot.actor_user_id.clone(),
amount: result.snapshot.experience_reward,
source: PlayerProgressionGrantSource::HostileNpc,
updated_at_micros: result.snapshot.updated_at_micros,
},
)?;
// 章节计划可能尚未初始化;此时不能阻断战斗胜利结算,只跳过章节账本写入。
try_update_chapter_progression_ledger_tx(
ctx,
result.snapshot.actor_user_id.clone(),
result.snapshot.chapter_id.clone(),
ChapterProgressionLedgerInput {
user_id: result.snapshot.actor_user_id.clone(),
chapter_id: result.snapshot.chapter_id.clone().unwrap_or_default(),
granted_quest_xp: 0,
granted_hostile_xp: result.snapshot.experience_reward,
hostile_defeat_increment: 1,
level_at_exit: Some(updated_player.level),
updated_at_micros: result.snapshot.updated_at_micros,
},
)?;
}
}
Ok(result)
}
// 当前阶段先把 npc_state 立成显式真相表,避免继续把关系状态藏在运行时 JSON 快照里。
#[spacetimedb::reducer]
pub fn upsert_npc_state(ctx: &ReducerContext, input: NpcStateUpsertInput) -> Result<(), String> {
upsert_npc_state_record(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步 upsert 接口,返回最新 NPC 状态快照。
#[spacetimedb::procedure]
pub fn upsert_npc_state_and_return(
ctx: &mut ProcedureContext,
input: NpcStateUpsertInput,
) -> NpcStateProcedureResult {
match ctx.try_with_tx(|tx| upsert_npc_state_record(tx, input.clone())) {
Ok(record) => NpcStateProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => NpcStateProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 当前阶段只承接 NPC 关系状态的最小社交动作,不提前把背包、战斗和队伍副作用也塞进来。
#[spacetimedb::reducer]
pub fn resolve_npc_social_action(
ctx: &ReducerContext,
input: ResolveNpcSocialActionInput,
) -> Result<(), String> {
resolve_npc_social_action_record(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步社交动作接口,返回动作后的 NPC 状态快照。
#[spacetimedb::procedure]
pub fn resolve_npc_social_action_and_return(
ctx: &mut ProcedureContext,
input: ResolveNpcSocialActionInput,
) -> NpcStateProcedureResult {
match ctx.try_with_tx(|tx| resolve_npc_social_action_record(tx, input.clone())) {
Ok(record) => NpcStateProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => NpcStateProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 当前阶段先冻结 NPC 正式交互统一入口,不直接在这里扩出队伍、战斗、背包等跨子域副作用。
#[spacetimedb::reducer]
pub fn resolve_npc_interaction(
ctx: &ReducerContext,
input: ResolveNpcInteractionInput,
) -> Result<(), String> {
resolve_npc_interaction_record(ctx, input).map(|_| ())
}
#[spacetimedb::procedure]
pub fn resolve_npc_interaction_and_return(
ctx: &mut ProcedureContext,
input: ResolveNpcInteractionInput,
) -> NpcInteractionProcedureResult {
match ctx.try_with_tx(|tx| resolve_npc_interaction_record(tx, input.clone())) {
Ok(result) => NpcInteractionProcedureResult {
ok: true,
result: Some(result),
error_message: None,
},
Err(message) => NpcInteractionProcedureResult {
ok: false,
result: None,
error_message: Some(message),
},
}
}
// fight / spar 的 battle_state 初始化属于聚合层编排,不回灌到 module-npc 纯领域 crate。
#[spacetimedb::procedure]
pub fn resolve_npc_battle_interaction_and_return(
ctx: &mut ProcedureContext,
input: ResolveNpcBattleInteractionInput,
) -> NpcBattleInteractionProcedureResult {
match ctx.try_with_tx(|tx| resolve_npc_battle_interaction_tx(tx, input.clone())) {
Ok(result) => NpcBattleInteractionProcedureResult {
ok: true,
result: Some(result),
error_message: None,
},
Err(message) => NpcBattleInteractionProcedureResult {
ok: false,
result: None,
error_message: Some(message),
},
}
}
// M4 首轮先把 story_session / story_event 作为显式会话真相源落到 SpacetimeDB避免后续继续依赖大 JSON 覆盖式写法。
#[spacetimedb::reducer]
pub fn begin_story_session(ctx: &ReducerContext, input: StorySessionInput) -> Result<(), String> {
begin_story_session_tx(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步创建故事会话,返回最新会话快照与开场事件,避免 HTTP 层再读 private table。
#[spacetimedb::procedure]
pub fn begin_story_session_and_return(
ctx: &mut ProcedureContext,
input: StorySessionInput,
) -> StorySessionProcedureResult {
match ctx.try_with_tx(|tx| begin_story_session_tx(tx, input.clone())) {
Ok((session, event)) => StorySessionProcedureResult {
ok: true,
session: Some(session),
event: Some(event),
error_message: None,
},
Err(message) => StorySessionProcedureResult {
ok: false,
session: None,
event: None,
error_message: Some(message),
},
}
}
fn begin_story_session_tx(
ctx: &ReducerContext,
input: StorySessionInput,
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> {
validate_story_session_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.is_some()
{
return Err("story_session.story_session_id 已存在".to_string());
}
let snapshot = build_story_session_snapshot(input);
let started_event = build_story_started_event(&snapshot);
let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros);
let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros);
ctx.db.story_session().insert(StorySession {
story_session_id: snapshot.story_session_id.clone(),
runtime_session_id: snapshot.runtime_session_id.clone(),
actor_user_id: snapshot.actor_user_id.clone(),
world_profile_id: snapshot.world_profile_id.clone(),
initial_prompt: snapshot.initial_prompt.clone(),
opening_summary: snapshot.opening_summary.clone(),
latest_narrative_text: snapshot.latest_narrative_text.clone(),
latest_choice_function_id: snapshot.latest_choice_function_id.clone(),
status: snapshot.status,
version: snapshot.version,
created_at,
updated_at,
});
ctx.db.story_event().insert(StoryEvent {
event_id: started_event.event_id.clone(),
story_session_id: started_event.story_session_id.clone(),
event_kind: started_event.event_kind,
narrative_text: started_event.narrative_text.clone(),
choice_function_id: started_event.choice_function_id.clone(),
created_at,
});
Ok((snapshot, started_event))
}
// M4 首轮继续把“故事推进”固定为事件追加 + 会话版本递增,为后续 resolve_story_action 接线提供最小基座。
#[spacetimedb::reducer]
pub fn continue_story(ctx: &ReducerContext, input: StoryContinueInput) -> Result<(), String> {
continue_story_tx(ctx, input).map(|_| ())
}
// procedure 面向 Axum 同步推进故事会话,返回最新会话快照与本次事件,避免 HTTP 层再读 private table。
#[spacetimedb::procedure]
pub fn continue_story_and_return(
ctx: &mut ProcedureContext,
input: StoryContinueInput,
) -> StorySessionProcedureResult {
match ctx.try_with_tx(|tx| continue_story_tx(tx, input.clone())) {
Ok((session, event)) => StorySessionProcedureResult {
ok: true,
session: Some(session),
event: Some(event),
error_message: None,
},
Err(message) => StorySessionProcedureResult {
ok: false,
session: None,
event: None,
error_message: Some(message),
},
}
}
// procedure 面向 Axum 读取指定 story session 的最小真实状态,当前只返回 session + event 列表。
#[spacetimedb::procedure]
pub fn get_story_session_state(
ctx: &mut ProcedureContext,
input: StorySessionStateInput,
) -> StorySessionStateProcedureResult {
match ctx.try_with_tx(|tx| get_story_session_state_tx(tx, input.clone())) {
Ok((session, events)) => StorySessionStateProcedureResult {
ok: true,
session: Some(session),
events,
error_message: None,
},
Err(message) => StorySessionStateProcedureResult {
ok: false,
session: None,
events: Vec::new(),
error_message: Some(message),
},
}
}
// Stage 6 先把 Agent 会话骨架写入 SpacetimeDBLLM 采集与卡片生成后续再接入。
#[spacetimedb::procedure]
pub fn create_custom_world_agent_session(
ctx: &mut ProcedureContext,
input: CustomWorldAgentSessionCreateInput,
) -> CustomWorldAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| create_custom_world_agent_session_tx(tx, input.clone())) {
Ok(session) => CustomWorldAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => CustomWorldAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
// Stage 6 读取拆表后的最小 Agent session snapshot供 Axum 兼容旧前端 contract。
#[spacetimedb::procedure]
pub fn get_custom_world_agent_session(
ctx: &mut ProcedureContext,
input: CustomWorldAgentSessionGetInput,
) -> CustomWorldAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| get_custom_world_agent_session_tx(tx, input.clone())) {
Ok(session) => CustomWorldAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => CustomWorldAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn delete_custom_world_agent_session(
ctx: &mut ProcedureContext,
input: CustomWorldAgentSessionGetInput,
) -> CustomWorldWorksListResult {
match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) {
Ok(items) => CustomWorldWorksListResult {
ok: true,
items,
error_message: None,
},
Err(message) => CustomWorldWorksListResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_custom_world_agent_message(
ctx: &mut ProcedureContext,
input: CustomWorldAgentMessageSubmitInput,
) -> CustomWorldAgentOperationProcedureResult {
match ctx.try_with_tx(|tx| submit_custom_world_agent_message_tx(tx, input.clone())) {
Ok(operation) => CustomWorldAgentOperationProcedureResult {
ok: true,
operation: Some(operation),
error_message: None,
},
Err(message) => CustomWorldAgentOperationProcedureResult {
ok: false,
operation: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn finalize_custom_world_agent_message_turn(
ctx: &mut ProcedureContext,
input: CustomWorldAgentMessageFinalizeInput,
) -> CustomWorldAgentOperationProcedureResult {
match ctx.try_with_tx(|tx| finalize_custom_world_agent_message_turn_tx(tx, input.clone())) {
Ok(operation) => CustomWorldAgentOperationProcedureResult {
ok: true,
operation: Some(operation),
error_message: None,
},
Err(message) => CustomWorldAgentOperationProcedureResult {
ok: false,
operation: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_custom_world_agent_operation(
ctx: &mut ProcedureContext,
input: CustomWorldAgentOperationGetInput,
) -> CustomWorldAgentOperationProcedureResult {
match ctx.try_with_tx(|tx| get_custom_world_agent_operation_tx(tx, input.clone())) {
Ok(operation) => CustomWorldAgentOperationProcedureResult {
ok: true,
operation: Some(operation),
error_message: None,
},
Err(message) => CustomWorldAgentOperationProcedureResult {
ok: false,
operation: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn upsert_custom_world_agent_operation_progress(
ctx: &mut ProcedureContext,
input: CustomWorldAgentOperationProgressInput,
) -> CustomWorldAgentOperationProcedureResult {
match ctx.try_with_tx(|tx| upsert_custom_world_agent_operation_progress_tx(tx, input.clone())) {
Ok(operation) => CustomWorldAgentOperationProcedureResult {
ok: true,
operation: Some(operation),
error_message: None,
},
Err(message) => CustomWorldAgentOperationProcedureResult {
ok: false,
operation: None,
error_message: Some(message),
},
}
}
fn continue_story_tx(
ctx: &ReducerContext,
input: StoryContinueInput,
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> {
validate_story_continue_input(&input).map_err(|error| error.to_string())?;
let current = ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?;
let current_snapshot = StorySessionSnapshot {
story_session_id: current.story_session_id.clone(),
runtime_session_id: current.runtime_session_id.clone(),
actor_user_id: current.actor_user_id.clone(),
world_profile_id: current.world_profile_id.clone(),
initial_prompt: current.initial_prompt.clone(),
opening_summary: current.opening_summary.clone(),
latest_narrative_text: current.latest_narrative_text.clone(),
latest_choice_function_id: current.latest_choice_function_id.clone(),
status: current.status,
version: current.version,
created_at_micros: current.created_at.to_micros_since_unix_epoch(),
updated_at_micros: current.updated_at.to_micros_since_unix_epoch(),
};
let (next_snapshot, event_snapshot) =
apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?;
ctx.db
.story_session()
.story_session_id()
.delete(&current.story_session_id);
ctx.db.story_session().insert(StorySession {
story_session_id: next_snapshot.story_session_id.clone(),
runtime_session_id: next_snapshot.runtime_session_id.clone(),
actor_user_id: next_snapshot.actor_user_id.clone(),
world_profile_id: next_snapshot.world_profile_id.clone(),
initial_prompt: next_snapshot.initial_prompt.clone(),
opening_summary: next_snapshot.opening_summary.clone(),
latest_narrative_text: next_snapshot.latest_narrative_text.clone(),
latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(),
status: next_snapshot.status,
version: next_snapshot.version,
created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros),
});
ctx.db.story_event().insert(StoryEvent {
event_id: event_snapshot.event_id.clone(),
story_session_id: event_snapshot.story_session_id.clone(),
event_kind: event_snapshot.event_kind,
narrative_text: event_snapshot.narrative_text.clone(),
choice_function_id: event_snapshot.choice_function_id.clone(),
created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros),
});
Ok((next_snapshot, event_snapshot))
}
fn get_story_session_state_tx(
ctx: &ReducerContext,
input: StorySessionStateInput,
) -> Result<(StorySessionSnapshot, Vec<StoryEventSnapshot>), String> {
validate_story_session_state_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.ok_or_else(|| "story_session 不存在".to_string())?;
let session_snapshot = build_story_session_snapshot_from_row(&session);
let mut events = ctx
.db
.story_event()
.iter()
.filter(|row| row.story_session_id == input.story_session_id)
.map(|row| build_story_event_snapshot_from_row(&row))
.collect::<Vec<_>>();
events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone()));
Ok((session_snapshot, events))
}
fn create_custom_world_agent_session_tx(
ctx: &ReducerContext,
input: CustomWorldAgentSessionCreateInput,
) -> Result<CustomWorldAgentSessionSnapshot, String> {
validate_custom_world_agent_session_create_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.is_some()
{
return Err("custom_world_agent_session.session_id 已存在".to_string());
}
if ctx
.db
.custom_world_agent_message()
.message_id()
.find(&input.welcome_message_id)
.is_some()
{
return Err("custom_world_agent_message.message_id 已存在".to_string());
}
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
ctx.db
.custom_world_agent_session()
.insert(CustomWorldAgentSession {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
seed_text: input.seed_text.trim().to_string(),
current_turn: 0,
progress_percent: 0,
stage: RpgAgentStage::CollectingIntent,
focus_card_id: None,
anchor_content_json: input.anchor_content_json.clone(),
creator_intent_json: input.creator_intent_json.clone(),
creator_intent_readiness_json: input.creator_intent_readiness_json.clone(),
anchor_pack_json: input.anchor_pack_json.clone(),
lock_state_json: input.lock_state_json.clone(),
draft_profile_json: input.draft_profile_json.clone(),
last_assistant_reply: Some(input.welcome_message_text.trim().to_string()),
publish_gate_json: None,
result_preview_json: None,
pending_clarifications_json: input.pending_clarifications_json.clone(),
quality_findings_json: input.quality_findings_json.clone(),
suggested_actions_json: input.suggested_actions_json.clone(),
recommended_replies_json: input.recommended_replies_json.clone(),
asset_coverage_json: input.asset_coverage_json.clone(),
checkpoints_json: input.checkpoints_json.clone(),
created_at,
updated_at: created_at,
});
ctx.db
.custom_world_agent_message()
.insert(CustomWorldAgentMessage {
message_id: input.welcome_message_id,
session_id: input.session_id.clone(),
role: RpgAgentMessageRole::Assistant,
kind: RpgAgentMessageKind::Chat,
text: input.welcome_message_text.trim().to_string(),
related_operation_id: None,
created_at,
});
get_custom_world_agent_session_tx(
ctx,
CustomWorldAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn get_custom_world_agent_session_tx(
ctx: &ReducerContext,
input: CustomWorldAgentSessionGetInput,
) -> Result<CustomWorldAgentSessionSnapshot, String> {
validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
Ok(build_custom_world_agent_session_snapshot(ctx, &session))
}
fn delete_custom_world_agent_session_tx(
ctx: &ReducerContext,
input: CustomWorldAgentSessionGetInput,
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, String> {
validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if session.stage == RpgAgentStage::Published {
let published_profile = ctx
.db
.custom_world_profile()
.iter()
.find(|row| {
row.owner_user_id == input.owner_user_id
&& row.source_agent_session_id.as_deref() == Some(input.session_id.as_str())
&& row.deleted_at.is_none()
})
.ok_or_else(|| "已发布 RPG 作品缺少关联 profile无法删除".to_string())?;
// 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品”
// 收敛为 profile 软删除,避免前端误入草稿删除接口时继续暴露 procedure 分叉。
delete_custom_world_profile_record(
ctx,
CustomWorldProfileDeleteInput {
profile_id: published_profile.profile_id,
owner_user_id: input.owner_user_id.clone(),
deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)?;
return list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
);
}
// 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。
ctx.db
.custom_world_agent_session()
.session_id()
.delete(&session.session_id);
for message in ctx
.db
.custom_world_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_agent_message()
.message_id()
.delete(&message.message_id);
}
for operation in ctx
.db
.custom_world_agent_operation()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_agent_operation()
.operation_id()
.delete(&operation.operation_id);
}
for card in ctx
.db
.custom_world_draft_card()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_draft_card()
.card_id()
.delete(&card.card_id);
}
list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
fn submit_custom_world_agent_message_tx(
ctx: &ReducerContext,
input: CustomWorldAgentMessageSubmitInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_message_submit_input(&input).map_err(|error| error.to_string())?;
if input.user_message_text.contains("__phase1_force_fail__") {
return Err("forced failure".to_string());
}
let _session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if ctx
.db
.custom_world_agent_message()
.message_id()
.find(&input.user_message_id)
.is_some()
{
return Err("custom_world_agent_message.message_id 已存在".to_string());
}
if ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
.is_some()
{
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
}
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
let user_message_text = input.user_message_text.trim().to_string();
ctx.db
.custom_world_agent_message()
.insert(CustomWorldAgentMessage {
message_id: input.user_message_id,
session_id: input.session_id.clone(),
role: RpgAgentMessageRole::User,
kind: RpgAgentMessageKind::Chat,
text: user_message_text,
related_operation_id: Some(input.operation_id.clone()),
created_at: submitted_at,
});
ctx.db
.custom_world_agent_operation()
.insert(CustomWorldAgentOperation {
operation_id: input.operation_id.clone(),
session_id: input.session_id.clone(),
operation_type: RpgAgentOperationType::ProcessMessage,
status: RpgAgentOperationStatus::Running,
phase_label: "消息处理中".to_string(),
phase_detail: "已记录用户消息,等待大模型生成本轮回复。".to_string(),
progress: 10,
error_message: None,
created_at: submitted_at,
updated_at: submitted_at,
});
get_custom_world_agent_operation_tx(
ctx,
CustomWorldAgentOperationGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
operation_id: input.operation_id,
},
)
}
fn get_custom_world_agent_operation_tx(
ctx: &ReducerContext,
input: CustomWorldAgentOperationGetInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_operation_get_input(&input).map_err(|error| error.to_string())?;
ctx.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
let operation = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
.filter(|row| row.session_id == input.session_id)
.ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn upsert_custom_world_agent_operation_progress_tx(
ctx: &ReducerContext,
input: CustomWorldAgentOperationProgressInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_operation_progress_input(&input)
.map_err(|error| error.to_string())?;
ctx.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
let timestamp = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let operation = if let Some(current) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
{
if current.session_id != input.session_id {
return Err("custom_world_agent_operation.session_id 不匹配".to_string());
}
let next = rebuild_custom_world_agent_operation_row(
&current,
CustomWorldAgentOperationPatch {
status: Some(input.operation_status),
phase_label: Some(input.phase_label.clone()),
phase_detail: Some(input.phase_detail.clone()),
progress: Some(input.operation_progress),
error_message: Some(input.error_message.clone()),
updated_at_micros: Some(input.updated_at_micros),
},
)?;
replace_custom_world_agent_operation(ctx, &current, next.clone());
next
} else {
ctx.db
.custom_world_agent_operation()
.insert(CustomWorldAgentOperation {
operation_id: input.operation_id.clone(),
session_id: input.session_id.clone(),
operation_type: input.operation_type,
status: input.operation_status,
phase_label: input.phase_label.clone(),
phase_detail: input.phase_detail.clone(),
progress: input.operation_progress,
error_message: input.error_message.clone(),
created_at: timestamp,
updated_at: timestamp,
})
};
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn finalize_custom_world_agent_message_turn_tx(
ctx: &ReducerContext,
input: CustomWorldAgentMessageFinalizeInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_message_finalize_input(&input)
.map_err(|error| error.to_string())?;
let session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
let operation = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
.filter(|row| row.session_id == input.session_id)
.ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let next_session = if input.operation_status == RpgAgentOperationStatus::Failed {
rebuild_custom_world_agent_session_row(
&session,
CustomWorldAgentSessionPatch {
updated_at_micros: Some(input.updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?
} else {
let assistant_message_id = input.assistant_message_id.clone().ok_or_else(|| {
"custom_world_agent_message.assistant_message_id 不能为空".to_string()
})?;
let assistant_reply_text = input
.assistant_reply_text
.clone()
.ok_or_else(|| "custom_world_agent_message.text 不能为空".to_string())?;
if ctx
.db
.custom_world_agent_message()
.message_id()
.find(&assistant_message_id)
.is_some()
{
return Err("custom_world_agent_message.assistant_message_id 已存在".to_string());
}
ctx.db
.custom_world_agent_message()
.insert(CustomWorldAgentMessage {
message_id: assistant_message_id,
session_id: input.session_id.clone(),
role: RpgAgentMessageRole::Assistant,
kind: RpgAgentMessageKind::Chat,
text: assistant_reply_text.clone(),
related_operation_id: Some(input.operation_id.clone()),
created_at: updated_at,
});
rebuild_custom_world_agent_session_row(
&session,
CustomWorldAgentSessionPatch {
current_turn: Some(session.current_turn.saturating_add(1)),
progress_percent: Some(input.progress_percent),
stage: Some(input.stage),
focus_card_id: Some(input.focus_card_id.clone()),
anchor_content_json: Some(input.anchor_content_json.clone()),
creator_intent_json: Some(input.creator_intent_json.clone()),
creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()),
anchor_pack_json: Some(input.anchor_pack_json.clone()),
draft_profile_json: Some(input.draft_profile_json.clone()),
last_assistant_reply: Some(Some(assistant_reply_text)),
pending_clarifications_json: Some(input.pending_clarifications_json.clone()),
quality_findings_json: Some(input.quality_findings_json.clone()),
suggested_actions_json: Some(input.suggested_actions_json.clone()),
recommended_replies_json: Some(input.recommended_replies_json.clone()),
asset_coverage_json: Some(input.asset_coverage_json.clone()),
updated_at_micros: Some(input.updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?
};
replace_custom_world_agent_session(ctx, &session, next_session);
let next_operation = rebuild_custom_world_agent_operation_row(
&operation,
CustomWorldAgentOperationPatch {
status: Some(input.operation_status),
phase_label: Some(input.phase_label.clone()),
phase_detail: Some(input.phase_detail.clone()),
progress: Some(input.operation_progress),
error_message: Some(input.error_message.clone()),
updated_at_micros: Some(input.updated_at_micros),
},
)?;
replace_custom_world_agent_operation(ctx, &operation, next_operation.clone());
Ok(build_custom_world_agent_operation_snapshot(&next_operation))
}
// 当前阶段先把 quest_record / quest_log 立成最小任务真相源,后续再把奖励结算和 story action 总分发接进来。
#[spacetimedb::reducer]
pub fn accept_quest(ctx: &ReducerContext, input: QuestRecordInput) -> Result<(), String> {
let snapshot = build_quest_record_snapshot(input).map_err(|error| error.to_string())?;
if ctx
.db
.quest_record()
.quest_id()
.find(&snapshot.quest_id)
.is_some()
{
return Err("quest_record.quest_id 已存在".to_string());
}
ctx.db
.quest_record()
.insert(build_quest_record_row(snapshot.clone()));
append_quest_log(
ctx,
&snapshot,
QuestLogEventKind::Accepted,
None,
None,
None,
None,
snapshot.created_at_micros,
);
Ok(())
}
// 任务推进 reducer 只认 QuestProgressSignal不直接掺入背包、成长和关系奖励发放。
#[spacetimedb::reducer]
pub fn apply_quest_signal(
ctx: &ReducerContext,
input: QuestSignalApplyInput,
) -> Result<(), String> {
let signal = input.signal.clone();
let current = ctx
.db
.quest_record()
.quest_id()
.find(&input.quest_id)
.ok_or_else(|| "quest_record 不存在,无法应用任务信号".to_string())?;
let outcome = apply_quest_record_signal(build_quest_record_snapshot_from_row(&current), input)
.map_err(|error| error.to_string())?;
if !outcome.changed {
return Ok(());
}
ctx.db.quest_record().quest_id().delete(&current.quest_id);
ctx.db
.quest_record()
.insert(build_quest_record_row(outcome.next_record.clone()));
append_quest_log(
ctx,
&outcome.next_record,
if outcome.completed_now {
QuestLogEventKind::Completed
} else {
QuestLogEventKind::Progressed
},
Some(outcome.signal_kind),
Some(signal),
outcome.changed_step_id,
outcome.changed_step_progress,
outcome.next_record.updated_at_micros,
);
Ok(())
}
#[spacetimedb::reducer]
pub fn acknowledge_quest_completion(
ctx: &ReducerContext,
input: QuestCompletionAckInput,
) -> Result<(), String> {
let current = ctx
.db
.quest_record()
.quest_id()
.find(&input.quest_id)
.ok_or_else(|| "quest_record 不存在,无法确认完成提示".to_string())?;
let outcome =
acknowledge_quest_record_completion(build_quest_record_snapshot_from_row(&current), input)
.map_err(|error| error.to_string())?;
if !outcome.changed {
return Ok(());
}
ctx.db.quest_record().quest_id().delete(&current.quest_id);
ctx.db
.quest_record()
.insert(build_quest_record_row(outcome.next_record.clone()));
append_quest_log(
ctx,
&outcome.next_record,
QuestLogEventKind::CompletionAcknowledged,
None,
None,
None,
None,
outcome.next_record.updated_at_micros,
);
Ok(())
}
#[spacetimedb::reducer]
pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<(), String> {
let current = ctx
.db
.quest_record()
.quest_id()
.find(&input.quest_id)
.ok_or_else(|| "quest_record 不存在,无法交付任务".to_string())?;
let next = turn_in_quest_record(build_quest_record_snapshot_from_row(&current), input)
.map_err(|error| error.to_string())?;
ctx.db.quest_record().quest_id().delete(&current.quest_id);
ctx.db
.quest_record()
.insert(build_quest_record_row(next.clone()));
append_quest_log(
ctx,
&next,
QuestLogEventKind::TurnedIn,
None,
None,
None,
None,
next.updated_at_micros,
);
let reward_experience = next.reward.experience.unwrap_or(0);
grant_quest_reward_items(ctx, &next)?;
if reward_experience > 0 {
let updated_player = upsert_player_progression_after_grant_tx(
ctx,
PlayerProgressionGrantInput {
user_id: next.actor_user_id.clone(),
amount: reward_experience,
source: PlayerProgressionGrantSource::Quest,
updated_at_micros: next.updated_at_micros,
},
)?;
// 章节计划缺失时先保持任务交付成功,避免成长联动反向阻断 quest 主链。
try_update_chapter_progression_ledger_tx(
ctx,
next.actor_user_id.clone(),
next.chapter_id.clone(),
ChapterProgressionLedgerInput {
user_id: next.actor_user_id.clone(),
chapter_id: next.chapter_id.clone().unwrap_or_default(),
granted_quest_xp: reward_experience,
granted_hostile_xp: 0,
hostile_defeat_increment: 0,
level_at_exit: Some(updated_player.level),
updated_at_micros: next.updated_at_micros,
},
)?;
}
Ok(())
}
// M5 Stage 2 先把 library profile upsert 固定成最小正式写入口;已发布作品在这里同步刷新 gallery 投影。
#[spacetimedb::reducer]
pub fn upsert_custom_world_profile(
ctx: &ReducerContext,
input: CustomWorldProfileUpsertInput,
) -> Result<(), String> {
upsert_custom_world_profile_record(ctx, input).map(|_| ())
}
// procedure 面向 Axum 返回 profile 与可能同步出的 gallery 投影,避免 HTTP 层再二次查询私有表。
#[spacetimedb::procedure]
pub fn upsert_custom_world_profile_and_return(
ctx: &mut ProcedureContext,
input: CustomWorldProfileUpsertInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| upsert_custom_world_profile_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
// publish 负责同时推进 profile 发布态与 gallery 公开投影,避免公开列表继续运行时拼装。
#[spacetimedb::reducer]
pub fn publish_custom_world_profile(
ctx: &ReducerContext,
input: CustomWorldProfilePublishInput,
) -> Result<(), String> {
publish_custom_world_profile_record(ctx, input).map(|_| ())
}
#[spacetimedb::procedure]
pub fn publish_custom_world_profile_and_return(
ctx: &mut ProcedureContext,
input: CustomWorldProfilePublishInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| publish_custom_world_profile_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
// unpublish 负责撤掉 gallery 投影,并把 profile 恢复为 draft。
#[spacetimedb::reducer]
pub fn unpublish_custom_world_profile(
ctx: &ReducerContext,
input: CustomWorldProfileUnpublishInput,
) -> Result<(), String> {
unpublish_custom_world_profile_record(ctx, input).map(|_| ())
}
#[spacetimedb::procedure]
pub fn unpublish_custom_world_profile_and_return(
ctx: &mut ProcedureContext,
input: CustomWorldProfileUnpublishInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| unpublish_custom_world_profile_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
// 删除入口继续走 owner-only 软删除,不直接物理删除 profile 真相。
#[spacetimedb::procedure]
pub fn delete_custom_world_profile_and_return(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfileDeleteInput,
) -> CustomWorldProfileListResult {
match ctx.try_with_tx(|tx| {
delete_custom_world_profile_record(tx, input.clone())?;
list_custom_world_profile_snapshots(
tx,
CustomWorldProfileListInput {
owner_user_id: input.owner_user_id.clone(),
},
)
}) {
Ok(entries) => CustomWorldProfileListResult {
ok: true,
entries,
error_message: None,
},
Err(message) => CustomWorldProfileListResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_custom_world_profiles(
ctx: &mut ProcedureContext,
input: CustomWorldProfileListInput,
) -> CustomWorldProfileListResult {
match ctx.try_with_tx(|tx| list_custom_world_profile_snapshots(tx, input.clone())) {
Ok(entries) => CustomWorldProfileListResult {
ok: true,
entries,
error_message: None,
},
Err(message) => CustomWorldProfileListResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_custom_world_gallery_entries(
ctx: &mut ProcedureContext,
) -> CustomWorldGalleryListResult {
match ctx.try_with_tx(|tx| list_custom_world_gallery_snapshots(tx)) {
Ok(entries) => CustomWorldGalleryListResult {
ok: true,
entries,
error_message: None,
},
Err(message) => CustomWorldGalleryListResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_custom_world_library_detail(
ctx: &mut ProcedureContext,
input: CustomWorldLibraryDetailInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| get_custom_world_library_detail_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry,
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_custom_world_gallery_detail(
ctx: &mut ProcedureContext,
input: CustomWorldGalleryDetailInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry,
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_custom_world_gallery_detail_by_code(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldGalleryDetailByCodeInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| get_custom_world_gallery_detail_record_by_code(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry,
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn remix_custom_world_profile(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfileRemixInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| remix_custom_world_profile_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn record_custom_world_profile_play(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfilePlayRecordInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| record_custom_world_profile_play_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry: Some(gallery_entry),
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn record_custom_world_profile_like(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfileLikeRecordInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry: Some(gallery_entry),
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_custom_world_works(
ctx: &mut ProcedureContext,
input: CustomWorldWorksListInput,
) -> CustomWorldWorksListResult {
match ctx.try_with_tx(|tx| list_custom_world_work_snapshots(tx, input.clone())) {
Ok(items) => CustomWorldWorksListResult {
ok: true,
items,
error_message: None,
},
Err(message) => CustomWorldWorksListResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_custom_world_agent_card_detail(
ctx: &mut ProcedureContext,
input: CustomWorldAgentCardDetailGetInput,
) -> CustomWorldDraftCardDetailResult {
match ctx.try_with_tx(|tx| get_custom_world_agent_card_detail_tx(tx, input.clone())) {
Ok(card) => CustomWorldDraftCardDetailResult {
ok: true,
card: Some(card),
error_message: None,
},
Err(message) => CustomWorldDraftCardDetailResult {
ok: false,
card: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn execute_custom_world_agent_action(
ctx: &mut ProcedureContext,
input: CustomWorldAgentActionExecuteInput,
) -> CustomWorldAgentActionExecuteResult {
match ctx.try_with_tx(|tx| execute_custom_world_agent_action_tx(tx, input.clone())) {
Ok(operation) => CustomWorldAgentActionExecuteResult {
ok: true,
operation: Some(operation),
error_message: None,
},
Err(message) => CustomWorldAgentActionExecuteResult {
ok: false,
operation: None,
error_message: Some(message),
},
}
}
// Stage 3 先把 published profile compile 作为独立 procedure 暴露,避免把编译逻辑和表写入、发布动作强耦合。
#[spacetimedb::procedure]
pub fn compile_custom_world_published_profile(
_ctx: &mut ProcedureContext,
input: CustomWorldPublishedProfileCompileInput,
) -> CustomWorldPublishedProfileCompileResult {
match build_custom_world_published_profile_compile_snapshot(input) {
Ok(record) => CustomWorldPublishedProfileCompileResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(error) => CustomWorldPublishedProfileCompileResult {
ok: false,
record: None,
error_message: Some(error.to_string()),
},
}
}
// Stage 4 把 publish_world 串成单事务主链compile -> profile upsert -> profile publish -> session.stage 推进。
#[spacetimedb::procedure]
pub fn publish_custom_world_world(
ctx: &mut ProcedureContext,
input: CustomWorldPublishWorldInput,
) -> CustomWorldPublishWorldResult {
match ctx.try_with_tx(|tx| publish_custom_world_world_record(tx, input.clone())) {
Ok((compiled_record, entry, gallery_entry, session_stage)) => {
CustomWorldPublishWorldResult {
ok: true,
compiled_record: Some(compiled_record),
entry: Some(entry),
gallery_entry,
session_stage: Some(session_stage),
error_message: None,
}
}
Err(message) => CustomWorldPublishWorldResult {
ok: false,
compiled_record: None,
entry: None,
gallery_entry: None,
session_stage: None,
error_message: Some(message),
},
}
}
// M4 首轮先把 treasure_record 固定成可审计的宝藏结算真相表,奖励写入与 story 归属关系由 reducer 显式校验。
#[spacetimedb::reducer]
pub fn resolve_treasure_interaction(
ctx: &ReducerContext,
input: TreasureResolveInput,
) -> Result<(), String> {
upsert_treasure_record(ctx, input).map(|_| ())
}
// procedure 面向后续 Axum facade同步返回最终 treasure_record 快照,避免 HTTP 层再额外读取私有表。
#[spacetimedb::procedure]
pub fn resolve_treasure_interaction_and_return(
ctx: &mut ProcedureContext,
input: TreasureResolveInput,
) -> TreasureRecordProcedureResult {
match ctx.try_with_tx(|tx| upsert_treasure_record(tx, input.clone())) {
Ok(record) => TreasureRecordProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => TreasureRecordProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn upsert_treasure_record(
ctx: &ReducerContext,
input: TreasureResolveInput,
) -> Result<TreasureRecordSnapshot, String> {
let snapshot = build_treasure_record_snapshot(input).map_err(|error| error.to_string())?;
let story_session = ctx
.db
.story_session()
.story_session_id()
.find(&snapshot.story_session_id)
.ok_or_else(|| {
"treasure_record.story_session_id 对应的 story_session 不存在".to_string()
})?;
if story_session.runtime_session_id != snapshot.runtime_session_id {
return Err(
"treasure_record.runtime_session_id 必须与 story_session.runtime_session_id 一致"
.to_string(),
);
}
if story_session.actor_user_id != snapshot.actor_user_id {
return Err(
"treasure_record.actor_user_id 必须与 story_session.actor_user_id 一致".to_string(),
);
}
// treasure_record 首版按单次结算真相处理:同 id 重放直接返回已落库快照,避免记录更新和重复发奖脱节。
if let Some(existing) = ctx
.db
.treasure_record()
.treasure_record_id()
.find(&snapshot.treasure_record_id)
{
return Ok(build_treasure_record_snapshot_from_row(&existing));
}
let updated_at = Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros);
let created_at = Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros);
ctx.db
.treasure_record()
.insert(build_treasure_record_row(&snapshot, created_at, updated_at));
grant_treasure_reward_items_to_inventory(ctx, &snapshot)?;
Ok(snapshot)
}
fn grant_treasure_reward_items_to_inventory(
ctx: &ReducerContext,
snapshot: &TreasureRecordSnapshot,
) -> Result<(), String> {
for (index, reward_item) in snapshot.reward_items.iter().cloned().enumerate() {
let inventory_item = build_inventory_item_snapshot_from_reward_item(
&snapshot.treasure_record_id,
reward_item,
)
.map_err(|error| error.to_string())?;
let slot_id = build_treasure_inventory_slot_id(&snapshot.treasure_record_id, index);
let mutation_id = build_treasure_inventory_mutation_id(&snapshot.treasure_record_id, index);
apply_inventory_mutation_tx(
ctx,
InventoryMutationInput {
mutation_id,
runtime_session_id: snapshot.runtime_session_id.clone(),
story_session_id: Some(snapshot.story_session_id.clone()),
actor_user_id: snapshot.actor_user_id.clone(),
mutation: InventoryMutation::GrantItem(module_inventory::GrantInventoryItemInput {
slot_id,
item: inventory_item,
}),
updated_at_micros: snapshot.updated_at_micros,
},
)?;
}
Ok(())
}
fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String {
format!(
"{}{}_{}",
INVENTORY_SLOT_ID_PREFIX, treasure_record_id, reward_index
)
}
fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String {
format!(
"{}{}_{}",
INVENTORY_MUTATION_ID_PREFIX, treasure_record_id, reward_index
)
}
fn build_treasure_record_row(
snapshot: &TreasureRecordSnapshot,
created_at: Timestamp,
updated_at: Timestamp,
) -> TreasureRecord {
TreasureRecord {
treasure_record_id: snapshot.treasure_record_id.clone(),
runtime_session_id: snapshot.runtime_session_id.clone(),
story_session_id: snapshot.story_session_id.clone(),
actor_user_id: snapshot.actor_user_id.clone(),
encounter_id: snapshot.encounter_id.clone(),
encounter_name: snapshot.encounter_name.clone(),
scene_id: snapshot.scene_id.clone(),
scene_name: snapshot.scene_name.clone(),
action: snapshot.action,
reward_items: snapshot.reward_items.clone(),
reward_hp: snapshot.reward_hp,
reward_mana: snapshot.reward_mana,
reward_currency: snapshot.reward_currency,
story_hint: snapshot.story_hint.clone(),
created_at,
updated_at,
}
}
fn upsert_custom_world_profile_record(
ctx: &ReducerContext,
input: CustomWorldProfileUpsertInput,
) -> Result<
(
CustomWorldProfileSnapshot,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
validate_custom_world_profile_upsert_input(&input).map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let current = ctx
.db
.custom_world_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.or_else(|| {
input
.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)
})
})
});
let next_row = match current {
Some(existing) => {
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: input.source_agent_session_id.clone(),
publication_status: existing.publication_status,
world_name: input.world_name.clone(),
subtitle: input.subtitle.clone(),
summary_text: input.summary_text.clone(),
theme_mode: input.theme_mode,
cover_image_src: input.cover_image_src.clone(),
profile_payload_json: input.profile_payload_json.clone(),
playable_npc_count: input.playable_npc_count,
landmark_count: input.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: input.author_display_name.clone(),
published_at: existing.published_at,
deleted_at: None,
created_at: existing.created_at,
updated_at,
}
}
None => CustomWorldProfile {
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
public_work_code: input.public_work_code.clone(),
author_public_user_code: input.author_public_user_code.clone(),
source_agent_session_id: input.source_agent_session_id.clone(),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: input.world_name.clone(),
subtitle: input.subtitle.clone(),
summary_text: input.summary_text.clone(),
theme_mode: input.theme_mode,
cover_image_src: input.cover_image_src.clone(),
profile_payload_json: input.profile_payload_json.clone(),
playable_npc_count: input.playable_npc_count,
landmark_count: input.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: input.author_display_name.clone(),
published_at: None,
deleted_at: None,
created_at: updated_at,
updated_at,
},
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = if inserted.publication_status == CustomWorldPublicationStatus::Published {
Some(sync_custom_world_gallery_entry_from_profile(
ctx, &inserted,
)?)
} else {
ctx.db
.custom_world_gallery_entry()
.profile_id()
.delete(&inserted.profile_id);
None
};
Ok((
build_custom_world_profile_snapshot(&inserted),
gallery_entry,
))
}
fn publish_custom_world_world_record(
ctx: &ReducerContext,
input: CustomWorldPublishWorldInput,
) -> Result<
(
module_custom_world::CustomWorldPublishedProfileCompileSnapshot,
CustomWorldProfileSnapshot,
Option<CustomWorldGalleryEntrySnapshot>,
RpgAgentStage,
),
String,
> {
validate_custom_world_publish_world_input(&input).map_err(|error| error.to_string())?;
let compiled_record = build_custom_world_published_profile_compile_snapshot(
CustomWorldPublishedProfileCompileInput {
session_id: input.session_id.clone(),
profile_id: input.profile_id.clone(),
owner_user_id: input.owner_user_id.clone(),
draft_profile_json: input.draft_profile_json.clone(),
legacy_result_profile_json: input.legacy_result_profile_json.clone(),
setting_text: input.setting_text.clone(),
author_display_name: input.author_display_name.clone(),
updated_at_micros: input.published_at_micros,
},
)
.map_err(|error| error.to_string())?;
let _ = upsert_custom_world_profile_record(
ctx,
CustomWorldProfileUpsertInput {
profile_id: compiled_record.profile_id.clone(),
owner_user_id: compiled_record.owner_user_id.clone(),
public_work_code: input.public_work_code.clone(),
author_public_user_code: Some(input.author_public_user_code.clone()),
source_agent_session_id: Some(input.session_id.clone()),
world_name: compiled_record.world_name.clone(),
subtitle: compiled_record.subtitle.clone(),
summary_text: compiled_record.summary_text.clone(),
theme_mode: compiled_record.theme_mode,
cover_image_src: compiled_record.cover_image_src.clone(),
profile_payload_json: compiled_record.compiled_profile_payload_json.clone(),
playable_npc_count: compiled_record.playable_npc_count,
landmark_count: compiled_record.landmark_count,
author_display_name: compiled_record.author_display_name.clone(),
updated_at_micros: input.published_at_micros,
},
)?;
let (entry, gallery_entry) = publish_custom_world_profile_record(
ctx,
CustomWorldProfilePublishInput {
profile_id: compiled_record.profile_id.clone(),
owner_user_id: compiled_record.owner_user_id.clone(),
public_work_code: input.public_work_code.clone(),
author_public_user_code: input.author_public_user_code.clone(),
author_display_name: compiled_record.author_display_name.clone(),
published_at_micros: input.published_at_micros,
},
)?;
let session_stage = mark_custom_world_agent_session_published(
ctx,
&input.session_id,
&input.owner_user_id,
input.published_at_micros,
)?;
Ok((compiled_record, entry, gallery_entry, session_stage))
}
fn publish_custom_world_profile_record(
ctx: &ReducerContext,
input: CustomWorldProfilePublishInput,
) -> Result<
(
CustomWorldProfileSnapshot,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
validate_custom_world_profile_publish_input(&input).map_err(|error| error.to_string())?;
let existing = ctx
.db
.custom_world_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?;
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing
.public_work_code
.clone()
.or_else(|| Some(build_public_work_code_from_profile_id(&existing.profile_id))),
author_public_user_code: Some(input.author_public_user_code.clone()),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: CustomWorldPublicationStatus::Published,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: input.author_display_name.clone(),
published_at: Some(published_at),
deleted_at: None,
created_at: existing.created_at,
updated_at: published_at,
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
Ok((
build_custom_world_profile_snapshot(&inserted),
Some(gallery_entry),
))
}
fn unpublish_custom_world_profile_record(
ctx: &ReducerContext,
input: CustomWorldProfileUnpublishInput,
) -> Result<
(
CustomWorldProfileSnapshot,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
validate_custom_world_profile_unpublish_input(&input).map_err(|error| error.to_string())?;
let existing = ctx
.db
.custom_world_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_profile 不存在,无法取消发布".to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
ctx.db
.custom_world_gallery_entry()
.profile_id()
.delete(&existing.profile_id);
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: input.author_display_name.clone(),
published_at: None,
deleted_at: None,
created_at: existing.created_at,
updated_at,
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
Ok((build_custom_world_profile_snapshot(&inserted), None))
}
fn delete_custom_world_profile_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfileDeleteInput,
) -> Result<(), String> {
validate_custom_world_profile_delete_input(&input).map_err(|error| error.to_string())?;
let Some(existing) = ctx
.db
.custom_world_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
else {
return Ok(());
};
if existing.deleted_at.is_some() {
return Ok(());
}
let deleted_at = Timestamp::from_micros_since_unix_epoch(input.deleted_at_micros);
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
ctx.db
.custom_world_gallery_entry()
.profile_id()
.delete(&existing.profile_id);
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: existing.author_display_name.clone(),
published_at: None,
deleted_at: Some(deleted_at),
created_at: existing.created_at,
updated_at: deleted_at,
};
let _ = ctx.db.custom_world_profile().insert(next_row);
Ok(())
}
fn list_custom_world_profile_snapshots(
ctx: &ReducerContext,
input: CustomWorldProfileListInput,
) -> Result<Vec<CustomWorldProfileSnapshot>, String> {
validate_custom_world_profile_list_input(&input).map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.custom_world_profile()
.by_custom_world_profile_owner_user_id()
.filter(&input.owner_user_id)
.filter(|row| row.deleted_at.is_none())
.map(|row| build_custom_world_profile_list_snapshot(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(entries)
}
fn build_custom_world_profile_list_snapshot(
row: &CustomWorldProfile,
) -> CustomWorldProfileSnapshot {
let mut snapshot = build_custom_world_profile_snapshot(row);
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
snapshot
}
fn build_custom_world_profile_list_payload_json(row: &CustomWorldProfile) -> String {
let source_profile = serde_json::from_str::<JsonValue>(&row.profile_payload_json).ok();
let source_object = source_profile.as_ref().and_then(JsonValue::as_object);
let empty_roles = JsonValue::Array(Vec::new());
let empty_landmarks = JsonValue::Array(Vec::new());
// 中文注释:首屏作品列表只需要卡片摘要,不能继续把完整 profile 大 JSON 随列表搬回 Axum。
let payload = json!({
"id": row.profile_id,
"name": row.world_name,
"subtitle": row.subtitle,
"summary": row.summary_text,
"tone": source_object
.and_then(|object| object.get("tone"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"playerGoal": source_object
.and_then(|object| object.get("playerGoal"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"settingText": source_object
.and_then(|object| object.get("settingText"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"themeMode": row.theme_mode.as_str(),
"templateWorldType": source_object
.and_then(|object| object.get("templateWorldType"))
.and_then(JsonValue::as_str)
.unwrap_or("WUXIA"),
"compatibilityTemplateWorldType": source_object
.and_then(|object| object.get("compatibilityTemplateWorldType"))
.cloned()
.unwrap_or(JsonValue::Null),
"cover": row.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": source_object
.and_then(|object| object.get("majorFactions"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"coreConflicts": source_object
.and_then(|object| object.get("coreConflicts"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"playableNpcs": source_object
.and_then(|object| object.get("playableNpcs"))
.cloned()
.unwrap_or_else(|| empty_roles.clone()),
"storyNpcs": source_object
.and_then(|object| object.get("storyNpcs"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"items": source_object
.and_then(|object| object.get("items"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"camp": source_object
.and_then(|object| object.get("camp"))
.cloned()
.unwrap_or(JsonValue::Null),
"landmarks": source_object
.and_then(|object| object.get("landmarks"))
.cloned()
.unwrap_or_else(|| empty_landmarks.clone()),
"ownedSettingLayers": source_object
.and_then(|object| object.get("ownedSettingLayers"))
.cloned()
.unwrap_or(JsonValue::Null),
});
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string())
}
fn list_custom_world_gallery_snapshots(
ctx: &ReducerContext,
) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
sync_missing_custom_world_gallery_entries(ctx)?;
let mut entries = ctx
.db
.custom_world_gallery_entry()
.iter()
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.published_at_micros
.cmp(&left.published_at_micros)
.then(right.updated_at_micros.cmp(&left.updated_at_micros))
});
Ok(entries)
}
fn get_custom_world_library_detail_record(
ctx: &ReducerContext,
input: CustomWorldLibraryDetailInput,
) -> Result<
(
Option<CustomWorldProfileSnapshot>,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
validate_custom_world_library_detail_input(&input).map_err(|error| error.to_string())?;
let profile = ctx
.db
.custom_world_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none());
let gallery_entry = profile
.as_ref()
.filter(|row| row.publication_status == CustomWorldPublicationStatus::Published)
.and_then(|row| {
ctx.db
.custom_world_gallery_entry()
.profile_id()
.find(&row.profile_id)
.filter(|gallery_row| gallery_row.owner_user_id == row.owner_user_id)
});
Ok((
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
fn get_custom_world_gallery_detail_record(
ctx: &ReducerContext,
input: CustomWorldGalleryDetailInput,
) -> Result<
(
Option<CustomWorldProfileSnapshot>,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
validate_custom_world_gallery_detail_input(&input).map_err(|error| error.to_string())?;
let profile = ctx
.db
.custom_world_profile()
.profile_id()
.find(&input.profile_id)
.filter(|row| {
row.owner_user_id == input.owner_user_id
&& row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
});
let gallery_entry = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&input.profile_id)
.filter(|row| row.owner_user_id == input.owner_user_id);
Ok((
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
fn get_custom_world_gallery_detail_record_by_code(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldGalleryDetailByCodeInput,
) -> Result<
(
Option<CustomWorldProfileSnapshot>,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
validate_custom_world_gallery_detail_by_code_input(&input)
.map_err(|error| error.to_string())?;
let normalized_public_work_code = normalize_public_work_code(&input.public_work_code)
.ok_or_else(|| "public_work_code 格式不正确".to_string())?;
let gallery_entry = ctx
.db
.custom_world_gallery_entry()
.iter()
.find(|row| row.public_work_code == normalized_public_work_code);
let profile = gallery_entry.as_ref().and_then(|row| {
ctx.db
.custom_world_profile()
.profile_id()
.find(&row.profile_id)
.filter(|profile_row| {
profile_row.owner_user_id == row.owner_user_id
&& profile_row.publication_status == CustomWorldPublicationStatus::Published
&& profile_row.deleted_at.is_none()
})
});
Ok((
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
fn remix_custom_world_profile_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfileRemixInput,
) -> Result<
(
CustomWorldProfileSnapshot,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
let source_owner_user_id = input.source_owner_user_id.trim();
let source_profile_id = input.source_profile_id.trim();
let target_owner_user_id = input.target_owner_user_id.trim();
let target_profile_id = input.target_profile_id.trim();
if source_owner_user_id.is_empty()
|| source_profile_id.is_empty()
|| target_owner_user_id.is_empty()
|| target_profile_id.is_empty()
{
return Err("custom_world remix 参数不能为空".to_string());
}
if input.author_display_name.trim().is_empty() {
return Err("custom_world remix 作者名不能为空".to_string());
}
// Remix 只允许从已发布源作品派生草稿,同时把源作品的公开 remix 计数同步到画廊。
let source = ctx
.db
.custom_world_profile()
.profile_id()
.find(&source_profile_id.to_string())
.filter(|row| row.owner_user_id == source_owner_user_id)
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布源作品不存在".to_string())?;
if ctx
.db
.custom_world_profile()
.profile_id()
.find(&target_profile_id.to_string())
.is_some()
{
return Err("custom_world remix 目标 profile 已存在".to_string());
}
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
let next_source = CustomWorldProfile {
profile_id: source.profile_id.clone(),
owner_user_id: source.owner_user_id.clone(),
public_work_code: source.public_work_code.clone(),
author_public_user_code: source.author_public_user_code.clone(),
source_agent_session_id: source.source_agent_session_id.clone(),
publication_status: source.publication_status,
world_name: source.world_name.clone(),
subtitle: source.subtitle.clone(),
summary_text: source.summary_text.clone(),
theme_mode: source.theme_mode,
cover_image_src: source.cover_image_src.clone(),
profile_payload_json: source.profile_payload_json.clone(),
playable_npc_count: source.playable_npc_count,
landmark_count: source.landmark_count,
play_count: source.play_count,
remix_count: source.remix_count.saturating_add(1),
like_count: source.like_count,
author_display_name: source.author_display_name.clone(),
published_at: source.published_at,
deleted_at: source.deleted_at,
created_at: source.created_at,
updated_at: remixed_at,
};
ctx.db
.custom_world_profile()
.profile_id()
.delete(&source.profile_id);
let updated_source = ctx.db.custom_world_profile().insert(next_source);
let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?;
// 新草稿继承作品内容,但互动计数从 0 开始,避免把源作品热度复制成用户资产。
let draft = CustomWorldProfile {
profile_id: target_profile_id.to_string(),
owner_user_id: target_owner_user_id.to_string(),
public_work_code: None,
author_public_user_code: None,
source_agent_session_id: None,
publication_status: CustomWorldPublicationStatus::Draft,
world_name: source.world_name.clone(),
subtitle: source.subtitle.clone(),
summary_text: source.summary_text.clone(),
theme_mode: source.theme_mode,
cover_image_src: source.cover_image_src.clone(),
profile_payload_json: source.profile_payload_json.clone(),
playable_npc_count: source.playable_npc_count,
landmark_count: source.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: input.author_display_name.trim().to_string(),
published_at: None,
deleted_at: None,
created_at: remixed_at,
updated_at: remixed_at,
};
let inserted_draft = ctx.db.custom_world_profile().insert(draft);
Ok((
build_custom_world_profile_snapshot(&inserted_draft),
Some(source_gallery),
))
}
fn record_custom_world_profile_play_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfilePlayRecordInput,
) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> {
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
if owner_user_id.is_empty() || profile_id.is_empty() {
return Err("custom_world play 参数不能为空".to_string());
}
let existing = ctx
.db
.custom_world_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
record_public_work_play(
ctx,
crate::runtime::PublicWorkPlayRecordInput {
source_type: "custom-world".to_string(),
owner_user_id: existing.owner_user_id.clone(),
profile_id: existing.profile_id.clone(),
played_at_micros: input.played_at_micros,
},
)?;
// 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: existing.publication_status,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count.saturating_add(1),
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: existing.author_display_name.clone(),
published_at: existing.published_at,
deleted_at: existing.deleted_at,
created_at: existing.created_at,
updated_at: played_at,
};
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
Ok((
build_custom_world_profile_snapshot(&inserted),
gallery_entry,
))
}
fn record_custom_world_profile_like_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfileLikeRecordInput,
) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> {
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
let user_id = input.user_id.trim();
if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() {
return Err("custom_world like 参数不能为空".to_string());
}
let existing = ctx
.db
.custom_world_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
let inserted_like = record_public_work_like(
ctx,
crate::runtime::PublicWorkLikeRecordInput {
source_type: "custom-world".to_string(),
owner_user_id: existing.owner_user_id.clone(),
profile_id: existing.profile_id.clone(),
user_id: user_id.to_string(),
liked_at_micros: input.liked_at_micros,
},
)?;
if !inserted_like {
let gallery_entry = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&existing.profile_id)
.filter(|row| row.owner_user_id == existing.owner_user_id)
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
.ok_or_else(|| "custom_world gallery_entry 不存在".to_string())?;
return Ok((
build_custom_world_profile_snapshot(&existing),
gallery_entry,
));
}
// 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: existing.publication_status,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count.saturating_add(1),
author_display_name: existing.author_display_name.clone(),
published_at: existing.published_at,
deleted_at: existing.deleted_at,
created_at: existing.created_at,
updated_at: liked_at,
};
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
Ok((
build_custom_world_profile_snapshot(&inserted),
gallery_entry,
))
}
fn list_custom_world_work_snapshots(
ctx: &ReducerContext,
input: CustomWorldWorksListInput,
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, String> {
validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?;
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
&& should_include_custom_world_agent_session_work(ctx, row)
}) {
active_agent_session_ids.insert(session.session_id.clone());
let gate = build_custom_world_publish_gate_from_session(&session);
let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref());
let title = resolve_session_work_title(&session, draft_profile.as_ref());
let summary = resolve_session_work_summary(&session, draft_profile.as_ref());
let stage_label = Some(resolve_rpg_agent_stage_label(session.stage).to_string());
let subtitle =
resolve_session_work_subtitle(draft_profile.as_ref(), stage_label.as_deref());
let (playable_npc_count, landmark_count) =
resolve_session_work_counts(ctx, &session, draft_profile.as_ref());
items.push(CustomWorldWorkSummarySnapshot {
work_id: format!("draft:{}", session.session_id),
source_type: "agent_session".to_string(),
status: "draft".to_string(),
title,
subtitle,
summary,
cover_image_src: resolve_session_work_cover_image_src(draft_profile.as_ref()),
cover_render_mode: None,
cover_character_image_srcs_json: "[]".to_string(),
updated_at_micros: session.updated_at.to_micros_since_unix_epoch(),
published_at_micros: None,
stage: Some(session.stage),
stage_label,
playable_npc_count,
landmark_count,
role_visual_ready_count: None,
role_animation_ready_count: None,
role_asset_summary_label: None,
session_id: Some(session.session_id.clone()),
profile_id: None,
can_resume: true,
can_enter_world: gate.can_enter_world,
blocker_count: gate.blocker_count,
publish_ready: gate.publish_ready,
});
}
for profile in ctx
.db
.custom_world_profile()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
.filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids))
{
items.push(CustomWorldWorkSummarySnapshot {
work_id: format!("published:{}", profile.profile_id),
source_type: "published_profile".to_string(),
status: profile.publication_status.as_str().to_string(),
title: profile.world_name.clone(),
subtitle: profile.subtitle.clone(),
summary: profile.summary_text.clone(),
cover_image_src: profile.cover_image_src.clone(),
cover_render_mode: None,
cover_character_image_srcs_json: "[]".to_string(),
updated_at_micros: profile.updated_at.to_micros_since_unix_epoch(),
published_at_micros: profile
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
stage: None,
stage_label: None,
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
role_visual_ready_count: None,
role_animation_ready_count: None,
role_asset_summary_label: None,
session_id: profile.source_agent_session_id.clone(),
profile_id: Some(profile.profile_id.clone()),
can_resume: false,
can_enter_world: profile.publication_status == CustomWorldPublicationStatus::Published,
blocker_count: 0,
publish_ready: true,
});
}
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| {
let left_rank = if left.source_type == "agent_session" {
0
} else {
1
};
let right_rank = if right.source_type == "agent_session" {
0
} else {
1
};
left_rank.cmp(&right_rank)
})
.then(left.work_id.cmp(&right.work_id))
});
Ok(items)
}
fn should_include_custom_world_agent_session_work(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
) -> bool {
if custom_world_agent_session_has_direct_work_content(session) {
return true;
}
if ctx.db.custom_world_agent_message().iter().any(|message| {
message.session_id == session.session_id
&& matches!(message.role, RpgAgentMessageRole::User)
}) {
return true;
}
ctx.db
.custom_world_draft_card()
.iter()
.any(|card| card.session_id == session.session_id)
}
fn custom_world_agent_session_has_direct_work_content(session: &CustomWorldAgentSession) -> bool {
// 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容;
// 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。
!session.seed_text.trim().is_empty()
|| matches!(
session.stage,
RpgAgentStage::ObjectRefining
| RpgAgentStage::VisualRefining
| RpgAgentStage::LongTailReview
| RpgAgentStage::ReadyToPublish
| RpgAgentStage::Published
)
|| parse_optional_session_object(session.draft_profile_json.as_deref())
.as_ref()
.is_some_and(|profile| !profile.is_empty())
}
fn should_include_custom_world_profile_work(
row: &CustomWorldProfile,
active_agent_session_ids: &HashSet<String>,
) -> bool {
// 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。
if row.publication_status == CustomWorldPublicationStatus::Published {
return true;
}
// 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物,
// works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。
row.source_agent_session_id
.as_ref()
.map_or(true, |session_id| {
!active_agent_session_ids.contains(session_id)
})
}
fn get_custom_world_agent_card_detail_tx(
ctx: &ReducerContext,
input: CustomWorldAgentCardDetailGetInput,
) -> Result<CustomWorldDraftCardDetailSnapshot, String> {
validate_custom_world_agent_card_detail_get_input(&input).map_err(|error| error.to_string())?;
ctx.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
let card = ctx
.db
.custom_world_draft_card()
.card_id()
.find(&input.card_id)
.filter(|row| row.session_id == input.session_id)
.ok_or_else(|| "custom_world_draft_card 不存在".to_string())?;
build_custom_world_draft_card_detail_snapshot(&card)
}
fn execute_custom_world_agent_action_tx(
ctx: &ReducerContext,
input: CustomWorldAgentActionExecuteInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_action_execute_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if let Some(existing_operation) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
{
let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation"
&& existing_operation.session_id == input.session_id
&& existing_operation.operation_type == RpgAgentOperationType::DraftFoundation
&& matches!(
existing_operation.status,
RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running
);
if !can_reuse_running_draft_operation {
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
}
}
let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default();
match input.action.trim() {
"draft_foundation" => execute_draft_foundation_action(ctx, &session, &input, &payload),
"update_draft_card" => execute_update_draft_card_action(ctx, &session, &input, &payload),
"sync_result_profile" => {
execute_sync_result_profile_action(ctx, &session, &input, &payload)
}
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
"generate_characters"
| "generate_landmarks"
| "generate_role_assets"
| "sync_role_assets"
| "generate_scene_assets"
| "sync_scene_assets"
| "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input),
other => Err(format!("custom world action `{other}` 当前尚未支持")),
}
}
fn execute_draft_foundation_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
if session.progress_percent < 100 {
return Err("draft_foundation requires progressPercent >= 100".to_string());
}
let updated_at = input.submitted_at_micros;
let draft_profile = payload
.get("draftProfile")
.and_then(JsonValue::as_object)
.cloned()
.ok_or_else(|| {
"draft_foundation requires externally generated payload.draftProfile".to_string()
})?;
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile.clone()))
.map_err(|error| format!("draft_foundation 无法序列化 draft_profile_json: {error}"))?;
let gate = summarize_publish_gate_from_json(
&input.session_id,
RpgAgentStage::ObjectRefining,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
progress_percent: Some(100),
stage: Some(RpgAgentStage::ObjectRefining),
draft_profile_json: Some(Some(draft_profile_json.clone())),
last_assistant_reply: Some(Some(
"世界底稿已整理完成,接下来可以继续细化卡片和发布预览。".to_string(),
)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
&parse_json_array_or_empty(&session.quality_findings_json),
updated_at,
)?),
checkpoints_json: Some(append_checkpoint_json(
&session.checkpoints_json,
&build_session_checkpoint_value("foundation-ready", "底稿整理完成", session),
)?),
updated_at_micros: Some(updated_at),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
upsert_world_foundation_card(ctx, &session.session_id, &draft_profile, updated_at)?;
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"已整理出第一版世界底稿,并同步生成世界基础卡片。",
updated_at,
);
let operation = complete_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::DraftFoundation,
"底稿已整理",
"第一版 foundation draft 已写入会话与世界卡。",
updated_at,
)?;
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_update_draft_card_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, "update_draft_card")?;
let card_id =
read_required_payload_text(payload, "cardId", "update_draft_card requires cardId")?;
let card = ctx
.db
.custom_world_draft_card()
.card_id()
.find(&card_id)
.filter(|row| row.session_id == session.session_id)
.ok_or_else(|| "update_draft_card target card does not exist".to_string())?;
let sections = payload
.get("sections")
.and_then(JsonValue::as_array)
.ok_or_else(|| "update_draft_card requires sections".to_string())?;
if sections.is_empty() {
return Err("update_draft_card requires sections".to_string());
}
let mut detail_object =
parse_optional_session_object(card.detail_payload_json.as_deref()).unwrap_or_default();
let mut detail_sections = detail_object
.get("sections")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_else(|| build_fallback_card_sections_json(&card));
for patch in sections {
let patch_object = patch
.as_object()
.ok_or_else(|| "update_draft_card.sections 必须是 object 数组".to_string())?;
let section_id = read_required_payload_text(
patch_object,
"sectionId",
"update_draft_card section.sectionId is required",
)?;
let value = patch_object
.get("value")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.trim()
.to_string();
let mut updated = false;
for existing in &mut detail_sections {
if existing.get("id").and_then(JsonValue::as_str) == Some(section_id.as_str()) {
if let Some(object) = existing.as_object_mut() {
object.insert("value".to_string(), JsonValue::String(value.clone()));
}
updated = true;
break;
}
}
if !updated {
detail_sections.push(json!({
"id": section_id,
"label": section_id,
"value": value,
}));
}
}
detail_object.insert("id".to_string(), JsonValue::String(card.card_id.clone()));
detail_object.insert(
"kind".to_string(),
JsonValue::String(card.kind.as_str().to_string()),
);
detail_object.insert("title".to_string(), JsonValue::String(card.title.clone()));
detail_object.insert(
"sections".to_string(),
JsonValue::Array(detail_sections.clone()),
);
detail_object.insert(
"linkedIds".to_string(),
serde_json::from_str::<JsonValue>(&card.linked_ids_json)
.unwrap_or_else(|_| JsonValue::Array(Vec::new())),
);
detail_object.insert("locked".to_string(), JsonValue::Bool(false));
detail_object.insert("editable".to_string(), JsonValue::Bool(false));
detail_object.insert(
"editableSectionIds".to_string(),
JsonValue::Array(Vec::new()),
);
detail_object.insert("warningMessages".to_string(), JsonValue::Array(Vec::new()));
let updated_title = extract_detail_section_value(&detail_sections, "title")
.unwrap_or_else(|| card.title.clone());
let updated_subtitle = extract_detail_section_value(&detail_sections, "subtitle")
.unwrap_or_else(|| card.subtitle.clone());
let updated_summary = extract_detail_section_value(&detail_sections, "summary")
.unwrap_or_else(|| card.summary.clone());
let detail_payload_json = serde_json::to_string(&JsonValue::Object(detail_object))
.map_err(|error| format!("update_draft_card 无法序列化 detail_payload_json: {error}"))?;
replace_custom_world_draft_card(
ctx,
&card,
CustomWorldDraftCard {
card_id: card.card_id.clone(),
session_id: card.session_id.clone(),
kind: card.kind,
status: card.status,
title: updated_title.clone(),
subtitle: updated_subtitle.clone(),
summary: updated_summary.clone(),
linked_ids_json: card.linked_ids_json.clone(),
warning_count: card.warning_count,
asset_status: card.asset_status,
asset_status_label: card.asset_status_label.clone(),
detail_payload_json: Some(detail_payload_json),
created_at: card.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros),
},
);
let next_session = sync_session_draft_profile_from_card_update(
session,
&card,
&updated_title,
&updated_subtitle,
&updated_summary,
input.submitted_at_micros,
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
&format!("已更新卡片《{}》的草稿内容。", updated_title),
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::UpdateDraftCard,
"卡片已更新",
&format!("卡片 {} 的 detail 与摘要字段已同步更新。", card_id),
input.submitted_at_micros,
);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_sync_result_profile_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_result_profile_sync_stage(session.stage, "sync_result_profile")?;
let mut profile = payload
.get("profile")
.and_then(JsonValue::as_object)
.cloned()
.ok_or_else(|| "sync_result_profile requires profile".to_string())?;
if let Some(stable_profile_id) = resolve_stable_agent_draft_profile_id(session) {
// 结果页回写时必须沿用当前草稿的稳定身份,避免把同一草稿写成新条目。
profile.insert(
"id".to_string(),
JsonValue::String(stable_profile_id.clone()),
);
upsert_nested_result_profile_id(&mut profile, &stable_profile_id);
}
let draft_profile = ensure_minimal_draft_profile(profile, &session.seed_text);
let gate = summarize_publish_gate_from_json(
&session.session_id,
session.stage,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
last_assistant_reply: Some(Some("结果页草稿已同步回当前会话。".to_string())),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
&parse_json_array_or_empty(&session.quality_findings_json),
input.submitted_at_micros,
)?),
checkpoints_json: Some(append_checkpoint_json(
&session.checkpoints_json,
&build_session_checkpoint_value("sync-result-profile", "同步结果页草稿", session),
)?),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"结果页 profile 已回写当前会话,并重建预览。",
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::SyncResultProfile,
"结果页已同步",
"draft_profile_json 与 result_preview 已更新。",
input.submitted_at_micros,
);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn resolve_stable_agent_draft_profile_id(session: &CustomWorldAgentSession) -> Option<String> {
parse_optional_session_object(session.draft_profile_json.as_deref())
.and_then(|profile| read_optional_text_field(&profile, &["legacyResultProfile.id", "id"]))
}
fn upsert_nested_result_profile_id(
profile: &mut JsonMap<String, JsonValue>,
stable_profile_id: &str,
) {
let legacy_result_profile = profile
.entry("legacyResultProfile".to_string())
.or_insert_with(|| JsonValue::Object(JsonMap::new()));
if let Some(object) = legacy_result_profile.as_object_mut() {
object.insert(
"id".to_string(),
JsonValue::String(stable_profile_id.to_string()),
);
}
}
fn is_same_agent_draft_profile_candidate(
row: &CustomWorldProfile,
owner_user_id: &str,
source_agent_session_id: &str,
) -> bool {
row.owner_user_id == owner_user_id
&& row.deleted_at.is_none()
&& row.publication_status == CustomWorldPublicationStatus::Draft
&& row.source_agent_session_id.as_deref() == Some(source_agent_session_id)
}
fn execute_publish_world_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_publishable_stage(session.stage, "publish_world")?;
let draft_profile =
if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) {
explicit.clone()
} else {
parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| "publish_world requires draft_profile_json".to_string())?
};
let gate = summarize_publish_gate_from_json(
&session.session_id,
session.stage,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
if !gate.publish_ready {
return Err(format!(
"当前世界仍有 {} 个 blocker暂时不能发布",
gate.blocker_count
));
}
let profile_id = payload
.get("profileId")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| gate.profile_id.clone());
let setting_text = payload
.get("settingText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| session.seed_text.clone());
let legacy_result_profile_json = payload
.get("legacyResultProfile")
.map(serialize_json_value)
.transpose()?;
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
.unwrap_or_else(|| "陶泥主".to_string());
let publish_result = publish_custom_world_world_record(
ctx,
CustomWorldPublishWorldInput {
session_id: session.session_id.clone(),
profile_id,
owner_user_id: session.owner_user_id.clone(),
public_work_code: None,
author_public_user_code,
draft_profile_json: serialize_json_value(&JsonValue::Object(draft_profile.clone()))?,
legacy_result_profile_json,
setting_text,
author_display_name,
published_at_micros: input.submitted_at_micros,
},
)?;
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
&format!("正式世界档案已发布:{}", publish_result.1.profile_id),
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::PublishWorld,
"世界已发布",
&format!(
"正式世界档案已写入作品库:{}",
publish_result.1.profile_id
),
input.submitted_at_micros,
);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_revert_checkpoint_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_long_tail_stage(session.stage, "revert_checkpoint")?;
let checkpoint_id = read_required_payload_text(
payload,
"checkpointId",
"revert_checkpoint requires checkpointId",
)?;
let checkpoint = parse_json_array_or_empty(&session.checkpoints_json)
.into_iter()
.find(|entry| {
entry
.get("checkpointId")
.and_then(JsonValue::as_str)
.map(str::trim)
== Some(checkpoint_id.as_str())
})
.ok_or_else(|| "revert_checkpoint target checkpoint does not exist".to_string())?;
let snapshot = checkpoint
.get("snapshot")
.and_then(JsonValue::as_object)
.cloned()
.ok_or_else(|| {
"revert_checkpoint target checkpoint does not contain a restorable snapshot".to_string()
})?;
let restored_stage = snapshot
.get("stage")
.and_then(JsonValue::as_str)
.and_then(parse_rpg_agent_stage)
.unwrap_or(session.stage);
let restored_progress = snapshot
.get("progressPercent")
.and_then(JsonValue::as_u64)
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(session.progress_percent);
let restored_draft_profile = snapshot
.get("draftProfile")
.and_then(JsonValue::as_object)
.cloned();
let restored_quality_findings = snapshot
.get("qualityFindings")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_else(Vec::new);
let gate = summarize_publish_gate_from_json(
&session.session_id,
restored_stage,
restored_draft_profile.as_ref(),
&restored_quality_findings,
);
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
progress_percent: Some(restored_progress),
stage: Some(restored_stage),
draft_profile_json: Some(
restored_draft_profile
.as_ref()
.map(|value| serialize_json_value(&JsonValue::Object(value.clone())))
.transpose()?,
),
last_assistant_reply: Some(Some(
"已恢复到所选 checkpoint 的世界草稿状态。".to_string(),
)),
quality_findings_json: Some(serialize_json_value(&JsonValue::Array(
restored_quality_findings,
))?),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
restored_draft_profile.as_ref(),
&gate,
&parse_json_array_or_empty(&serialize_json_value(&JsonValue::Array(
snapshot
.get("qualityFindings")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_else(Vec::new),
))?),
input.submitted_at_micros,
)?),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
"已恢复到所选 checkpoint。",
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
RpgAgentOperationType::RevertCheckpoint,
"已回滚 checkpoint",
&format!("会话已恢复到 checkpoint {}", checkpoint_id),
input.submitted_at_micros,
);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn execute_placeholder_custom_world_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
let operation_type = map_action_name_to_operation_type(input.action.as_str())
.ok_or_else(|| format!("action {} 无法映射到 operation type", input.action))?;
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
&format!(
"动作 {} 已接入最小兼容占位,后续会继续补真实编排。",
input.action
),
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
operation_type,
"动作已完成",
&format!("{} 当前已走最小兼容闭环。", input.action),
input.submitted_at_micros,
);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
#[derive(Clone, Debug, Default)]
struct CustomWorldAgentSessionPatch {
current_turn: Option<u32>,
progress_percent: Option<u32>,
stage: Option<RpgAgentStage>,
focus_card_id: Option<Option<String>>,
anchor_content_json: Option<String>,
creator_intent_json: Option<Option<String>>,
creator_intent_readiness_json: Option<String>,
anchor_pack_json: Option<Option<String>>,
lock_state_json: Option<Option<String>>,
draft_profile_json: Option<Option<String>>,
last_assistant_reply: Option<Option<String>>,
publish_gate_json: Option<Option<String>>,
result_preview_json: Option<Option<String>>,
pending_clarifications_json: Option<String>,
quality_findings_json: Option<String>,
suggested_actions_json: Option<String>,
recommended_replies_json: Option<String>,
asset_coverage_json: Option<String>,
checkpoints_json: Option<String>,
updated_at_micros: Option<i64>,
}
#[derive(Clone, Debug, Default)]
struct CustomWorldAgentOperationPatch {
status: Option<RpgAgentOperationStatus>,
phase_label: Option<String>,
phase_detail: Option<String>,
progress: Option<u32>,
error_message: Option<Option<String>>,
updated_at_micros: Option<i64>,
}
fn build_custom_world_publish_gate_from_session(
session: &CustomWorldAgentSession,
) -> CustomWorldPublishGateSnapshot {
let quality_findings = parse_json_array_or_empty(&session.quality_findings_json);
summarize_publish_gate_from_json(
&session.session_id,
session.stage,
parse_optional_session_object(session.draft_profile_json.as_deref()).as_ref(),
&quality_findings,
)
}
fn summarize_publish_gate_from_json(
session_id: &str,
stage: RpgAgentStage,
draft_profile: Option<&JsonMap<String, JsonValue>>,
quality_findings: &[JsonValue],
) -> CustomWorldPublishGateSnapshot {
let profile_id = draft_profile
.and_then(|profile| read_optional_text_field(profile, &["legacyResultProfile.id", "id"]))
.unwrap_or_else(|| format!("agent-draft-{session_id}"));
let mut blockers = Vec::new();
if draft_profile.is_none() {
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_empty_draft".to_string(),
code: "publish_empty_draft".to_string(),
message: "当前世界草稿为空,无法发布。".to_string(),
});
}
if let Some(profile) = draft_profile {
if read_optional_text_field(
profile,
&[
"worldHook",
"creatorIntent.worldHook",
"anchorContent.worldPromise",
"anchorContent.worldPromise.hook",
"settingText",
],
)
.is_none()
{
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_missing_world_hook".to_string(),
code: "publish_missing_world_hook".to_string(),
message: "当前世界缺少 world hook发布前需要先补齐世界一句话钩子。".to_string(),
});
}
if read_optional_text_field(
profile,
&[
"playerPremise",
"creatorIntent.playerPremise",
"anchorContent.playerEntryPoint",
"anchorContent.playerEntryPoint.openingIdentity",
"anchorContent.playerEntryPoint.openingProblem",
"anchorContent.playerEntryPoint.entryMotivation",
],
)
.is_none()
{
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_missing_player_premise".to_string(),
code: "publish_missing_player_premise".to_string(),
message: "当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。"
.to_string(),
});
}
if !json_array_has_non_empty_text(profile.get("coreConflicts")) {
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_missing_core_conflict".to_string(),
code: "publish_missing_core_conflict".to_string(),
message: "当前世界缺少核心冲突,发布前需要先补齐核心冲突。".to_string(),
});
}
let has_main_chapter = profile
.get("chapters")
.and_then(JsonValue::as_array)
.map(|value| !value.is_empty())
.unwrap_or(false)
|| profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.map(|value| !value.is_empty())
.unwrap_or(false)
|| profile
.get("sceneChapters")
.and_then(JsonValue::as_array)
.map(|value| !value.is_empty())
.unwrap_or(false);
if !has_main_chapter {
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_missing_main_chapter".to_string(),
code: "publish_missing_main_chapter".to_string(),
message: "当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。".to_string(),
});
}
let has_scene_act = profile
.get("sceneChapterBlueprints")
.or_else(|| profile.get("sceneChapters"))
.and_then(JsonValue::as_array)
.map(|chapters| {
chapters.iter().any(|chapter| {
chapter
.get("acts")
.and_then(JsonValue::as_array)
.map(|acts| !acts.is_empty())
.unwrap_or(false)
})
})
.unwrap_or(false);
if !has_scene_act {
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: "publish_missing_first_act".to_string(),
code: "publish_missing_first_act".to_string(),
message: "当前世界还没有主线第一幕,发布前至少要保留一个场景幕。".to_string(),
});
}
}
for finding in quality_findings {
if finding.get("severity").and_then(JsonValue::as_str) == Some("blocker") {
blockers.push(CustomWorldPublishBlockerSnapshot {
blocker_id: finding
.get("id")
.and_then(JsonValue::as_str)
.unwrap_or("publish-quality-blocker")
.to_string(),
code: finding
.get("code")
.and_then(JsonValue::as_str)
.unwrap_or("publish_quality_blocker")
.to_string(),
message: finding
.get("message")
.and_then(JsonValue::as_str)
.unwrap_or("当前世界仍存在 blocker。")
.to_string(),
});
}
}
let blocker_count = blockers.len() as u32;
let publish_ready = blocker_count == 0;
CustomWorldPublishGateSnapshot {
profile_id,
blockers,
blocker_count,
publish_ready,
can_enter_world: stage == RpgAgentStage::Published && publish_ready,
}
}
fn publish_gate_to_json_value(gate: &CustomWorldPublishGateSnapshot) -> JsonValue {
json!({
"profileId": gate.profile_id,
"blockers": gate.blockers.iter().map(|entry| {
json!({
"id": entry.blocker_id,
"code": entry.code,
"message": entry.message,
})
}).collect::<Vec<_>>(),
"blockerCount": gate.blocker_count,
"publishReady": gate.publish_ready,
"canEnterWorld": gate.can_enter_world,
})
}
fn build_result_preview_json(
draft_profile: Option<&JsonMap<String, JsonValue>>,
gate: &CustomWorldPublishGateSnapshot,
quality_findings: &[JsonValue],
generated_at_micros: i64,
) -> Result<Option<String>, String> {
let Some(profile) = draft_profile else {
return Ok(None);
};
serialize_json_value(&json!({
"preview": JsonValue::Object(profile.clone()),
"source": "session_preview",
"generatedAt": format_timestamp_micros(generated_at_micros),
"qualityFindings": quality_findings,
"blockers": gate.blockers.iter().map(|entry| {
json!({
"id": entry.blocker_id,
"code": entry.code,
"message": entry.message,
})
}).collect::<Vec<_>>(),
"publishReady": gate.publish_ready,
"canEnterWorld": gate.can_enter_world,
}))
.map(Some)
}
fn build_supported_actions_json(
stage: RpgAgentStage,
progress_percent: u32,
gate: &CustomWorldPublishGateSnapshot,
checkpoints: &[JsonValue],
) -> Vec<JsonValue> {
let has_checkpoint = checkpoints
.iter()
.any(|entry| entry.get("snapshot").is_some());
let draft_refining_enabled = matches!(
stage,
RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining
);
let long_tail_enabled = matches!(
stage,
RpgAgentStage::ObjectRefining
| RpgAgentStage::VisualRefining
| RpgAgentStage::LongTailReview
| RpgAgentStage::ReadyToPublish
);
vec![
build_supported_action_json(
"draft_foundation",
progress_percent >= 100,
(progress_percent < 100).then(|| "draft_foundation requires progressPercent >= 100".to_string()),
),
build_supported_action_json(
"update_draft_card",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"update_draft_card is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"sync_result_profile",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"sync_result_profile is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"generate_characters",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"generate_characters is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"generate_landmarks",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"generate_landmarks is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"generate_role_assets",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"generate_role_assets is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"sync_role_assets",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"sync_role_assets is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"generate_scene_assets",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"generate_scene_assets is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"sync_scene_assets",
draft_refining_enabled,
(!draft_refining_enabled).then(|| {
"sync_scene_assets is only available during object_refining or visual_refining"
.to_string()
}),
),
build_supported_action_json(
"expand_long_tail",
long_tail_enabled,
(!long_tail_enabled).then(|| {
"expand_long_tail is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string()
}),
),
build_supported_action_json(
"publish_world",
long_tail_enabled && gate.publish_ready,
(!long_tail_enabled)
.then(|| {
"publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string()
})
.or_else(|| (!gate.publish_ready).then(|| "publish_world requires publish gate without blockers".to_string())),
),
build_supported_action_json(
"revert_checkpoint",
long_tail_enabled && has_checkpoint,
(!long_tail_enabled)
.then(|| {
"revert_checkpoint is only available during object_refining, visual_refining, long_tail_review or ready_to_publish".to_string()
})
.or_else(|| (!has_checkpoint).then(|| "revert_checkpoint requires at least one restorable checkpoint snapshot".to_string())),
),
]
}
fn build_supported_action_json(action: &str, enabled: bool, reason: Option<String>) -> JsonValue {
json!({
"action": action,
"enabled": enabled,
"reason": reason,
})
}
fn build_custom_world_draft_card_detail_snapshot(
card: &CustomWorldDraftCard,
) -> Result<CustomWorldDraftCardDetailSnapshot, String> {
if let Some(detail_payload_json) = card.detail_payload_json.as_deref() {
let detail_value =
serde_json::from_str::<JsonValue>(detail_payload_json).map_err(|error| {
format!("custom_world_draft_card.detail_payload_json 非法: {error}")
})?;
if let Some(object) = detail_value.as_object() {
let sections = object
.get("sections")
.and_then(JsonValue::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| {
let object = entry.as_object()?;
Some(CustomWorldDraftCardDetailSectionSnapshot {
section_id: object.get("id")?.as_str()?.to_string(),
label: object
.get("label")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string(),
value: object
.get("value")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string(),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| build_fallback_card_sections(&card));
return Ok(CustomWorldDraftCardDetailSnapshot {
card_id: card.card_id.clone(),
kind: card.kind,
title: object
.get("title")
.and_then(JsonValue::as_str)
.unwrap_or(card.title.as_str())
.to_string(),
sections,
linked_ids_json: card.linked_ids_json.clone(),
locked: object
.get("locked")
.and_then(JsonValue::as_bool)
.unwrap_or(false),
editable: object
.get("editable")
.and_then(JsonValue::as_bool)
.unwrap_or(false),
editable_section_ids_json: serialize_json_value(
object
.get("editableSectionIds")
.unwrap_or(&JsonValue::Array(Vec::new())),
)?,
warning_messages_json: serialize_json_value(
object
.get("warningMessages")
.unwrap_or(&JsonValue::Array(Vec::new())),
)?,
asset_status: card.asset_status,
asset_status_label: card.asset_status_label.clone(),
});
}
}
Ok(CustomWorldDraftCardDetailSnapshot {
card_id: card.card_id.clone(),
kind: card.kind,
title: card.title.clone(),
sections: build_fallback_card_sections(card),
linked_ids_json: card.linked_ids_json.clone(),
locked: false,
editable: false,
editable_section_ids_json: "[]".to_string(),
warning_messages_json: "[]".to_string(),
asset_status: card.asset_status,
asset_status_label: card.asset_status_label.clone(),
})
}
fn build_fallback_card_sections(
card: &CustomWorldDraftCard,
) -> Vec<CustomWorldDraftCardDetailSectionSnapshot> {
vec![
CustomWorldDraftCardDetailSectionSnapshot {
section_id: "title".to_string(),
label: "标题".to_string(),
value: card.title.clone(),
},
CustomWorldDraftCardDetailSectionSnapshot {
section_id: "subtitle".to_string(),
label: "副标题".to_string(),
value: card.subtitle.clone(),
},
CustomWorldDraftCardDetailSectionSnapshot {
section_id: "summary".to_string(),
label: "摘要".to_string(),
value: card.summary.clone(),
},
]
}
fn build_fallback_card_sections_json(card: &CustomWorldDraftCard) -> Vec<JsonValue> {
build_fallback_card_sections(card)
.into_iter()
.map(|section| {
json!({
"id": section.section_id,
"label": section.label,
"value": section.value,
})
})
.collect()
}
fn rebuild_custom_world_agent_session_row(
current: &CustomWorldAgentSession,
patch: CustomWorldAgentSessionPatch,
) -> Result<CustomWorldAgentSession, String> {
Ok(CustomWorldAgentSession {
session_id: current.session_id.clone(),
owner_user_id: current.owner_user_id.clone(),
seed_text: current.seed_text.clone(),
current_turn: patch.current_turn.unwrap_or(current.current_turn),
progress_percent: patch.progress_percent.unwrap_or(current.progress_percent),
stage: patch.stage.unwrap_or(current.stage),
focus_card_id: patch
.focus_card_id
.unwrap_or_else(|| current.focus_card_id.clone()),
anchor_content_json: patch
.anchor_content_json
.unwrap_or_else(|| current.anchor_content_json.clone()),
creator_intent_json: patch
.creator_intent_json
.unwrap_or_else(|| current.creator_intent_json.clone()),
creator_intent_readiness_json: patch
.creator_intent_readiness_json
.unwrap_or_else(|| current.creator_intent_readiness_json.clone()),
anchor_pack_json: patch
.anchor_pack_json
.unwrap_or_else(|| current.anchor_pack_json.clone()),
lock_state_json: patch
.lock_state_json
.unwrap_or_else(|| current.lock_state_json.clone()),
draft_profile_json: patch
.draft_profile_json
.unwrap_or_else(|| current.draft_profile_json.clone()),
last_assistant_reply: patch
.last_assistant_reply
.unwrap_or_else(|| current.last_assistant_reply.clone()),
publish_gate_json: patch
.publish_gate_json
.unwrap_or_else(|| current.publish_gate_json.clone()),
result_preview_json: patch
.result_preview_json
.unwrap_or_else(|| current.result_preview_json.clone()),
pending_clarifications_json: patch
.pending_clarifications_json
.unwrap_or_else(|| current.pending_clarifications_json.clone()),
quality_findings_json: patch
.quality_findings_json
.unwrap_or_else(|| current.quality_findings_json.clone()),
suggested_actions_json: patch
.suggested_actions_json
.unwrap_or_else(|| current.suggested_actions_json.clone()),
recommended_replies_json: patch
.recommended_replies_json
.unwrap_or_else(|| current.recommended_replies_json.clone()),
asset_coverage_json: patch
.asset_coverage_json
.unwrap_or_else(|| current.asset_coverage_json.clone()),
checkpoints_json: patch
.checkpoints_json
.unwrap_or_else(|| current.checkpoints_json.clone()),
created_at: current.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(
patch
.updated_at_micros
.unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()),
),
})
}
fn rebuild_custom_world_agent_operation_row(
current: &CustomWorldAgentOperation,
patch: CustomWorldAgentOperationPatch,
) -> Result<CustomWorldAgentOperation, String> {
let phase_label = patch
.phase_label
.unwrap_or_else(|| current.phase_label.clone());
let progress = patch.progress.unwrap_or(current.progress);
validate_custom_world_agent_operation_fields(
&current.operation_id,
&current.session_id,
&phase_label,
progress,
)
.map_err(|error| error.to_string())?;
Ok(CustomWorldAgentOperation {
operation_id: current.operation_id.clone(),
session_id: current.session_id.clone(),
operation_type: current.operation_type,
status: patch.status.unwrap_or(current.status),
phase_label,
phase_detail: patch
.phase_detail
.unwrap_or_else(|| current.phase_detail.clone()),
progress,
error_message: patch
.error_message
.unwrap_or_else(|| current.error_message.clone()),
created_at: current.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(
patch
.updated_at_micros
.unwrap_or_else(|| current.updated_at.to_micros_since_unix_epoch()),
),
})
}
fn replace_custom_world_agent_session(
ctx: &ReducerContext,
current: &CustomWorldAgentSession,
next: CustomWorldAgentSession,
) {
ctx.db
.custom_world_agent_session()
.session_id()
.delete(&current.session_id);
ctx.db.custom_world_agent_session().insert(next);
}
fn replace_custom_world_agent_operation(
ctx: &ReducerContext,
current: &CustomWorldAgentOperation,
next: CustomWorldAgentOperation,
) {
ctx.db
.custom_world_agent_operation()
.operation_id()
.delete(&current.operation_id);
ctx.db.custom_world_agent_operation().insert(next);
}
fn replace_custom_world_draft_card(
ctx: &ReducerContext,
current: &CustomWorldDraftCard,
next: CustomWorldDraftCard,
) {
ctx.db
.custom_world_draft_card()
.card_id()
.delete(&current.card_id);
ctx.db.custom_world_draft_card().insert(next);
}
fn complete_custom_world_operation(
ctx: &ReducerContext,
operation_id: &str,
session_id: &str,
operation_type: RpgAgentOperationType,
phase_label: &str,
phase_detail: &str,
timestamp_micros: i64,
) -> Result<CustomWorldAgentOperation, String> {
if let Some(current) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&operation_id.to_string())
{
if current.session_id != session_id {
return Err("custom_world_agent_operation.session_id 不匹配".to_string());
}
if current.operation_type != operation_type {
return Err("custom_world_agent_operation.operation_type 不匹配".to_string());
}
let next = rebuild_custom_world_agent_operation_row(
&current,
CustomWorldAgentOperationPatch {
status: Some(RpgAgentOperationStatus::Completed),
phase_label: Some(phase_label.to_string()),
phase_detail: Some(phase_detail.to_string()),
progress: Some(100),
error_message: Some(None),
updated_at_micros: Some(timestamp_micros),
},
)?;
replace_custom_world_agent_operation(ctx, &current, next.clone());
return Ok(next);
}
Ok(build_and_insert_custom_world_operation(
ctx,
operation_id,
session_id,
operation_type,
phase_label,
phase_detail,
timestamp_micros,
))
}
fn build_and_insert_custom_world_operation(
ctx: &ReducerContext,
operation_id: &str,
session_id: &str,
operation_type: RpgAgentOperationType,
phase_label: &str,
phase_detail: &str,
timestamp_micros: i64,
) -> CustomWorldAgentOperation {
let row = CustomWorldAgentOperation {
operation_id: operation_id.to_string(),
session_id: session_id.to_string(),
operation_type,
status: RpgAgentOperationStatus::Completed,
phase_label: phase_label.to_string(),
phase_detail: phase_detail.to_string(),
progress: 100,
error_message: None,
created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros),
};
ctx.db.custom_world_agent_operation().insert(row)
}
fn append_custom_world_action_result_message(
ctx: &ReducerContext,
session_id: &str,
operation_id: &str,
text: &str,
timestamp_micros: i64,
) {
let row = CustomWorldAgentMessage {
message_id: format!("message-action-{}-{}", operation_id, timestamp_micros),
session_id: session_id.to_string(),
role: RpgAgentMessageRole::Assistant,
kind: RpgAgentMessageKind::ActionResult,
text: text.to_string(),
related_operation_id: Some(operation_id.to_string()),
created_at: Timestamp::from_micros_since_unix_epoch(timestamp_micros),
};
ctx.db.custom_world_agent_message().insert(row);
}
fn upsert_world_foundation_card(
ctx: &ReducerContext,
session_id: &str,
draft_profile: &JsonMap<String, JsonValue>,
updated_at_micros: i64,
) -> Result<(), String> {
let card_id = build_world_foundation_card_id(session_id);
let existing_card = ctx
.db
.custom_world_draft_card()
.card_id()
.find(&card_id)
.filter(|row| row.session_id == session_id);
let title = read_optional_text_field(draft_profile, &["name", "title"])
.unwrap_or_else(|| "世界底稿".to_string());
let subtitle = read_optional_text_field(draft_profile, &["subtitle"]).unwrap_or_default();
let summary = read_optional_text_field(draft_profile, &["summary"])
.unwrap_or_else(|| "第一版世界底稿已生成。".to_string());
let detail_payload_json = serialize_json_value(&json!({
"id": card_id,
"kind": "world",
"title": title,
"sections": [
{ "id": "title", "label": "标题", "value": read_optional_text_field(draft_profile, &["name", "title"]).unwrap_or_else(|| "世界底稿".to_string()) },
{ "id": "subtitle", "label": "副标题", "value": subtitle },
{ "id": "summary", "label": "摘要", "value": summary },
],
"linkedIds": [],
"locked": false,
"editable": false,
"editableSectionIds": [],
"warningMessages": [],
}))?;
if let Some(existing) = existing_card {
replace_custom_world_draft_card(
ctx,
&existing,
CustomWorldDraftCard {
card_id: existing.card_id.clone(),
session_id: existing.session_id.clone(),
kind: RpgAgentDraftCardKind::World,
status: RpgAgentDraftCardStatus::Confirmed,
title: read_optional_text_field(draft_profile, &["name", "title"])
.unwrap_or_else(|| "世界底稿".to_string()),
subtitle: read_optional_text_field(draft_profile, &["subtitle"])
.unwrap_or_default(),
summary: read_optional_text_field(draft_profile, &["summary"])
.unwrap_or_else(|| "第一版世界底稿已生成。".to_string()),
linked_ids_json: "[]".to_string(),
warning_count: 0,
asset_status: None,
asset_status_label: None,
detail_payload_json: Some(detail_payload_json),
created_at: existing.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
},
);
} else {
ctx.db
.custom_world_draft_card()
.insert(CustomWorldDraftCard {
card_id,
session_id: session_id.to_string(),
kind: RpgAgentDraftCardKind::World,
status: RpgAgentDraftCardStatus::Confirmed,
title: read_optional_text_field(draft_profile, &["name", "title"])
.unwrap_or_else(|| "世界底稿".to_string()),
subtitle: read_optional_text_field(draft_profile, &["subtitle"])
.unwrap_or_default(),
summary: read_optional_text_field(draft_profile, &["summary"])
.unwrap_or_else(|| "第一版世界底稿已生成。".to_string()),
linked_ids_json: "[]".to_string(),
warning_count: 0,
asset_status: None,
asset_status_label: None,
detail_payload_json: Some(detail_payload_json),
created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
}
Ok(())
}
fn build_world_foundation_card_id(session_id: &str) -> String {
// `custom_world_draft_card.card_id` 是全局主键,世界底稿卡必须带上会话维度,避免多会话写入时触发唯一键冲突。
format!("custom-world:{session_id}:world-foundation")
}
fn sync_session_draft_profile_from_card_update(
session: &CustomWorldAgentSession,
card: &CustomWorldDraftCard,
updated_title: &str,
updated_subtitle: &str,
updated_summary: &str,
updated_at_micros: i64,
) -> Result<CustomWorldAgentSession, String> {
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
.unwrap_or_else(|| build_minimal_draft_profile_from_seed(&session.seed_text));
if card.kind == RpgAgentDraftCardKind::World {
draft_profile.insert(
"name".to_string(),
JsonValue::String(updated_title.to_string()),
);
draft_profile.insert(
"subtitle".to_string(),
JsonValue::String(updated_subtitle.to_string()),
);
draft_profile.insert(
"summary".to_string(),
JsonValue::String(updated_summary.to_string()),
);
}
let gate = summarize_publish_gate_from_json(
&session.session_id,
session.stage,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
&parse_json_array_or_empty(&session.quality_findings_json),
updated_at_micros,
)?),
last_assistant_reply: Some(Some(format!("卡片《{}》已更新。", updated_title))),
updated_at_micros: Some(updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)
}
fn ensure_refining_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> {
if matches!(
stage,
RpgAgentStage::ObjectRefining | RpgAgentStage::VisualRefining
) {
Ok(())
} else {
Err(format!(
"{action} is only available during object_refining or visual_refining"
))
}
}
fn ensure_result_profile_sync_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> {
if matches!(
stage,
RpgAgentStage::ObjectRefining
| RpgAgentStage::VisualRefining
| RpgAgentStage::LongTailReview
| RpgAgentStage::ReadyToPublish
) {
Ok(())
} else {
Err(format!(
"{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish"
))
}
}
fn ensure_long_tail_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> {
if matches!(
stage,
RpgAgentStage::ObjectRefining
| RpgAgentStage::VisualRefining
| RpgAgentStage::LongTailReview
| RpgAgentStage::ReadyToPublish
) {
Ok(())
} else {
Err(format!(
"{action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish"
))
}
}
fn ensure_publishable_stage(stage: RpgAgentStage, action: &str) -> Result<(), String> {
ensure_long_tail_stage(stage, action)
}
fn map_action_name_to_operation_type(action: &str) -> Option<RpgAgentOperationType> {
match action {
"draft_foundation" => Some(RpgAgentOperationType::DraftFoundation),
"update_draft_card" => Some(RpgAgentOperationType::UpdateDraftCard),
"sync_result_profile" => Some(RpgAgentOperationType::SyncResultProfile),
"generate_characters" => Some(RpgAgentOperationType::GenerateCharacters),
"generate_landmarks" => Some(RpgAgentOperationType::GenerateLandmarks),
"generate_role_assets" => Some(RpgAgentOperationType::GenerateRoleAssets),
"sync_role_assets" => Some(RpgAgentOperationType::SyncRoleAssets),
"generate_scene_assets" => Some(RpgAgentOperationType::GenerateSceneAssets),
"sync_scene_assets" => Some(RpgAgentOperationType::SyncSceneAssets),
"expand_long_tail" => Some(RpgAgentOperationType::ExpandLongTail),
"publish_world" => Some(RpgAgentOperationType::PublishWorld),
"revert_checkpoint" => Some(RpgAgentOperationType::RevertCheckpoint),
_ => None,
}
}
fn parse_rpg_agent_stage(value: &str) -> Option<RpgAgentStage> {
match value.trim() {
"collecting_intent" => Some(RpgAgentStage::CollectingIntent),
"clarifying" => Some(RpgAgentStage::Clarifying),
"foundation_review" => Some(RpgAgentStage::FoundationReview),
"object_refining" => Some(RpgAgentStage::ObjectRefining),
"visual_refining" => Some(RpgAgentStage::VisualRefining),
"long_tail_review" => Some(RpgAgentStage::LongTailReview),
"ready_to_publish" => Some(RpgAgentStage::ReadyToPublish),
"published" => Some(RpgAgentStage::Published),
"error" => Some(RpgAgentStage::Error),
_ => None,
}
}
fn resolve_rpg_agent_stage_label(stage: RpgAgentStage) -> &'static str {
match stage {
RpgAgentStage::CollectingIntent => "收集世界锚点",
RpgAgentStage::Clarifying => "补齐关键锚点",
RpgAgentStage::FoundationReview => "准备整理底稿",
RpgAgentStage::ObjectRefining => "待完善草稿",
RpgAgentStage::VisualRefining => "视觉工坊",
RpgAgentStage::LongTailReview => "扩展长尾",
RpgAgentStage::ReadyToPublish => "准备发布",
RpgAgentStage::Published => "已发布",
RpgAgentStage::Error => "发生错误",
}
}
fn parse_optional_session_object(value: Option<&str>) -> Option<JsonMap<String, JsonValue>> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| serde_json::from_str::<JsonValue>(value).ok())
.and_then(|value| value.as_object().cloned())
}
fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
serde_json::from_str::<JsonValue>(raw)
.ok()
.and_then(|value| value.as_array().cloned())
.unwrap_or_default()
}
fn serialize_json_value(value: &JsonValue) -> Result<String, String> {
serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}"))
}
fn read_required_payload_text(
payload: &JsonMap<String, JsonValue>,
key: &str,
error_message: &str,
) -> Result<String, String> {
payload
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| error_message.to_string())
}
fn read_optional_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
for key in keys {
let mut current = JsonValue::Object(object.clone());
let mut found = true;
for segment in key.split('.') {
if let Some(next) = current.get(segment) {
current = next.clone();
} else {
found = false;
break;
}
}
if found {
if let Some(value) = current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
}
None
}
fn resolve_session_work_title(
session: &CustomWorldAgentSession,
draft_profile: Option<&JsonMap<String, JsonValue>>,
) -> String {
draft_profile
.and_then(|profile| read_optional_text_field(profile, &["name", "title"]))
.or_else(|| {
let seed = session.seed_text.trim();
(!seed.is_empty()).then(|| seed.to_string())
})
.unwrap_or_else(|| "未命名草稿".to_string())
}
fn resolve_session_work_summary(
session: &CustomWorldAgentSession,
draft_profile: Option<&JsonMap<String, JsonValue>>,
) -> String {
draft_profile
.and_then(|profile| read_optional_text_field(profile, &["summary"]))
.or_else(|| {
let seed = session.seed_text.trim();
(!seed.is_empty()).then(|| seed.to_string())
})
.unwrap_or_else(|| "还在收集你的世界锚点。".to_string())
}
fn resolve_session_work_subtitle(
draft_profile: Option<&JsonMap<String, JsonValue>>,
stage_label: Option<&str>,
) -> String {
draft_profile
.and_then(|profile| read_optional_text_field(profile, &["subtitle"]))
.or_else(|| stage_label.map(ToOwned::to_owned))
.unwrap_or_default()
}
fn resolve_session_work_cover_image_src(
draft_profile: Option<&JsonMap<String, JsonValue>>,
) -> Option<String> {
let profile = draft_profile?;
if let Some(camp) = profile.get("camp").and_then(JsonValue::as_object) {
if let Some(image_src) = read_optional_text_field(camp, &["imageSrc"]) {
return Some(image_src);
}
}
if let Some(landmarks) = profile.get("landmarks").and_then(JsonValue::as_array) {
for landmark in landmarks {
if let Some(object) = landmark.as_object() {
if let Some(image_src) = read_optional_text_field(object, &["imageSrc"]) {
return Some(image_src);
}
}
}
}
None
}
fn resolve_session_work_counts(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
draft_profile: Option<&JsonMap<String, JsonValue>>,
) -> (u32, u32) {
if let Some(profile) = draft_profile {
let role_count = profile
.get("playableNpcs")
.and_then(JsonValue::as_array)
.map(|entries| entries.len() as u32)
.unwrap_or(0)
+ profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.map(|entries| entries.len() as u32)
.unwrap_or(0);
let landmark_count = profile
.get("landmarks")
.and_then(JsonValue::as_array)
.map(|entries| entries.len() as u32)
.unwrap_or(0);
return (role_count, landmark_count);
}
let mut role_count = 0u32;
let mut landmark_count = 0u32;
for card in ctx
.db
.custom_world_draft_card()
.iter()
.filter(|row| row.session_id == session.session_id)
{
match card.kind {
RpgAgentDraftCardKind::Character => {
role_count = role_count.saturating_add(1);
}
RpgAgentDraftCardKind::Landmark => {
landmark_count = landmark_count.saturating_add(1);
}
_ => {}
}
}
(role_count, landmark_count)
}
fn ensure_minimal_draft_profile(
mut profile: JsonMap<String, JsonValue>,
seed_text: &str,
) -> JsonMap<String, JsonValue> {
if read_optional_text_field(&profile, &["name", "title"]).is_none() {
profile.insert(
"name".to_string(),
JsonValue::String(seed_text.trim().to_string().if_empty("未命名草稿")),
);
}
if read_optional_text_field(&profile, &["summary"]).is_none() {
profile.insert(
"summary".to_string(),
JsonValue::String(
(!seed_text.trim().is_empty())
.then(|| seed_text.trim().to_string())
.unwrap_or_else(|| "还在收集你的世界锚点。".to_string()),
),
);
}
profile
.entry("subtitle".to_string())
.or_insert_with(|| JsonValue::String(String::new()));
profile
.entry("worldHook".to_string())
.or_insert_with(|| JsonValue::String(String::new()));
profile
.entry("playerPremise".to_string())
.or_insert_with(|| JsonValue::String(String::new()));
profile
.entry("coreConflicts".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
.entry("playableNpcs".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
.entry("storyNpcs".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
.entry("landmarks".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
.entry("chapters".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
.entry("sceneChapters".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
.entry("sceneChapterBlueprints".to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()));
profile
}
fn build_minimal_draft_profile_from_seed(seed_text: &str) -> JsonMap<String, JsonValue> {
ensure_minimal_draft_profile(JsonMap::new(), seed_text)
}
fn build_session_checkpoint_value(
checkpoint_id_suffix: &str,
label: &str,
session: &CustomWorldAgentSession,
) -> JsonValue {
json!({
"checkpointId": format!("checkpoint-{}-{}", session.session_id, checkpoint_id_suffix),
"createdAt": format_timestamp_micros(session.updated_at.to_micros_since_unix_epoch()),
"label": label,
"snapshot": {
"stage": session.stage.as_str(),
"progressPercent": session.progress_percent,
"draftProfile": parse_optional_session_object(session.draft_profile_json.as_deref()).map(JsonValue::Object),
"qualityFindings": parse_json_array_or_empty(&session.quality_findings_json),
}
})
}
fn append_checkpoint_json(current: &str, checkpoint: &JsonValue) -> Result<String, String> {
let mut checkpoints = parse_json_array_or_empty(current);
checkpoints.push(checkpoint.clone());
serialize_json_value(&JsonValue::Array(checkpoints))
}
fn extract_detail_section_value(sections: &[JsonValue], target_id: &str) -> Option<String> {
sections.iter().find_map(|entry| {
let object = entry.as_object()?;
(object.get("id").and_then(JsonValue::as_str) == Some(target_id)).then(|| {
object
.get("value")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string()
})
})
}
fn json_array_has_non_empty_text(value: Option<&JsonValue>) -> bool {
value
.and_then(JsonValue::as_array)
.map(|entries| {
entries.iter().any(|entry| {
entry
.as_str()
.map(str::trim)
.filter(|text| !text.is_empty())
.is_some()
})
})
.unwrap_or(false)
}
trait IfEmptyString {
fn if_empty(self, fallback: &str) -> String;
}
impl IfEmptyString for String {
fn if_empty(self, fallback: &str) -> String {
if self.trim().is_empty() {
fallback.to_string()
} else {
self
}
}
}
fn mark_custom_world_agent_session_published(
ctx: &ReducerContext,
session_id: &str,
owner_user_id: &str,
updated_at_micros: i64,
) -> Result<RpgAgentStage, String> {
let existing = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&session_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在,无法推进到 published".to_string())?;
ctx.db
.custom_world_agent_session()
.session_id()
.delete(&existing.session_id);
let next_row = CustomWorldAgentSession {
session_id: existing.session_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
seed_text: existing.seed_text.clone(),
current_turn: existing.current_turn,
progress_percent: existing.progress_percent,
stage: RpgAgentStage::Published,
focus_card_id: existing.focus_card_id.clone(),
anchor_content_json: existing.anchor_content_json.clone(),
creator_intent_json: existing.creator_intent_json.clone(),
creator_intent_readiness_json: existing.creator_intent_readiness_json.clone(),
anchor_pack_json: existing.anchor_pack_json.clone(),
lock_state_json: existing.lock_state_json.clone(),
draft_profile_json: existing.draft_profile_json.clone(),
last_assistant_reply: existing.last_assistant_reply.clone(),
publish_gate_json: existing.publish_gate_json.clone(),
result_preview_json: existing.result_preview_json.clone(),
pending_clarifications_json: existing.pending_clarifications_json.clone(),
quality_findings_json: existing.quality_findings_json.clone(),
suggested_actions_json: existing.suggested_actions_json.clone(),
recommended_replies_json: existing.recommended_replies_json.clone(),
asset_coverage_json: existing.asset_coverage_json.clone(),
checkpoints_json: existing.checkpoints_json.clone(),
created_at: existing.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
};
ctx.db.custom_world_agent_session().insert(next_row);
Ok(RpgAgentStage::Published)
}
fn sync_custom_world_gallery_entry_from_profile(
ctx: &ReducerContext,
profile: &CustomWorldProfile,
) -> Result<CustomWorldGalleryEntrySnapshot, String> {
let published_at = profile
.published_at
.ok_or_else(|| "published profile 缺少 published_at无法同步 gallery".to_string())?;
ctx.db
.custom_world_gallery_entry()
.profile_id()
.delete(&profile.profile_id);
let row = CustomWorldGalleryEntry {
profile_id: profile.profile_id.clone(),
owner_user_id: profile.owner_user_id.clone(),
public_work_code: profile.public_work_code.clone().ok_or_else(|| {
"published profile 缺少 public_work_code无法同步 gallery".to_string()
})?,
author_public_user_code: profile.author_public_user_code.clone().ok_or_else(|| {
"published profile 缺少 author_public_user_code无法同步 gallery".to_string()
})?,
author_display_name: profile.author_display_name.clone(),
world_name: profile.world_name.clone(),
subtitle: profile.subtitle.clone(),
summary_text: profile.summary_text.clone(),
cover_image_src: profile.cover_image_src.clone(),
theme_mode: profile.theme_mode,
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
published_at,
updated_at: profile.updated_at,
};
let inserted = ctx.db.custom_world_gallery_entry().insert(row);
Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted))
}
fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> {
let published_profiles = ctx
.db
.custom_world_profile()
.iter()
.filter(|profile| {
profile.publication_status == CustomWorldPublicationStatus::Published
&& profile.deleted_at.is_none()
})
.collect::<Vec<_>>();
for profile in published_profiles {
if profile.published_at.is_none() {
continue;
}
let existing_gallery_entry = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&profile.profile_id)
.filter(|entry| entry.owner_user_id == profile.owner_user_id);
if existing_gallery_entry.is_some()
&& profile.public_work_code.is_some()
&& profile.author_public_user_code.is_some()
{
continue;
}
let profile_with_public_fields = ensure_custom_world_profile_public_fields(ctx, &profile);
sync_custom_world_gallery_entry_from_profile(ctx, &profile_with_public_fields)?;
}
Ok(())
}
fn ensure_custom_world_profile_public_fields(
ctx: &ReducerContext,
profile: &CustomWorldProfile,
) -> CustomWorldProfile {
if profile.public_work_code.is_some() && profile.author_public_user_code.is_some() {
return build_custom_world_profile_row_copy(profile);
}
ctx.db
.custom_world_profile()
.profile_id()
.delete(&profile.profile_id);
let next_row = CustomWorldProfile {
profile_id: profile.profile_id.clone(),
owner_user_id: profile.owner_user_id.clone(),
public_work_code: profile
.public_work_code
.clone()
.or_else(|| Some(build_public_work_code_from_profile_id(&profile.profile_id))),
author_public_user_code: profile.author_public_user_code.clone().or_else(|| {
Some(build_public_user_code_from_owner_user_id(
&profile.owner_user_id,
))
}),
source_agent_session_id: profile.source_agent_session_id.clone(),
publication_status: profile.publication_status,
world_name: profile.world_name.clone(),
subtitle: profile.subtitle.clone(),
summary_text: profile.summary_text.clone(),
theme_mode: profile.theme_mode,
cover_image_src: profile.cover_image_src.clone(),
profile_payload_json: profile.profile_payload_json.clone(),
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
author_display_name: profile.author_display_name.clone(),
published_at: profile.published_at,
deleted_at: profile.deleted_at,
created_at: profile.created_at,
updated_at: profile.updated_at,
};
ctx.db.custom_world_profile().insert(next_row)
}
fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWorldProfile {
CustomWorldProfile {
profile_id: profile.profile_id.clone(),
owner_user_id: profile.owner_user_id.clone(),
public_work_code: profile.public_work_code.clone(),
author_public_user_code: profile.author_public_user_code.clone(),
source_agent_session_id: profile.source_agent_session_id.clone(),
publication_status: profile.publication_status,
world_name: profile.world_name.clone(),
subtitle: profile.subtitle.clone(),
summary_text: profile.summary_text.clone(),
theme_mode: profile.theme_mode,
cover_image_src: profile.cover_image_src.clone(),
profile_payload_json: profile.profile_payload_json.clone(),
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
author_display_name: profile.author_display_name.clone(),
published_at: profile.published_at,
deleted_at: profile.deleted_at,
created_at: profile.created_at,
updated_at: profile.updated_at,
}
}
fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
CustomWorldProfileSnapshot {
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
public_work_code: row.public_work_code.clone(),
author_public_user_code: row.author_public_user_code.clone(),
source_agent_session_id: row.source_agent_session_id.clone(),
publication_status: row.publication_status,
world_name: row.world_name.clone(),
subtitle: row.subtitle.clone(),
summary_text: row.summary_text.clone(),
theme_mode: row.theme_mode,
cover_image_src: row.cover_image_src.clone(),
profile_payload_json: row.profile_payload_json.clone(),
playable_npc_count: row.playable_npc_count,
landmark_count: row.landmark_count,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
author_display_name: row.author_display_name.clone(),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
deleted_at_micros: row
.deleted_at
.map(|value| value.to_micros_since_unix_epoch()),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_custom_world_agent_session_snapshot(
ctx: &ReducerContext,
row: &CustomWorldAgentSession,
) -> CustomWorldAgentSessionSnapshot {
let mut messages = ctx
.db
.custom_world_agent_message()
.iter()
.filter(|message| message.session_id == 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()));
let mut draft_cards = ctx
.db
.custom_world_draft_card()
.iter()
.filter(|card| card.session_id == 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()));
let mut operations = ctx
.db
.custom_world_agent_operation()
.iter()
.filter(|operation| operation.session_id == row.session_id)
.map(|operation| build_custom_world_agent_operation_snapshot(&operation))
.collect::<Vec<_>>();
operations
.sort_by_key(|operation| (operation.created_at_micros, operation.operation_id.clone()));
CustomWorldAgentSessionSnapshot {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent,
stage: row.stage,
focus_card_id: row.focus_card_id.clone(),
anchor_content_json: row.anchor_content_json.clone(),
creator_intent_json: row.creator_intent_json.clone(),
creator_intent_readiness_json: row.creator_intent_readiness_json.clone(),
anchor_pack_json: row.anchor_pack_json.clone(),
lock_state_json: row.lock_state_json.clone(),
draft_profile_json: row.draft_profile_json.clone(),
last_assistant_reply: row.last_assistant_reply.clone(),
publish_gate_json: row.publish_gate_json.clone(),
result_preview_json: row.result_preview_json.clone(),
pending_clarifications_json: row.pending_clarifications_json.clone(),
quality_findings_json: row.quality_findings_json.clone(),
suggested_actions_json: row.suggested_actions_json.clone(),
recommended_replies_json: row.recommended_replies_json.clone(),
asset_coverage_json: row.asset_coverage_json.clone(),
checkpoints_json: row.checkpoints_json.clone(),
supported_actions_json: serialize_json_value(&JsonValue::Array(
build_supported_actions_json(
row.stage,
row.progress_percent,
&build_custom_world_publish_gate_from_session(row),
&parse_json_array_or_empty(&row.checkpoints_json),
),
))
.unwrap_or_else(|_| "[]".to_string()),
messages,
draft_cards,
operations,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_custom_world_agent_message_snapshot(
row: &CustomWorldAgentMessage,
) -> CustomWorldAgentMessageSnapshot {
CustomWorldAgentMessageSnapshot {
message_id: row.message_id.clone(),
session_id: row.session_id.clone(),
role: row.role,
kind: row.kind,
text: row.text.clone(),
related_operation_id: row.related_operation_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
}
}
fn build_custom_world_agent_operation_snapshot(
row: &CustomWorldAgentOperation,
) -> CustomWorldAgentOperationSnapshot {
CustomWorldAgentOperationSnapshot {
operation_id: row.operation_id.clone(),
session_id: row.session_id.clone(),
operation_type: row.operation_type,
status: row.status,
phase_label: row.phase_label.clone(),
phase_detail: row.phase_detail.clone(),
progress: row.progress,
error_message: row.error_message.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_custom_world_draft_card_snapshot(
row: &CustomWorldDraftCard,
) -> CustomWorldDraftCardSnapshot {
CustomWorldDraftCardSnapshot {
card_id: row.card_id.clone(),
session_id: row.session_id.clone(),
kind: row.kind,
status: row.status,
title: row.title.clone(),
subtitle: row.subtitle.clone(),
summary: row.summary.clone(),
linked_ids_json: row.linked_ids_json.clone(),
warning_count: row.warning_count,
asset_status: row.asset_status,
asset_status_label: row.asset_status_label.clone(),
detail_payload_json: row.detail_payload_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_custom_world_gallery_entry_snapshot(
ctx: &ReducerContext,
row: &CustomWorldGalleryEntry,
) -> CustomWorldGalleryEntrySnapshot {
CustomWorldGalleryEntrySnapshot {
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
public_work_code: row.public_work_code.clone(),
author_public_user_code: row.author_public_user_code.clone(),
author_display_name: row.author_display_name.clone(),
world_name: row.world_name.clone(),
subtitle: row.subtitle.clone(),
summary_text: row.summary_text.clone(),
cover_image_src: row.cover_image_src.clone(),
theme_mode: row.theme_mode,
playable_npc_count: row.playable_npc_count,
landmark_count: row.landmark_count,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: count_recent_public_work_plays(
ctx,
"custom-world",
&row.profile_id,
ctx.timestamp.to_micros_since_unix_epoch(),
),
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
// 作品公开号保持稳定公开语义,本期先由 profile_id 派生 deterministic fallback
// 后续若引入独立 sequence 表,可无痛替换生成来源而不影响读写接口。
fn build_public_work_code_from_profile_id(profile_id: &str) -> String {
let digits = profile_id
.chars()
.filter(|character| character.is_ascii_digit())
.collect::<String>();
let normalized_digits = if digits.is_empty() {
let checksum = profile_id.bytes().fold(0u32, |accumulator, value| {
accumulator.wrapping_mul(131) + u32::from(value)
});
format!("{:08}", checksum % 100_000_000)
} else {
format!("{:0>8}", &digits[digits.len().saturating_sub(8)..])
};
format!("CW-{normalized_digits}")
}
fn build_public_user_code_from_owner_user_id(owner_user_id: &str) -> String {
owner_user_id
.trim_start_matches("user_")
.parse::<u64>()
.ok()
.map(|sequence| format!("SY-{sequence:08}"))
.unwrap_or_else(|| "SY-00000000".to_string())
}
fn normalize_public_work_code(input: &str) -> Option<String> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("CW").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return None;
}
Some(format!("CW-{digits:0>8}"))
}
fn build_quest_record_row(snapshot: QuestRecordSnapshot) -> QuestRecord {
QuestRecord {
quest_id: snapshot.quest_id,
runtime_session_id: snapshot.runtime_session_id,
story_session_id: snapshot.story_session_id,
actor_user_id: snapshot.actor_user_id,
issuer_npc_id: snapshot.issuer_npc_id,
issuer_npc_name: snapshot.issuer_npc_name,
scene_id: snapshot.scene_id,
chapter_id: snapshot.chapter_id,
act_id: snapshot.act_id,
thread_id: snapshot.thread_id,
contract_id: snapshot.contract_id,
title: snapshot.title,
description: snapshot.description,
summary: snapshot.summary,
objective: snapshot.objective,
progress: snapshot.progress,
status: snapshot.status,
completion_notified: snapshot.completion_notified,
reward: snapshot.reward,
reward_text: snapshot.reward_text,
narrative_binding: snapshot.narrative_binding,
steps: snapshot.steps,
active_step_id: snapshot.active_step_id,
visible_stage: snapshot.visible_stage,
hidden_flags: snapshot.hidden_flags,
discovered_fact_ids: snapshot.discovered_fact_ids,
related_carrier_ids: snapshot.related_carrier_ids,
consequence_ids: snapshot.consequence_ids,
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
completed_at: snapshot
.completed_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
turned_in_at: snapshot
.turned_in_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
}
}
fn build_player_progression_row(snapshot: PlayerProgressionSnapshot) -> PlayerProgression {
PlayerProgression {
user_id: snapshot.user_id,
level: snapshot.level,
current_level_xp: snapshot.current_level_xp,
total_xp: snapshot.total_xp,
xp_to_next_level: snapshot.xp_to_next_level,
pending_level_ups: snapshot.pending_level_ups,
last_granted_source: snapshot.last_granted_source,
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
fn build_player_progression_snapshot_from_row(
row: &PlayerProgression,
) -> PlayerProgressionSnapshot {
PlayerProgressionSnapshot {
user_id: row.user_id.clone(),
level: row.level,
current_level_xp: row.current_level_xp,
total_xp: row.total_xp,
xp_to_next_level: row.xp_to_next_level,
pending_level_ups: row.pending_level_ups,
last_granted_source: row.last_granted_source,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_chapter_progression_id(user_id: &str, chapter_id: &str) -> String {
format!("chapprog_{}_{}", user_id.trim(), chapter_id.trim())
}
fn build_chapter_progression_row(snapshot: ChapterProgressionSnapshot) -> ChapterProgression {
ChapterProgression {
chapter_progression_id: build_chapter_progression_id(
&snapshot.user_id,
&snapshot.chapter_id,
),
user_id: snapshot.user_id,
chapter_id: snapshot.chapter_id,
chapter_index: snapshot.chapter_index,
total_chapters: snapshot.total_chapters,
entry_pseudo_level_millis: snapshot.entry_pseudo_level_millis,
exit_pseudo_level_millis: snapshot.exit_pseudo_level_millis,
entry_level: snapshot.entry_level,
exit_level: snapshot.exit_level,
planned_total_xp: snapshot.planned_total_xp,
planned_quest_xp: snapshot.planned_quest_xp,
planned_hostile_xp: snapshot.planned_hostile_xp,
actual_quest_xp: snapshot.actual_quest_xp,
actual_hostile_xp: snapshot.actual_hostile_xp,
expected_hostile_defeat_count: snapshot.expected_hostile_defeat_count,
actual_hostile_defeat_count: snapshot.actual_hostile_defeat_count,
level_at_entry: snapshot.level_at_entry,
level_at_exit: snapshot.level_at_exit,
pace_band: snapshot.pace_band,
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
fn build_chapter_progression_snapshot_from_row(
row: &ChapterProgression,
) -> ChapterProgressionSnapshot {
ChapterProgressionSnapshot {
user_id: row.user_id.clone(),
chapter_id: row.chapter_id.clone(),
chapter_index: row.chapter_index,
total_chapters: row.total_chapters,
entry_pseudo_level_millis: row.entry_pseudo_level_millis,
exit_pseudo_level_millis: row.exit_pseudo_level_millis,
entry_level: row.entry_level,
exit_level: row.exit_level,
planned_total_xp: row.planned_total_xp,
planned_quest_xp: row.planned_quest_xp,
planned_hostile_xp: row.planned_hostile_xp,
actual_quest_xp: row.actual_quest_xp,
actual_hostile_xp: row.actual_hostile_xp,
expected_hostile_defeat_count: row.expected_hostile_defeat_count,
actual_hostile_defeat_count: row.actual_hostile_defeat_count,
level_at_entry: row.level_at_entry,
level_at_exit: row.level_at_exit,
pace_band: row.pace_band,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_quest_record_snapshot_from_row(row: &QuestRecord) -> QuestRecordSnapshot {
QuestRecordSnapshot {
quest_id: row.quest_id.clone(),
runtime_session_id: row.runtime_session_id.clone(),
story_session_id: row.story_session_id.clone(),
actor_user_id: row.actor_user_id.clone(),
issuer_npc_id: row.issuer_npc_id.clone(),
issuer_npc_name: row.issuer_npc_name.clone(),
scene_id: row.scene_id.clone(),
chapter_id: row.chapter_id.clone(),
act_id: row.act_id.clone(),
thread_id: row.thread_id.clone(),
contract_id: row.contract_id.clone(),
title: row.title.clone(),
description: row.description.clone(),
summary: row.summary.clone(),
objective: row.objective.clone(),
progress: row.progress,
status: row.status,
completion_notified: row.completion_notified,
reward: row.reward.clone(),
reward_text: row.reward_text.clone(),
narrative_binding: row.narrative_binding.clone(),
steps: row.steps.clone(),
active_step_id: row.active_step_id.clone(),
visible_stage: row.visible_stage,
hidden_flags: row.hidden_flags.clone(),
discovered_fact_ids: row.discovered_fact_ids.clone(),
related_carrier_ids: row.related_carrier_ids.clone(),
consequence_ids: row.consequence_ids.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
completed_at_micros: row
.completed_at
.map(|value| value.to_micros_since_unix_epoch()),
turned_in_at_micros: row
.turned_in_at
.map(|value| value.to_micros_since_unix_epoch()),
}
}
fn build_inventory_slot_row(snapshot: InventorySlotSnapshot) -> InventorySlot {
InventorySlot {
slot_id: snapshot.slot_id,
runtime_session_id: snapshot.runtime_session_id,
story_session_id: snapshot.story_session_id,
actor_user_id: snapshot.actor_user_id,
container_kind: snapshot.container_kind,
slot_key: snapshot.slot_key,
item_id: snapshot.item_id,
category: snapshot.category,
name: snapshot.name,
description: snapshot.description,
quantity: snapshot.quantity,
rarity: snapshot.rarity,
tags: snapshot.tags,
stackable: snapshot.stackable,
stack_key: snapshot.stack_key,
equipment_slot_id: snapshot.equipment_slot_id,
source_kind: snapshot.source_kind,
source_reference_id: snapshot.source_reference_id,
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
fn build_inventory_slot_snapshot_from_row(row: &InventorySlot) -> InventorySlotSnapshot {
InventorySlotSnapshot {
slot_id: row.slot_id.clone(),
runtime_session_id: row.runtime_session_id.clone(),
story_session_id: row.story_session_id.clone(),
actor_user_id: row.actor_user_id.clone(),
container_kind: row.container_kind,
slot_key: row.slot_key.clone(),
item_id: row.item_id.clone(),
category: row.category.clone(),
name: row.name.clone(),
description: row.description.clone(),
quantity: row.quantity,
rarity: row.rarity,
tags: row.tags.clone(),
stackable: row.stackable,
stack_key: row.stack_key.clone(),
equipment_slot_id: row.equipment_slot_id,
source_kind: row.source_kind,
source_reference_id: row.source_reference_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn grant_quest_reward_items(
ctx: &ReducerContext,
snapshot: &QuestRecordSnapshot,
) -> Result<(), String> {
if !ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == snapshot.runtime_session_id
&& row.actor_user_id == snapshot.actor_user_id
})
.all(|row| row.source_reference_id.as_deref() != Some(snapshot.quest_id.as_str()))
{
return Ok(());
}
for (index, reward_item) in snapshot.reward.items.clone().into_iter().enumerate() {
let inventory_item =
build_inventory_item_snapshot_from_quest_reward_item(&snapshot.quest_id, reward_item);
grant_inventory_item_to_actor(
ctx,
&snapshot.runtime_session_id,
snapshot.story_session_id.clone(),
&snapshot.actor_user_id,
inventory_item,
build_reward_seed(snapshot.updated_at_micros, index),
snapshot.updated_at_micros,
)?;
}
Ok(())
}
fn grant_battle_reward_items(
ctx: &ReducerContext,
snapshot: &BattleStateSnapshot,
) -> Result<(), String> {
if snapshot.reward_items.is_empty() {
return Ok(());
}
if !ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == snapshot.runtime_session_id
&& row.actor_user_id == snapshot.actor_user_id
})
.all(|row| row.source_reference_id.as_deref() != Some(snapshot.battle_state_id.as_str()))
{
return Ok(());
}
for (index, reward_item) in snapshot.reward_items.clone().into_iter().enumerate() {
let inventory_item = build_inventory_item_snapshot_from_battle_reward_item(
&snapshot.battle_state_id,
reward_item,
);
grant_inventory_item_to_actor(
ctx,
&snapshot.runtime_session_id,
Some(snapshot.story_session_id.clone()),
&snapshot.actor_user_id,
inventory_item,
build_reward_seed(snapshot.updated_at_micros, index),
snapshot.updated_at_micros,
)?;
}
Ok(())
}
fn grant_inventory_item_to_actor(
ctx: &ReducerContext,
runtime_session_id: &str,
story_session_id: Option<String>,
actor_user_id: &str,
item: InventoryItemSnapshot,
seed_micros: i64,
updated_at_micros: i64,
) -> Result<(), String> {
let current_slots = ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == runtime_session_id && row.actor_user_id == actor_user_id
})
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
let slot_id = generate_inventory_slot_id(seed_micros);
let mutation_id = generate_inventory_mutation_id(seed_micros);
let outcome = apply_inventory_slot_mutation(
current_slots,
InventoryMutationInput {
mutation_id,
runtime_session_id: runtime_session_id.to_string(),
story_session_id,
actor_user_id: actor_user_id.to_string(),
mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id, item }),
updated_at_micros,
},
)
.map_err(|error| error.to_string())?;
for removed_slot_id in outcome.removed_slot_ids {
ctx.db.inventory_slot().slot_id().delete(&removed_slot_id);
}
for slot in outcome.next_slots {
ctx.db.inventory_slot().slot_id().delete(&slot.slot_id);
ctx.db
.inventory_slot()
.insert(build_inventory_slot_row(slot));
}
Ok(())
}
fn build_inventory_item_snapshot_from_battle_reward_item(
battle_state_id: &str,
reward_item: RuntimeItemRewardItemSnapshot,
) -> InventoryItemSnapshot {
InventoryItemSnapshot {
item_id: reward_item.item_id,
category: reward_item.category,
name: reward_item.item_name,
description: reward_item.description,
quantity: reward_item.quantity,
rarity: map_runtime_reward_item_rarity(reward_item.rarity),
tags: reward_item.tags,
stackable: reward_item.stackable,
stack_key: reward_item.stack_key,
equipment_slot_id: reward_item
.equipment_slot_id
.map(map_runtime_reward_equipment_slot),
source_kind: InventoryItemSourceKind::CombatDrop,
source_reference_id: Some(battle_state_id.to_string()),
}
}
fn build_inventory_item_snapshot_from_quest_reward_item(
quest_id: &str,
reward_item: QuestRewardItem,
) -> InventoryItemSnapshot {
InventoryItemSnapshot {
item_id: reward_item.item_id,
category: reward_item.category,
name: reward_item.name,
description: reward_item.description,
quantity: reward_item.quantity,
rarity: map_quest_reward_item_rarity(reward_item.rarity),
tags: reward_item.tags,
stackable: reward_item.stackable,
stack_key: reward_item.stack_key,
equipment_slot_id: reward_item
.equipment_slot_id
.map(map_quest_reward_equipment_slot),
source_kind: InventoryItemSourceKind::QuestReward,
source_reference_id: Some(quest_id.to_string()),
}
}
fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity {
match rarity {
QuestRewardItemRarity::Common => InventoryItemRarity::Common,
QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
QuestRewardItemRarity::Rare => InventoryItemRarity::Rare,
QuestRewardItemRarity::Epic => InventoryItemRarity::Epic,
QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
}
}
fn map_runtime_reward_item_rarity(
rarity: module_runtime_item::RuntimeItemRewardItemRarity,
) -> InventoryItemRarity {
match rarity {
module_runtime_item::RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
module_runtime_item::RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
module_runtime_item::RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
module_runtime_item::RuntimeItemRewardItemRarity::Legendary => {
InventoryItemRarity::Legendary
}
}
}
fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot {
match slot {
QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
}
}
fn map_runtime_reward_equipment_slot(
slot: module_runtime_item::RuntimeItemEquipmentSlot,
) -> InventoryEquipmentSlot {
match slot {
module_runtime_item::RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
module_runtime_item::RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
module_runtime_item::RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
}
}
fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 {
updated_at_micros.saturating_add(index as i64 + 1)
}
fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot {
StorySessionSnapshot {
story_session_id: row.story_session_id.clone(),
runtime_session_id: row.runtime_session_id.clone(),
actor_user_id: row.actor_user_id.clone(),
world_profile_id: row.world_profile_id.clone(),
initial_prompt: row.initial_prompt.clone(),
opening_summary: row.opening_summary.clone(),
latest_narrative_text: row.latest_narrative_text.clone(),
latest_choice_function_id: row.latest_choice_function_id.clone(),
status: row.status,
version: row.version,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_story_event_snapshot_from_row(row: &StoryEvent) -> StoryEventSnapshot {
StoryEventSnapshot {
event_id: row.event_id.clone(),
story_session_id: row.story_session_id.clone(),
event_kind: row.event_kind,
narrative_text: row.narrative_text.clone(),
choice_function_id: row.choice_function_id.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
}
}
fn build_treasure_record_snapshot_from_row(row: &TreasureRecord) -> TreasureRecordSnapshot {
TreasureRecordSnapshot {
treasure_record_id: row.treasure_record_id.clone(),
runtime_session_id: row.runtime_session_id.clone(),
story_session_id: row.story_session_id.clone(),
actor_user_id: row.actor_user_id.clone(),
encounter_id: row.encounter_id.clone(),
encounter_name: row.encounter_name.clone(),
scene_id: row.scene_id.clone(),
scene_name: row.scene_name.clone(),
action: row.action,
reward_items: row.reward_items.clone(),
reward_hp: row.reward_hp,
reward_mana: row.reward_mana,
reward_currency: row.reward_currency,
story_hint: row.story_hint.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn append_quest_log(
ctx: &ReducerContext,
snapshot: &QuestRecordSnapshot,
event_kind: QuestLogEventKind,
signal_kind: Option<QuestSignalKind>,
signal: Option<QuestProgressSignal>,
step_id: Option<String>,
step_progress: Option<u32>,
created_at_micros: i64,
) {
ctx.db.quest_log().insert(QuestLog {
log_id: generate_quest_log_id(&snapshot.quest_id, event_kind, created_at_micros),
quest_id: snapshot.quest_id.clone(),
runtime_session_id: snapshot.runtime_session_id.clone(),
actor_user_id: snapshot.actor_user_id.clone(),
event_kind,
status_after: snapshot.status,
signal_kind,
signal,
step_id,
step_progress,
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
});
}
fn get_player_progression_snapshot_tx(
ctx: &ReducerContext,
input: PlayerProgressionGetInput,
) -> Result<PlayerProgressionSnapshot, String> {
let user_id = input.user_id.trim().to_string();
if user_id.is_empty() {
return Err("player_progression.user_id 不能为空".to_string());
}
if let Some(existing) = ctx.db.player_progression().user_id().find(&user_id) {
return Ok(build_player_progression_snapshot_from_row(&existing));
}
create_initial_player_progression(user_id, 0).map_err(|error| error.to_string())
}
fn upsert_player_progression_after_grant_tx(
ctx: &ReducerContext,
input: PlayerProgressionGrantInput,
) -> Result<PlayerProgressionSnapshot, String> {
let current = if let Some(existing) = ctx.db.player_progression().user_id().find(&input.user_id)
{
build_player_progression_snapshot_from_row(&existing)
} else {
create_initial_player_progression(input.user_id.clone(), input.updated_at_micros)
.map_err(|error| error.to_string())?
};
let next = grant_player_experience(current, input).map_err(|error| error.to_string())?;
if ctx
.db
.player_progression()
.user_id()
.find(&next.user_id)
.is_some()
{
ctx.db.player_progression().user_id().delete(&next.user_id);
}
ctx.db
.player_progression()
.insert(build_player_progression_row(next.clone()));
Ok(next)
}
fn get_chapter_progression_snapshot_tx(
ctx: &ReducerContext,
input: ChapterProgressionGetInput,
) -> Result<ChapterProgressionSnapshot, String> {
let user_id = input.user_id.trim().to_string();
let chapter_id = input.chapter_id.trim().to_string();
if user_id.is_empty() {
return Err("chapter_progression.user_id 不能为空".to_string());
}
if chapter_id.is_empty() {
return Err("chapter_progression.chapter_id 不能为空".to_string());
}
let row_id = build_chapter_progression_id(&user_id, &chapter_id);
let existing = ctx
.db
.chapter_progression()
.chapter_progression_id()
.find(&row_id)
.ok_or_else(|| "chapter_progression 不存在".to_string())?;
Ok(build_chapter_progression_snapshot_from_row(&existing))
}
fn upsert_chapter_progression_snapshot_tx(
ctx: &ReducerContext,
input: ChapterProgressionInput,
) -> Result<ChapterProgressionSnapshot, String> {
let snapshot = build_chapter_progression_snapshot(input).map_err(|error| error.to_string())?;
let row_id = build_chapter_progression_id(&snapshot.user_id, &snapshot.chapter_id);
if ctx
.db
.chapter_progression()
.chapter_progression_id()
.find(&row_id)
.is_some()
{
ctx.db
.chapter_progression()
.chapter_progression_id()
.delete(&row_id);
}
ctx.db
.chapter_progression()
.insert(build_chapter_progression_row(snapshot.clone()));
Ok(snapshot)
}
fn update_chapter_progression_ledger_tx(
ctx: &ReducerContext,
input: ChapterProgressionLedgerInput,
) -> Result<ChapterProgressionSnapshot, String> {
let row_id = build_chapter_progression_id(&input.user_id, &input.chapter_id);
let current = ctx
.db
.chapter_progression()
.chapter_progression_id()
.find(&row_id)
.ok_or_else(|| "chapter_progression 不存在,无法记账".to_string())?;
let next = apply_chapter_progression_ledger(
build_chapter_progression_snapshot_from_row(&current),
input,
)
.map_err(|error| error.to_string())?;
ctx.db
.chapter_progression()
.chapter_progression_id()
.delete(&row_id);
ctx.db
.chapter_progression()
.insert(build_chapter_progression_row(next.clone()));
Ok(next)
}
fn try_update_chapter_progression_ledger_tx(
ctx: &ReducerContext,
user_id: String,
chapter_id: Option<String>,
input: ChapterProgressionLedgerInput,
) -> Result<Option<ChapterProgressionSnapshot>, String> {
let Some(chapter_id) = chapter_id.map(|value| value.trim().to_string()) else {
return Ok(None);
};
if chapter_id.is_empty() || user_id.trim().is_empty() {
return Ok(None);
}
let row_id = build_chapter_progression_id(user_id.trim(), &chapter_id);
if ctx
.db
.chapter_progression()
.chapter_progression_id()
.find(&row_id)
.is_none()
{
return Ok(None);
}
update_chapter_progression_ledger_tx(ctx, input).map(Some)
}
fn build_battle_state_row(snapshot: BattleStateSnapshot) -> BattleState {
BattleState {
battle_state_id: snapshot.battle_state_id,
story_session_id: snapshot.story_session_id,
runtime_session_id: snapshot.runtime_session_id,
actor_user_id: snapshot.actor_user_id,
chapter_id: snapshot.chapter_id,
target_npc_id: snapshot.target_npc_id,
target_name: snapshot.target_name,
battle_mode: snapshot.battle_mode,
status: snapshot.status,
player_hp: snapshot.player_hp,
player_max_hp: snapshot.player_max_hp,
player_mana: snapshot.player_mana,
player_max_mana: snapshot.player_max_mana,
target_hp: snapshot.target_hp,
target_max_hp: snapshot.target_max_hp,
experience_reward: snapshot.experience_reward,
reward_items: snapshot.reward_items,
turn_index: snapshot.turn_index,
last_action_function_id: snapshot.last_action_function_id,
last_action_text: snapshot.last_action_text,
last_result_text: snapshot.last_result_text,
last_damage_dealt: snapshot.last_damage_dealt,
last_damage_taken: snapshot.last_damage_taken,
last_outcome: snapshot.last_outcome,
version: snapshot.version,
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
fn build_battle_state_snapshot_from_row(row: &BattleState) -> BattleStateSnapshot {
BattleStateSnapshot {
battle_state_id: row.battle_state_id.clone(),
story_session_id: row.story_session_id.clone(),
runtime_session_id: row.runtime_session_id.clone(),
actor_user_id: row.actor_user_id.clone(),
chapter_id: row.chapter_id.clone(),
target_npc_id: row.target_npc_id.clone(),
target_name: row.target_name.clone(),
battle_mode: row.battle_mode,
status: row.status,
player_hp: row.player_hp,
player_max_hp: row.player_max_hp,
player_mana: row.player_mana,
player_max_mana: row.player_max_mana,
target_hp: row.target_hp,
target_max_hp: row.target_max_hp,
experience_reward: row.experience_reward,
reward_items: row.reward_items.clone(),
turn_index: row.turn_index,
last_action_function_id: row.last_action_function_id.clone(),
last_action_text: row.last_action_text.clone(),
last_result_text: row.last_result_text.clone(),
last_damage_dealt: row.last_damage_dealt,
last_damage_taken: row.last_damage_taken,
last_outcome: row.last_outcome,
version: row.version,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn upsert_npc_state_record(
ctx: &ReducerContext,
input: NpcStateUpsertInput,
) -> Result<NpcStateSnapshot, String> {
let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id);
let existing = ctx.db.npc_state().npc_state_id().find(&npc_state_id);
let normalized = normalize_npc_state_snapshot(
input,
existing
.as_ref()
.map(|row| row.created_at.to_micros_since_unix_epoch()),
)
.map_err(|error| error.to_string())?;
if existing.is_some() {
ctx.db.npc_state().npc_state_id().delete(&npc_state_id);
}
ctx.db
.npc_state()
.insert(build_npc_state_row(normalized.clone()));
Ok(normalized)
}
fn resolve_npc_social_action_record(
ctx: &ReducerContext,
input: ResolveNpcSocialActionInput,
) -> Result<NpcStateSnapshot, String> {
let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id);
let current = ctx
.db
.npc_state()
.npc_state_id()
.find(&npc_state_id)
.ok_or_else(|| "npc_state 不存在,无法执行社交动作".to_string())?;
let next = apply_npc_social_action(build_npc_state_snapshot_from_row(&current), input)
.map_err(|error| error.to_string())?;
ctx.db
.npc_state()
.npc_state_id()
.delete(&current.npc_state_id);
ctx.db.npc_state().insert(build_npc_state_row(next.clone()));
Ok(next)
}
fn resolve_npc_interaction_record(
ctx: &ReducerContext,
input: ResolveNpcInteractionInput,
) -> Result<module_npc::NpcInteractionResult, String> {
let npc_state_id = generate_npc_state_id(&input.runtime_session_id, &input.npc_id);
let current = ctx
.db
.npc_state()
.npc_state_id()
.find(&npc_state_id)
.ok_or_else(|| "npc_state 不存在,无法执行交互".to_string())?;
let result = resolve_npc_interaction_domain(build_npc_state_snapshot_from_row(&current), input)
.map_err(|error| error.to_string())?;
ctx.db
.npc_state()
.npc_state_id()
.delete(&current.npc_state_id);
ctx.db
.npc_state()
.insert(build_npc_state_row(result.npc_state.clone()));
Ok(result)
}
fn resolve_npc_battle_interaction_tx(
ctx: &ReducerContext,
input: ResolveNpcBattleInteractionInput,
) -> Result<NpcBattleInteractionResult, String> {
validate_npc_battle_interaction_input(&input)?;
let interaction = resolve_npc_interaction_record(ctx, input.npc_interaction.clone())?;
let battle_mode = interaction
.battle_mode
.ok_or_else(|| "当前 NPC 交互没有产出 battle_mode不能初始化 battle_state".to_string())?;
let battle_state_id = input
.battle_state_id
.clone()
.unwrap_or_else(|| generate_battle_state_id(input.npc_interaction.updated_at_micros));
if ctx
.db
.battle_state()
.battle_state_id()
.find(&battle_state_id)
.is_some()
{
return Err("battle_state.battle_state_id 已存在".to_string());
}
let battle_input = BattleStateInput {
battle_state_id,
story_session_id: input.story_session_id.trim().to_string(),
runtime_session_id: interaction.npc_state.runtime_session_id.clone(),
actor_user_id: input.actor_user_id.trim().to_string(),
chapter_id: None,
target_npc_id: interaction.npc_state.npc_id.clone(),
target_name: interaction.npc_state.npc_name.clone(),
battle_mode: map_npc_battle_mode(battle_mode),
player_hp: input.player_hp,
player_max_hp: input.player_max_hp,
player_mana: input.player_mana,
player_max_mana: input.player_max_mana,
target_hp: input.target_hp,
target_max_hp: input.target_max_hp,
experience_reward: input.experience_reward,
reward_items: input.reward_items.clone(),
created_at_micros: input.npc_interaction.updated_at_micros,
};
validate_battle_state_input(&battle_input).map_err(|error| error.to_string())?;
let battle_state = build_battle_state_snapshot(battle_input);
ctx.db
.battle_state()
.insert(build_battle_state_row(battle_state.clone()));
Ok(NpcBattleInteractionResult {
interaction,
battle_state,
})
}
fn validate_npc_battle_interaction_input(
input: &ResolveNpcBattleInteractionInput,
) -> Result<(), String> {
if input.story_session_id.trim().is_empty() {
return Err("resolve_npc_battle_interaction.story_session_id 不能为空".to_string());
}
if input.actor_user_id.trim().is_empty() {
return Err("resolve_npc_battle_interaction.actor_user_id 不能为空".to_string());
}
if !matches!(
input.npc_interaction.interaction_function_id.trim(),
NPC_FIGHT_FUNCTION_ID | NPC_SPAR_FUNCTION_ID
) {
return Err("resolve_npc_battle_interaction 只支持 npc_fight 或 npc_spar".to_string());
}
Ok(())
}
fn map_npc_battle_mode(mode: NpcInteractionBattleMode) -> BattleMode {
match mode {
NpcInteractionBattleMode::Fight => BattleMode::Fight,
NpcInteractionBattleMode::Spar => BattleMode::Spar,
}
}
fn build_npc_state_row(snapshot: NpcStateSnapshot) -> NpcState {
NpcState {
npc_state_id: snapshot.npc_state_id,
runtime_session_id: snapshot.runtime_session_id,
npc_id: snapshot.npc_id,
npc_name: snapshot.npc_name,
affinity: snapshot.affinity,
relation_state: snapshot.relation_state,
help_used: snapshot.help_used,
chatted_count: snapshot.chatted_count,
gifts_given: snapshot.gifts_given,
recruited: snapshot.recruited,
trade_stock_signature: snapshot.trade_stock_signature,
revealed_facts: snapshot.revealed_facts,
known_attribute_rumors: snapshot.known_attribute_rumors,
first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids,
stance_profile: snapshot.stance_profile,
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot {
NpcStateSnapshot {
npc_state_id: row.npc_state_id.clone(),
runtime_session_id: row.runtime_session_id.clone(),
npc_id: row.npc_id.clone(),
npc_name: row.npc_name.clone(),
affinity: row.affinity,
relation_state: row.relation_state.clone(),
help_used: row.help_used,
chatted_count: row.chatted_count,
gifts_given: row.gifts_given,
recruited: row.recruited,
trade_stock_signature: row.trade_stock_signature.clone(),
revealed_facts: row.revealed_facts.clone(),
known_attribute_rumors: row.known_attribute_rumors.clone(),
first_meaningful_contact_resolved: row.first_meaningful_contact_resolved,
seen_backstory_chapter_ids: row.seen_backstory_chapter_ids.clone(),
stance_profile: row.stance_profile.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_test_custom_world_agent_session(
seed_text: &str,
stage: RpgAgentStage,
draft_profile_json: Option<&str>,
) -> CustomWorldAgentSession {
CustomWorldAgentSession {
session_id: "session-1".to_string(),
owner_user_id: "user-1".to_string(),
seed_text: seed_text.to_string(),
current_turn: 0,
progress_percent: 0,
stage,
focus_card_id: None,
anchor_content_json: "{}".to_string(),
creator_intent_json: None,
creator_intent_readiness_json: "{}".to_string(),
anchor_pack_json: None,
lock_state_json: None,
draft_profile_json: draft_profile_json.map(str::to_string),
last_assistant_reply: None,
publish_gate_json: None,
result_preview_json: None,
pending_clarifications_json: "[]".to_string(),
quality_findings_json: "[]".to_string(),
suggested_actions_json: "[]".to_string(),
recommended_replies_json: "[]".to_string(),
asset_coverage_json: "{}".to_string(),
checkpoints_json: "[]".to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
}
}
#[test]
fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() {
let session = build_test_custom_world_agent_session(
"seed",
RpgAgentStage::ObjectRefining,
Some(r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#),
);
assert_eq!(
resolve_stable_agent_draft_profile_id(&session),
Some("stable-profile".to_string())
);
}
#[test]
fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() {
let empty_session =
build_test_custom_world_agent_session("", RpgAgentStage::CollectingIntent, Some("{}"));
let seeded_session = build_test_custom_world_agent_session(
"想做一个海雾群岛",
RpgAgentStage::CollectingIntent,
Some("{}"),
);
let drafted_session =
build_test_custom_world_agent_session("", RpgAgentStage::ObjectRefining, Some("{}"));
let profile_session = build_test_custom_world_agent_session(
"",
RpgAgentStage::CollectingIntent,
Some(r#"{"worldHook":"海雾会吞掉记错航线的人。"}"#),
);
assert!(!custom_world_agent_session_has_direct_work_content(
&empty_session,
));
assert!(custom_world_agent_session_has_direct_work_content(
&seeded_session,
));
assert!(custom_world_agent_session_has_direct_work_content(
&drafted_session,
));
assert!(custom_world_agent_session_has_direct_work_content(
&profile_session,
));
}
#[test]
fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() {
let matching = CustomWorldProfile {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: None,
author_public_user_code: None,
source_agent_session_id: Some("session-1".to_string()),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
};
let deleted = CustomWorldProfile {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: None,
author_public_user_code: None,
source_agent_session_id: Some("session-1".to_string()),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)),
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
};
let published = CustomWorldProfile {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: Some("CW-00000001".to_string()),
author_public_user_code: Some("SY-00000001".to_string()),
source_agent_session_id: Some("session-1".to_string()),
publication_status: CustomWorldPublicationStatus::Published,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
};
assert!(is_same_agent_draft_profile_candidate(
&matching,
"user-1",
"session-1",
));
assert!(!is_same_agent_draft_profile_candidate(
&matching,
"user-2",
"session-1",
));
assert!(!is_same_agent_draft_profile_candidate(
&matching,
"user-1",
"session-2",
));
assert!(!is_same_agent_draft_profile_candidate(
&deleted,
"user-1",
"session-1",
));
assert!(!is_same_agent_draft_profile_candidate(
&published,
"user-1",
"session-1",
));
}
#[test]
fn custom_world_works_hides_compiled_draft_profile_when_agent_session_is_active() {
fn build_test_custom_world_profile(
profile_id: &str,
source_agent_session_id: Option<&str>,
publication_status: CustomWorldPublicationStatus,
) -> CustomWorldProfile {
CustomWorldProfile {
profile_id: profile_id.to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: if publication_status == CustomWorldPublicationStatus::Published {
Some("CW-00000001".to_string())
} else {
None
},
author_public_user_code: None,
source_agent_session_id: source_agent_session_id.map(str::to_string),
publication_status,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: if publication_status == CustomWorldPublicationStatus::Published {
Some(Timestamp::from_micros_since_unix_epoch(2))
} else {
None
},
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
}
}
let draft_profile = build_test_custom_world_profile(
"profile-1",
Some("session-1"),
CustomWorldPublicationStatus::Draft,
);
let orphan_draft_profile = build_test_custom_world_profile(
"profile-2",
Some("session-2"),
CustomWorldPublicationStatus::Draft,
);
let published_profile = build_test_custom_world_profile(
"profile-3",
Some("session-1"),
CustomWorldPublicationStatus::Published,
);
let mut active_agent_session_ids = HashSet::new();
active_agent_session_ids.insert("session-1".to_string());
assert!(!should_include_custom_world_profile_work(
&draft_profile,
&active_agent_session_ids,
));
assert!(should_include_custom_world_profile_work(
&orphan_draft_profile,
&active_agent_session_ids,
));
assert!(should_include_custom_world_profile_work(
&published_profile,
&active_agent_session_ids,
));
}
#[test]
fn custom_world_works_keeps_compiled_draft_profile_without_active_agent_session() {
let draft_profile = CustomWorldProfile {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
public_work_code: None,
author_public_user_code: None,
source_agent_session_id: Some("session-1".to_string()),
publication_status: CustomWorldPublicationStatus::Draft,
world_name: "潮雾列岛".to_string(),
subtitle: String::new(),
summary_text: String::new(),
theme_mode: CustomWorldThemeMode::Mythic,
cover_image_src: None,
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
};
let mut active_agent_session_ids = HashSet::new();
assert!(should_include_custom_world_profile_work(
&draft_profile,
&active_agent_session_ids,
));
active_agent_session_ids.insert("session-2".to_string());
assert!(should_include_custom_world_profile_work(
&draft_profile,
&active_agent_session_ids,
));
}
#[test]
fn summarize_publish_gate_accepts_current_agent_result_schema() {
let draft_profile = serde_json::from_str::<JsonValue>(
r#"{
"id":"agent-draft-session-1",
"settingText":"海雾会吞掉记错航线的人。",
"creatorIntent":{"playerPremise":"玩家是带着旧航海日志返乡的守灯人。"},
"anchorContent":{
"worldPromise":{"hook":"在失真的海图上追查一场被篡改的沉船事故。"},
"playerEntryPoint":{
"openingIdentity":"被停职返乡的守灯人",
"openingProblem":"灯塔记录被人改写",
"entryMotivation":"查清父亲沉船真相"
}
},
"coreConflicts":["群岛议会试图掩盖沉船真相。"],
"sceneChapterBlueprints":[
{
"id":"scene-chapter-1",
"sceneId":"landmark-1",
"title":"失灯港",
"acts":[
{
"id":"act-1",
"title":"第一幕"
}
]
}
]
}"#,
)
.expect("draft profile should be valid json")
.as_object()
.cloned()
.expect("draft profile should be object");
let gate = summarize_publish_gate_from_json(
"session-1",
RpgAgentStage::ReadyToPublish,
Some(&draft_profile),
&[],
);
assert!(gate.publish_ready);
assert_eq!(gate.blocker_count, 0);
assert!(gate.blockers.is_empty());
}
#[test]
fn ensure_minimal_draft_profile_includes_scene_chapter_blueprints_slot() {
let profile = ensure_minimal_draft_profile(JsonMap::new(), "旧航路群岛");
assert_eq!(
profile.get("sceneChapterBlueprints"),
Some(&JsonValue::Array(Vec::new()))
);
}
#[test]
fn draft_foundation_payload_must_contain_external_draft_profile() {
let payload = JsonMap::new();
let result = payload
.get("draftProfile")
.and_then(JsonValue::as_object)
.cloned()
.ok_or_else(|| {
"draft_foundation requires externally generated payload.draftProfile".to_string()
});
assert_eq!(
result.expect_err("missing draftProfile should be rejected"),
"draft_foundation requires externally generated payload.draftProfile"
);
}
}