Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions

View File

@@ -116,6 +116,8 @@ fn upsert_asset_entity_binding(
}
};
emit_asset_entity_binding_changed_event(ctx, &snapshot);
Ok(snapshot)
}
@@ -133,3 +135,37 @@ fn build_asset_entity_binding_row(snapshot: &AssetEntityBindingSnapshot) -> Asse
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
fn emit_asset_entity_binding_changed_event(
ctx: &ReducerContext,
snapshot: &AssetEntityBindingSnapshot,
) {
let event = AssetEntityBindingChangedEvent {
binding_id: snapshot.binding_id.clone(),
asset_object_id: snapshot.asset_object_id.clone(),
entity_kind: snapshot.entity_kind.clone(),
entity_id: snapshot.entity_id.clone(),
slot: snapshot.slot.clone(),
asset_kind: snapshot.asset_kind.clone(),
owner_user_id: snapshot.owner_user_id.clone(),
profile_id: snapshot.profile_id.clone(),
occurred_at_micros: snapshot.updated_at_micros,
};
ctx.db.asset_event().insert(AssetEvent {
event_id: format!(
"assetevt_{}_{}_binding",
event.binding_id, event.occurred_at_micros
),
asset_object_id: event.asset_object_id,
binding_id: Some(event.binding_id),
event_kind: AssetEventKind::EntityBindingChanged,
asset_kind: event.asset_kind,
owner_user_id: event.owner_user_id,
profile_id: event.profile_id,
entity_kind: Some(event.entity_kind),
entity_id: Some(event.entity_id),
slot: Some(event.slot),
occurred_at: Timestamp::from_micros_since_unix_epoch(event.occurred_at_micros),
});
}

View File

@@ -5,6 +5,39 @@ const ASSET_HISTORY_CHARACTER_VISUAL_KIND: &str = "character_visual";
const ASSET_HISTORY_SCENE_IMAGE_KIND: &str = "scene_image";
const ASSET_HISTORY_PUZZLE_COVER_IMAGE_KIND: &str = "puzzle_cover_image";
/// 资产事件类型。
///
/// 事件表只承接订阅端和审计所需的轻量事实,正式资产状态仍以
/// `asset_object` 和 `asset_entity_binding` 为准。
#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)]
pub enum AssetEventKind {
ObjectConfirmed,
EntityBindingChanged,
}
#[spacetimedb::table(
accessor = asset_event,
public,
event,
index(accessor = by_asset_event_asset_object_id, btree(columns = [asset_object_id])),
index(accessor = by_asset_event_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_asset_event_profile_id, btree(columns = [profile_id]))
)]
pub struct AssetEvent {
#[primary_key]
pub(crate) event_id: String,
pub(crate) asset_object_id: String,
pub(crate) binding_id: Option<String>,
pub(crate) event_kind: AssetEventKind,
pub(crate) asset_kind: String,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) entity_kind: Option<String>,
pub(crate) entity_id: Option<String>,
pub(crate) slot: Option<String>,
pub(crate) occurred_at: Timestamp,
}
#[spacetimedb::table(
accessor = asset_object,
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
@@ -151,6 +184,8 @@ pub(crate) fn upsert_asset_object(
}
};
emit_asset_object_confirmed_event(ctx, &snapshot);
Ok(snapshot)
}
@@ -232,3 +267,34 @@ fn build_asset_object_row(snapshot: &AssetObjectUpsertSnapshot) -> AssetObject {
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
}
}
pub(crate) fn emit_asset_object_confirmed_event(
ctx: &ReducerContext,
snapshot: &AssetObjectUpsertSnapshot,
) {
let event = AssetObjectConfirmedEvent {
asset_object_id: snapshot.asset_object_id.clone(),
asset_kind: snapshot.asset_kind.clone(),
owner_user_id: snapshot.owner_user_id.clone(),
profile_id: snapshot.profile_id.clone(),
entity_id: snapshot.entity_id.clone(),
occurred_at_micros: snapshot.updated_at_micros,
};
ctx.db.asset_event().insert(AssetEvent {
event_id: format!(
"assetevt_{}_{}_confirmed",
event.asset_object_id, event.occurred_at_micros
),
asset_object_id: event.asset_object_id,
binding_id: None,
event_kind: AssetEventKind::ObjectConfirmed,
asset_kind: event.asset_kind,
owner_user_id: event.owner_user_id,
profile_id: event.profile_id,
entity_kind: None,
entity_id: event.entity_id,
slot: None,
occurred_at: Timestamp::from_micros_since_unix_epoch(event.occurred_at_micros),
});
}

View File

