init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,409 @@
use std::{error::Error, fmt};
use module_inventory::{
InventoryEquipmentSlot, InventoryItemRarity, InventoryItemSnapshot, InventoryItemSourceKind,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{
normalize_optional_string as normalize_shared_optional_string, normalize_required_string,
normalize_string_list as normalize_shared_string_list,
};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const TREASURE_RECORD_ID_PREFIX: &str = "treasure_";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TreasureInteractionAction {
Inspect,
Leave,
Secure,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeItemRewardItemSnapshot {
pub item_id: String,
pub category: String,
pub item_name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: RuntimeItemRewardItemRarity,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<RuntimeItemEquipmentSlot>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeItemRewardItemRarity {
Common,
Uncommon,
Rare,
Epic,
Legendary,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeItemEquipmentSlot {
Weapon,
Armor,
Relic,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureResolveInput {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub actor_user_id: String,
pub encounter_id: String,
pub encounter_name: String,
pub scene_id: Option<String>,
pub scene_name: Option<String>,
pub action: TreasureInteractionAction,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub reward_hp: u32,
pub reward_mana: u32,
pub reward_currency: u32,
pub story_hint: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureRecordSnapshot {
pub treasure_record_id: String,
pub runtime_session_id: String,
pub story_session_id: String,
pub actor_user_id: String,
pub encounter_id: String,
pub encounter_name: String,
pub scene_id: Option<String>,
pub scene_name: Option<String>,
pub action: TreasureInteractionAction,
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
pub reward_hp: u32,
pub reward_mana: u32,
pub reward_currency: u32,
pub story_hint: Option<String>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreasureRecordProcedureResult {
pub ok: bool,
pub record: Option<TreasureRecordSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TreasureFieldError {
MissingTreasureRecordId,
MissingRuntimeSessionId,
MissingStorySessionId,
MissingActorUserId,
MissingEncounterId,
MissingEncounterName,
MissingRewardItemId,
MissingRewardItemCategory,
MissingRewardItemName,
InvalidRewardItemQuantity,
MissingRewardItemStackKey,
RewardEquipmentItemCannotStack,
RewardNonStackableItemMustStaySingleQuantity,
}
pub fn build_treasure_record_snapshot(
input: TreasureResolveInput,
) -> Result<TreasureRecordSnapshot, TreasureFieldError> {
validate_treasure_input(&input)?;
Ok(TreasureRecordSnapshot {
treasure_record_id: input.treasure_record_id,
runtime_session_id: input.runtime_session_id,
story_session_id: input.story_session_id,
actor_user_id: input.actor_user_id,
encounter_id: input.encounter_id,
encounter_name: input.encounter_name,
scene_id: normalize_optional_value(input.scene_id),
scene_name: normalize_optional_value(input.scene_name),
action: input.action,
reward_items: input
.reward_items
.into_iter()
.map(normalize_reward_item)
.collect::<Result<Vec<_>, _>>()?,
reward_hp: input.reward_hp,
reward_mana: input.reward_mana,
reward_currency: input.reward_currency,
story_hint: normalize_optional_value(input.story_hint),
created_at_micros: input.created_at_micros,
updated_at_micros: input.updated_at_micros,
})
}
pub fn build_inventory_item_snapshot_from_reward_item(
treasure_record_id: &str,
reward_item: RuntimeItemRewardItemSnapshot,
) -> Result<InventoryItemSnapshot, TreasureFieldError> {
let treasure_record_id = normalize_required_value(
treasure_record_id.to_string(),
TreasureFieldError::MissingTreasureRecordId,
)?;
let reward_item = normalize_reward_item(reward_item)?;
Ok(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_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_reward_item_equipment_slot),
source_kind: InventoryItemSourceKind::TreasureReward,
source_reference_id: Some(treasure_record_id),
})
}
pub fn normalize_reward_item_snapshot(
reward_item: RuntimeItemRewardItemSnapshot,
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
normalize_reward_item(reward_item)
}
fn validate_treasure_input(input: &TreasureResolveInput) -> Result<(), TreasureFieldError> {
if input.treasure_record_id.trim().is_empty() {
return Err(TreasureFieldError::MissingTreasureRecordId);
}
if input.runtime_session_id.trim().is_empty() {
return Err(TreasureFieldError::MissingRuntimeSessionId);
}
if input.story_session_id.trim().is_empty() {
return Err(TreasureFieldError::MissingStorySessionId);
}
if input.actor_user_id.trim().is_empty() {
return Err(TreasureFieldError::MissingActorUserId);
}
if input.encounter_id.trim().is_empty() {
return Err(TreasureFieldError::MissingEncounterId);
}
if input.encounter_name.trim().is_empty() {
return Err(TreasureFieldError::MissingEncounterName);
}
Ok(())
}
fn normalize_optional_value(value: Option<String>) -> Option<String> {
normalize_shared_optional_string(value)
}
fn normalize_reward_item(
mut item: RuntimeItemRewardItemSnapshot,
) -> Result<RuntimeItemRewardItemSnapshot, TreasureFieldError> {
item.item_id = normalize_required_value(item.item_id, TreasureFieldError::MissingRewardItemId)?;
item.category =
normalize_required_value(item.category, TreasureFieldError::MissingRewardItemCategory)?;
item.item_name =
normalize_required_value(item.item_name, TreasureFieldError::MissingRewardItemName)?;
item.description = normalize_optional_value(item.description);
if item.quantity == 0 {
return Err(TreasureFieldError::InvalidRewardItemQuantity);
}
if !item.stackable && item.quantity != 1 {
return Err(TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity);
}
if item.equipment_slot_id.is_some() && item.stackable {
return Err(TreasureFieldError::RewardEquipmentItemCannotStack);
}
item.tags = normalize_string_list(item.tags);
item.stack_key = if item.stackable {
normalize_required_value(
item.stack_key,
TreasureFieldError::MissingRewardItemStackKey,
)?
} else {
normalize_optional_value(Some(item.stack_key)).unwrap_or_else(|| item.item_id.clone())
};
Ok(item)
}
fn normalize_required_value(
value: String,
error: TreasureFieldError,
) -> Result<String, TreasureFieldError> {
normalize_required_string(value).ok_or(error)
}
fn normalize_string_list(values: Vec<String>) -> Vec<String> {
normalize_shared_string_list(values)
}
fn map_reward_item_rarity(rarity: RuntimeItemRewardItemRarity) -> InventoryItemRarity {
match rarity {
RuntimeItemRewardItemRarity::Common => InventoryItemRarity::Common,
RuntimeItemRewardItemRarity::Uncommon => InventoryItemRarity::Uncommon,
RuntimeItemRewardItemRarity::Rare => InventoryItemRarity::Rare,
RuntimeItemRewardItemRarity::Epic => InventoryItemRarity::Epic,
RuntimeItemRewardItemRarity::Legendary => InventoryItemRarity::Legendary,
}
}
fn map_reward_item_equipment_slot(slot: RuntimeItemEquipmentSlot) -> InventoryEquipmentSlot {
match slot {
RuntimeItemEquipmentSlot::Weapon => InventoryEquipmentSlot::Weapon,
RuntimeItemEquipmentSlot::Armor => InventoryEquipmentSlot::Armor,
RuntimeItemEquipmentSlot::Relic => InventoryEquipmentSlot::Relic,
}
}
impl fmt::Display for TreasureFieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingTreasureRecordId => {
f.write_str("treasure_record.treasure_record_id 不能为空")
}
Self::MissingRuntimeSessionId => {
f.write_str("treasure_record.runtime_session_id 不能为空")
}
Self::MissingStorySessionId => f.write_str("treasure_record.story_session_id 不能为空"),
Self::MissingActorUserId => f.write_str("treasure_record.actor_user_id 不能为空"),
Self::MissingEncounterId => f.write_str("treasure_record.encounter_id 不能为空"),
Self::MissingEncounterName => f.write_str("treasure_record.encounter_name 不能为空"),
Self::MissingRewardItemId => {
f.write_str("treasure_record.reward_items[].item_id 不能为空")
}
Self::MissingRewardItemCategory => {
f.write_str("treasure_record.reward_items[].category 不能为空")
}
Self::MissingRewardItemName => {
f.write_str("treasure_record.reward_items[].item_name 不能为空")
}
Self::InvalidRewardItemQuantity => {
f.write_str("treasure_record.reward_items[].quantity 必须大于 0")
}
Self::MissingRewardItemStackKey => {
f.write_str("treasure_record.reward_items[].stack_key 不能为空")
}
Self::RewardEquipmentItemCannotStack => {
f.write_str("treasure_record.reward_items[] 可装备物品不能标记为 stackable")
}
Self::RewardNonStackableItemMustStaySingleQuantity => {
f.write_str("treasure_record.reward_items[] 不可堆叠物品必须固定为单槽位单数量")
}
}
}
}
impl Error for TreasureFieldError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_treasure_record_snapshot_accepts_minimal_contract() {
let snapshot = build_treasure_record_snapshot(TreasureResolveInput {
treasure_record_id: "treasure_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
story_session_id: "storysess_001".to_string(),
actor_user_id: "user_001".to_string(),
encounter_id: "enc_001".to_string(),
encounter_name: "旧钟楼暗格".to_string(),
scene_id: Some("scene_001".to_string()),
scene_name: Some("旧钟楼".to_string()),
action: TreasureInteractionAction::Inspect,
reward_items: vec![RuntimeItemRewardItemSnapshot {
item_id: "item_001".to_string(),
category: "遗物".to_string(),
item_name: "铜钥残片".to_string(),
description: Some("带着旧钟楼铜锈味的钥片。".to_string()),
quantity: 1,
rarity: RuntimeItemRewardItemRarity::Rare,
tags: vec!["钥片".to_string(), "钟楼".to_string()],
stackable: false,
stack_key: String::new(),
equipment_slot_id: None,
}],
reward_hp: 3,
reward_mana: 2,
reward_currency: 10,
story_hint: Some("发现了旧机关的回响。".to_string()),
created_at_micros: 10,
updated_at_micros: 10,
})
.expect("minimal treasure snapshot should succeed");
assert_eq!(snapshot.treasure_record_id, "treasure_001");
assert_eq!(snapshot.reward_items.len(), 1);
}
#[test]
fn build_inventory_item_snapshot_from_reward_item_keeps_inventory_fields() {
let item = build_inventory_item_snapshot_from_reward_item(
"treasure_001",
RuntimeItemRewardItemSnapshot {
item_id: "item_001".to_string(),
category: "遗物".to_string(),
item_name: "铜钥残片".to_string(),
description: Some("带着旧钟楼铜锈味的钥片。".to_string()),
quantity: 1,
rarity: RuntimeItemRewardItemRarity::Rare,
tags: vec!["钥片".to_string(), "钟楼".to_string()],
stackable: false,
stack_key: String::new(),
equipment_slot_id: Some(RuntimeItemEquipmentSlot::Relic),
},
)
.expect("reward item should convert into inventory item");
assert_eq!(item.item_id, "item_001");
assert_eq!(item.category, "遗物");
assert_eq!(item.name, "铜钥残片");
assert_eq!(item.rarity, InventoryItemRarity::Rare);
assert_eq!(item.stack_key, "item_001");
assert_eq!(item.equipment_slot_id, Some(InventoryEquipmentSlot::Relic));
assert_eq!(item.source_kind, InventoryItemSourceKind::TreasureReward);
assert_eq!(item.source_reference_id, Some("treasure_001".to_string()));
}
#[test]
fn normalize_reward_item_snapshot_trims_and_fills_stack_key() {
let item = normalize_reward_item_snapshot(RuntimeItemRewardItemSnapshot {
item_id: " item_001 ".to_string(),
category: " 遗物 ".to_string(),
item_name: " 铜钥残片 ".to_string(),
description: Some(" 带着旧钟楼铜锈味的钥片。 ".to_string()),
quantity: 1,
rarity: RuntimeItemRewardItemRarity::Rare,
tags: vec![" 钥片 ".to_string(), "".to_string(), "钟楼".to_string()],
stackable: false,
stack_key: String::new(),
equipment_slot_id: None,
})
.expect("reward item should normalize");
assert_eq!(item.item_id, "item_001");
assert_eq!(item.category, "遗物");
assert_eq!(item.item_name, "铜钥残片");
assert_eq!(
item.description.as_deref(),
Some("带着旧钟楼铜锈味的钥片。")
);
assert_eq!(item.tags, vec!["钥片".to_string(), "钟楼".to_string()]);
assert_eq!(item.stack_key, "item_001");
}
}