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

1954 lines
67 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.
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(),
}
}