@@ -6,6 +6,10 @@ 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 {
@@ -619,37 +623,11 @@ fn resolve_battle_state_record(
.battle_state()
.insert(build_battle_state_row(result.snapshot.clone()));
if result.outcome == CombatOutcome::Victory {
grant_battle_reward_items(ctx, &result.snapshot)?;
if result.snapshot.experience_reward > 0 {
let updated_player = upsert_player_progression_after_grant_tx(
ctx,
PlayerProgressionGrantInput {
user_id: result.snapshot.actor_user_id.clone(),
amount: result.snapshot.experience_reward,
source: PlayerProgressionGrantSource::HostileNpc,
updated_at_micros: result.snapshot.updated_at_micros,
},
)?;
// 章节计划可能尚未初始化;此时不能阻断战斗胜利结算,只跳过章节账本写入。
try_update_chapter_progression_ledger_tx(
ctx,
result.snapshot.actor_user_id.clone(),
result.snapshot.chapter_id.clone(),
ChapterProgressionLedgerInput {
user_id: result.snapshot.actor_user_id.clone(),
chapter_id: result.snapshot.chapter_id.clone().unwrap_or_default(),
granted_quest_xp: 0,
granted_hostile_xp: result.snapshot.experience_reward,
hostile_defeat_increment: 1,
level_at_exit: Some(updated_player.level),
updated_at_micros: result.snapshot.updated_at_micros,
},
)?;
}
}
apply_rpg_gameplay_settlement_plan(
ctx,
build_combat_victory_settlement_plan(&result.snapshot),
Some(result.snapshot.battle_state_id.as_str()),
)?;
Ok(result)
}
@@ -1094,35 +1072,11 @@ pub fn turn_in_quest(ctx: &ReducerContext, input: QuestTurnInInput) -> Result<()
next.updated_at_micros,
);
let reward_experience = next.reward.experience.unwrap_or(0);
grant_quest_reward_items(ctx, &next)?;
if reward_experience > 0 {
let updated_player = upsert_player_progression_after_grant_tx(
ctx,
PlayerProgressionGrantInput {
user_id: next.actor_user_id.clone(),
amount: reward_experience,
source: PlayerProgressionGrantSource::Quest,
updated_at_micros: next.updated_at_micros,
},
)?;
// 章节计划缺失时先保持任务交付成功,避免成长联动反向阻断 quest 主链。
try_update_chapter_progression_ledger_tx(
ctx,
next.actor_user_id.clone(),
next.chapter_id.clone(),
ChapterProgressionLedgerInput {
user_id: next.actor_user_id.clone(),
chapter_id: next.chapter_id.clone().unwrap_or_default(),
granted_quest_xp: reward_experience,
granted_hostile_xp: 0,
hostile_defeat_increment: 0,
level_at_exit: Some(updated_player.level),
updated_at_micros: next.updated_at_micros,
},
)?;
}
apply_rpg_gameplay_settlement_plan(
ctx,
build_quest_turn_in_settlement_plan(&next),
Some(next.quest_id.as_str()),
)?;
Ok(())
}
@@ -1199,57 +1153,15 @@ fn upsert_treasure_record(
.treasure_record()
.insert(build_treasure_record_row(&snapshot, created_at, updated_at));
grant_treasure_reward_items_to_inventory(ctx, &snapshot)?;
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 grant_treasure_reward_items_to_inventory(
ctx: &ReducerContext,
snapshot: &TreasureRecordSnapshot,
) -> Result<(), String> {
for (index, reward_item) in snapshot.reward_items.iter().cloned().enumerate() {
let inventory_item = build_inventory_item_snapshot_from_reward_item(
&snapshot.treasure_record_id,
reward_item,
)
.map_err(|error| error.to_string())?;
let slot_id = build_treasure_inventory_slot_id(&snapshot.treasure_record_id, index);
let mutation_id = build_treasure_inventory_mutation_id(&snapshot.treasure_record_id, index);
apply_inventory_mutation_tx(
ctx,
InventoryMutationInput {
mutation_id,
runtime_session_id: snapshot.runtime_session_id.clone(),
story_session_id: Some(snapshot.story_session_id.clone()),
actor_user_id: snapshot.actor_user_id.clone(),
mutation: InventoryMutation::GrantItem(module_inventory::GrantInventoryItemInput {
slot_id,
item: inventory_item,
}),
updated_at_micros: snapshot.updated_at_micros,
},
)?;
}
Ok(())
}
fn build_treasure_inventory_slot_id(treasure_record_id: &str, reward_index: usize) -> String {
format!(
"{}{}_{}",
INVENTORY_SLOT_ID_PREFIX, treasure_record_id, reward_index
)
}
fn build_treasure_inventory_mutation_id(treasure_record_id: &str, reward_index: usize) -> String {
format!(
"{}{}_{}",
INVENTORY_MUTATION_ID_PREFIX, treasure_record_id, reward_index
)
}
fn build_treasure_record_row(
snapshot: &TreasureRecordSnapshot,
created_at: Timestamp,
@@ -1496,214 +1408,69 @@ fn build_inventory_slot_snapshot_from_row(row: &InventorySlot) -> InventorySlotS
}
}
fn grant_quest_reward_items(
fn apply_rpg_gameplay_settlement_plan(
ctx: &ReducerContext,
snapshot: &QuestRecordSnapshot,
plan: RpgGameplaySettlementPlan,
inventory_source_reference_id: Option<&str>,
) -> Result<(), String> {
if !ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == snapshot.runtime_session_id
&& row.actor_user_id == snapshot.actor_user_id
})
.all(|row| row.source_reference_id.as_deref() != Some(snapshot.quest_id.as_str()))
{
return Ok(());
}
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 (index, reward_item) in snapshot.reward.items.clone().into_iter().enumerate() {
let inventory_item =
build_inventory_item_snapshot_from_quest_reward_item(&snapshot.quest_id, reward_item);
grant_inventory_item_to_actor(
ctx,
&snapshot.runtime_session_id,
snapshot.story_session_id.clone(),
&snapshot.actor_user_id,
inventory_item,
build_reward_seed(snapshot.updated_at_micros, index),
snapshot.updated_at_micros,
)?;
}
Ok(())
}
fn grant_battle_reward_items(
ctx: &ReducerContext,
snapshot: &BattleStateSnapshot,
) -> Result<(), String> {
if snapshot.reward_items.is_empty() {
return Ok(());
}
if !ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == snapshot.runtime_session_id
&& row.actor_user_id == snapshot.actor_user_id
})
.all(|row| row.source_reference_id.as_deref() != Some(snapshot.battle_state_id.as_str()))
{
return Ok(());
}
for (index, reward_item) in snapshot.reward_items.clone().into_iter().enumerate() {
let inventory_item = build_inventory_item_snapshot_from_battle_reward_item(
&snapshot.battle_state_id,
reward_item,
);
grant_inventory_item_to_actor(
ctx,
&snapshot.runtime_session_id,
Some(snapshot.story_session_id.clone()),
&snapshot.actor_user_id,
inventory_item,
build_reward_seed(snapshot.updated_at_micros, index),
snapshot.updated_at_micros,
)?;
}
Ok(())
}
fn grant_inventory_item_to_actor(
ctx: &ReducerContext,
runtime_session_id: &str,
story_session_id: Option<String>,
actor_user_id: &str,
item: InventoryItemSnapshot,
seed_micros: i64,
updated_at_micros: i64,
) -> Result<(), String> {
let current_slots = ctx
.db
.inventory_slot()
.iter()
.filter(|row| {
row.runtime_session_id == runtime_session_id && row.actor_user_id == actor_user_id
})
.map(|row| build_inventory_slot_snapshot_from_row(&row))
.collect::<Vec<_>>();
let slot_id = generate_inventory_slot_id(seed_micros);
let mutation_id = generate_inventory_mutation_id(seed_micros);
let outcome = apply_inventory_slot_mutation(
current_slots,
InventoryMutationInput {
mutation_id,
runtime_session_id: runtime_session_id.to_string(),
story_session_id,
actor_user_id: actor_user_id.to_string(),
mutation: InventoryMutation::GrantItem(GrantInventoryItemInput { slot_id, item }),
updated_at_micros,
},
)
.map_err(|error| error.to_string())?;
for removed_slot_id in outcome.removed_slot_ids {
ctx.db.inventory_slot().slot_id().delete(&removed_slot_id);
}
for slot in outcome.next_slots {
ctx.db.inventory_slot().slot_id().delete(&slot.slot_id);
ctx.db
.inventory_slot()
.insert(build_inventory_slot_row(slot));
}
Ok(())
}
fn build_inventory_item_snapshot_from_battle_reward_item(
battle_state_id: &str,
reward_item: RuntimeItemRewardItemSnapshot,
) -> InventoryItemSnapshot {
InventoryItemSnapshot {
item_id: reward_item.item_id,
category: reward_item.category,
name: reward_item.item_name,
description: reward_item.description,
quantity: reward_item.quantity,
rarity: map_runtime_reward_item_rarity(reward_item.rarity),
tags: reward_item.tags,
stackable: reward_item.stackable,
stack_key: reward_item.stack_key,
equipment_slot_id: reward_item
.equipment_slot_id
.map(map_runtime_reward_equipment_slot),
source_kind: InventoryItemSourceKind::CombatDrop,
source_reference_id: Some(battle_state_id.to_string()),
}
}
fn build_inventory_item_snapshot_from_quest_reward_item(
quest_id: &str,
reward_item: QuestRewardItem,
) -> InventoryItemSnapshot {
InventoryItemSnapshot {
item_id: reward_item.item_id,
category: reward_item.category,
name: reward_item.name,
description: reward_item.description,
quantity: reward_item.quantity,
rarity: map_quest_reward_item_rarity(reward_item.rarity),
tags: reward_item.tags,
stackable: reward_item.stackable,
stack_key: reward_item.stack_key,
equipment_slot_id: reward_item
.equipment_slot_id
.map(map_quest_reward_equipment_slot),
source_kind: InventoryItemSourceKind::QuestReward,
source_reference_id: Some(quest_id.to_string()),
}
}
fn map_quest_reward_item_rarity(rarity: QuestRewardItemRarity) -> InventoryItemRarity {
match rarity {
QuestRewardItemRarity::Common => InventoryItemRarity::Common,
QuestRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
QuestRewardItemRarity::Rare => InventoryItemRarity::Rare,
QuestRewardItemRarity::Epic => InventoryItemRarity::Epic,
QuestRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
}
}
fn map_runtime_reward_item_rarity(
rarity: module_runtime_item::RuntimeItemRewardItemRarity,
) -> InventoryItemRarity {
match rarity {
module_runtime_item::RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
module_runtime_item::RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
module_runtime_item::RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
module_runtime_item::RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
module_runtime_item::RuntimeItemRewardItemRarity::Legendary => {
InventoryItemRarity::Legendary
for mutation in plan.inventory_mutations.clone() {
apply_inventory_mutation_tx(ctx, mutation)?;
}
}
apply_progression_and_chapter_settlement(ctx, plan)
}
fn map_quest_reward_equipment_slot(slot: QuestRewardEquipmentSlot) -> InventoryEquipmentSlot {
match slot {
QuestRewardEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
QuestRewardEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
QuestRewardEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
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()
.iter()
.filter(|row| {
row.runtime_session_id == first_mutation.runtime_session_id
&& 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,
)?;
}
}
fn map_runtime_reward_equipment_slot(
slot: module_runtime_item::RuntimeItemEquipmentSlot,
) -> InventoryEquipmentSlot {
match slot {
module_runtime_item::RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
module_runtime_item::RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
module_runtime_item::RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
}
}
fn build_reward_seed(updated_at_micros: i64, index: usize) -> i64 {
updated_at_micros.saturating_add(index as i64 + 1)
Ok(())
}
fn build_story_session_snapshot_from_row(row: &StorySession) -> StorySessionSnapshot {

View File

@@ -139,6 +139,7 @@ macro_rules! migration_tables {
custom_world_gallery_entry,
asset_object,
asset_entity_binding,
asset_event,
puzzle_agent_session,
puzzle_agent_message,
puzzle_work_profile,

View File

@@ -1080,10 +1080,22 @@ fn start_puzzle_run_tx(
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "入口拼图作品不存在".to_string())?;
if entry_profile_row.publication_status != PuzzlePublicationStatus::Published {
// 结果页试玩允许作者启动自己的草稿 run公开入口仍必须保持已发布状态。
let is_owner_draft_preview = entry_profile_row.publication_status
== PuzzlePublicationStatus::Draft
&& entry_profile_row.owner_user_id == input.owner_user_id;
if entry_profile_row.publication_status != PuzzlePublicationStatus::Published
&& !is_owner_draft_preview
{
return Err("入口拼图作品未发布".to_string());
}
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
if entry_profile.cover_image_src.is_none() {
return Err("入口拼图作品缺少正式图片".to_string());
}
if entry_profile.theme_tags.is_empty() {
return Err("入口拼图作品缺少标签".to_string());
}
let mut run =
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
let current_grid_size = run.current_grid_size;
@@ -1102,13 +1114,15 @@ fn start_puzzle_run_tx(
)
.map(|value| value.profile_id.clone());
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
upsert_puzzle_profile_played_work(
ctx,
&input.owner_user_id,
&entry_profile_row,
input.started_at_micros,
)?;
if entry_profile_row.publication_status == PuzzlePublicationStatus::Published {
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
upsert_puzzle_profile_played_work(
ctx,
&input.owner_user_id,
&entry_profile_row,
input.started_at_micros,
)?;
}
insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?;
Ok(run)
}
@@ -1246,6 +1260,23 @@ fn submit_puzzle_leaderboard_entry_tx(
if current_level.grid_size != input.grid_size {
return Err("提交成绩的网格规格与当前关卡不匹配".to_string());
}
let current_profile_row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "提交成绩的拼图作品不存在".to_string())?;
if current_profile_row.publication_status != PuzzlePublicationStatus::Published {
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
&input.profile_id,
input.grid_size,
);
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
return Ok(run);
}
let nickname = input.nickname.trim();
if nickname.is_empty() {