1954 lines
67 KiB
Rust
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(¤t), input)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
ctx.db
|
||
.battle_state()
|
||
.battle_state_id()
|
||
.delete(¤t.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(¤t);
|
||
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(¤t.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(¤t), input)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
if !outcome.changed {
|
||
return Ok(());
|
||
}
|
||
|
||
ctx.db.quest_record().quest_id().delete(¤t.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(¤t), input)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
if !outcome.changed {
|
||
return Ok(());
|
||
}
|
||
|
||
ctx.db.quest_record().quest_id().delete(¤t.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(¤t), input)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
ctx.db.quest_record().quest_id().delete(¤t.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(¤t),
|
||
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(¤t), input)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
ctx.db
|
||
.npc_state()
|
||
.npc_state_id()
|
||
.delete(¤t.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(¤t), input)
|
||
.map_err(|error| error.to_string())?;
|
||
|
||
ctx.db
|
||
.npc_state()
|
||
.npc_state_id()
|
||
.delete(¤t.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(),
|
||
}
|
||
}
|