Files
Genarrative/server-rs/crates/spacetime-module/src/gameplay.rs
T

1954 lines
67 KiB
Rust

use crate::*;
use module_combat::resolve_combat_action as resolve_battle_state_action;
use module_inventory::apply_inventory_mutation as apply_inventory_slot_mutation;
use module_npc::resolve_npc_interaction as resolve_npc_interaction_domain;
use module_quest::{
acknowledge_quest_completion as acknowledge_quest_record_completion,
apply_quest_signal as apply_quest_record_signal,
};
use module_story::{
RpgGameplaySettlementPlan, build_combat_victory_settlement_plan,
build_quest_turn_in_settlement_plan, build_treasure_settlement_plan,
};
#[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::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()
.by_inventory_runtime_session_id()
.filter(&input.runtime_session_id)
.filter(|slot| slot.actor_user_id == input.actor_user_id)
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
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()
.by_inventory_runtime_session_id()
.filter(&validated_input.runtime_session_id)
.filter(|row| row.actor_user_id == validated_input.actor_user_id)
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
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()));
apply_rpg_gameplay_settlement_plan(
ctx,
build_combat_victory_settlement_plan(&result.snapshot),
Some(result.snapshot.battle_state_id.as_str()),
)?;
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),
},
}
}
fn continue_story_tx(
ctx: &ReducerContext,
input: StoryContinueInput,
) -> Result<(StorySessionSnapshot, StoryEventSnapshot), String> {
validate_story_continue_input(&input).map_err(|error| error.to_string())?;
let current = ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.ok_or_else(|| "story_session 不存在,无法继续推进".to_string())?;
let current_snapshot = build_story_session_snapshot_from_row(&current);
let (next_snapshot, event_snapshot) =
apply_story_continue(current_snapshot, input).map_err(|error| error.to_string())?;
ctx.db
.story_session()
.story_session_id()
.delete(&current.story_session_id);
ctx.db.story_session().insert(StorySession {
story_session_id: next_snapshot.story_session_id.clone(),
runtime_session_id: next_snapshot.runtime_session_id.clone(),
actor_user_id: next_snapshot.actor_user_id.clone(),
world_profile_id: next_snapshot.world_profile_id.clone(),
initial_prompt: next_snapshot.initial_prompt.clone(),
opening_summary: next_snapshot.opening_summary.clone(),
latest_narrative_text: next_snapshot.latest_narrative_text.clone(),
latest_choice_function_id: next_snapshot.latest_choice_function_id.clone(),
status: next_snapshot.status,
version: next_snapshot.version,
created_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.created_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(next_snapshot.updated_at_micros),
});
ctx.db.story_event().insert(StoryEvent {
event_id: event_snapshot.event_id.clone(),
story_session_id: event_snapshot.story_session_id.clone(),
event_kind: event_snapshot.event_kind,
narrative_text: event_snapshot.narrative_text.clone(),
choice_function_id: event_snapshot.choice_function_id.clone(),
created_at: Timestamp::from_micros_since_unix_epoch(event_snapshot.created_at_micros),
});
Ok((next_snapshot, event_snapshot))
}
fn get_story_session_state_tx(
ctx: &ReducerContext,
input: StorySessionStateInput,
) -> Result<(StorySessionSnapshot, Vec<StoryEventSnapshot>), String> {
validate_story_session_state_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.story_session()
.story_session_id()
.find(&input.story_session_id)
.ok_or_else(|| "story_session 不存在".to_string())?;
let session_snapshot = build_story_session_snapshot_from_row(&session);
let mut events = ctx
.db
.story_event()
.by_story_session_id()
.filter(&input.story_session_id)
.map(|row| build_story_event_snapshot_from_row(&row))
.collect::<Vec<_>>();
events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone()));
Ok((session_snapshot, events))
}
// 当前阶段先把 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,
);
apply_rpg_gameplay_settlement_plan(
ctx,
build_quest_turn_in_settlement_plan(&next),
Some(next.quest_id.as_str()),
)?;
Ok(())
}
// 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));
apply_rpg_gameplay_settlement_plan(
ctx,
build_treasure_settlement_plan(&snapshot).map_err(|error| error.to_string())?,
Some(snapshot.treasure_record_id.as_str()),
)?;
Ok(snapshot)
}
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 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 apply_rpg_gameplay_settlement_plan(
ctx: &ReducerContext,
plan: RpgGameplaySettlementPlan,
inventory_source_reference_id: Option<&str>,
) -> Result<(), String> {
if !plan.inventory_mutations.is_empty() {
if let Some(source_reference_id) = inventory_source_reference_id {
if inventory_reward_source_already_granted(ctx, &plan, source_reference_id) {
return apply_progression_and_chapter_settlement(ctx, plan);
}
}
for mutation in plan.inventory_mutations.clone() {
apply_inventory_mutation_tx(ctx, mutation)?;
}
}
apply_progression_and_chapter_settlement(ctx, plan)
}
fn inventory_reward_source_already_granted(
ctx: &ReducerContext,
plan: &RpgGameplaySettlementPlan,
source_reference_id: &str,
) -> bool {
let Some(first_mutation) = plan.inventory_mutations.first() else {
return false;
};
ctx.db
.inventory_slot()
.by_inventory_runtime_session_id()
.filter(&first_mutation.runtime_session_id)
.filter(|row| row.actor_user_id == first_mutation.actor_user_id)
.any(|row| row.source_reference_id.as_deref() == Some(source_reference_id))
}
fn apply_progression_and_chapter_settlement(
ctx: &ReducerContext,
plan: RpgGameplaySettlementPlan,
) -> Result<(), String> {
let updated_player = match plan.progression_grant {
Some(input) => Some(upsert_player_progression_after_grant_tx(ctx, input)?),
None => None,
};
if let Some(mut input) = plan.chapter_ledger {
if let Some(player) = updated_player {
input.level_at_exit = Some(player.level);
}
// 章节计划可能尚未初始化;此时不能反向阻断战斗或任务主链。
try_update_chapter_progression_ledger_tx(
ctx,
input.user_id.clone(),
Some(input.chapter_id.clone()),
input,
)?;
}
Ok(())
}
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(),
}
}