use std::{error::Error, fmt}; use serde::{Deserialize, Serialize}; use shared_kernel::{ build_prefixed_seed_id, format_timestamp_micros, normalize_optional_string as normalize_shared_optional_string, normalize_required_string, }; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; pub const STORY_SESSION_ID_PREFIX: &str = "storysess_"; pub const STORY_EVENT_ID_PREFIX: &str = "storyevt_"; pub const INITIAL_STORY_SESSION_VERSION: u32 = 1; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum StorySessionStatus { Active, Completed, Archived, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum StoryEventKind { SessionStarted, StoryContinued, } impl StorySessionStatus { pub fn as_str(&self) -> &'static str { match self { Self::Active => "active", Self::Completed => "completed", Self::Archived => "archived", } } } impl StoryEventKind { pub fn as_str(&self) -> &'static str { match self { Self::SessionStarted => "session_started", Self::StoryContinued => "story_continued", } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum StorySessionFieldError { MissingSessionId, MissingRuntimeSessionId, MissingActorUserId, MissingWorldProfileId, MissingInitialPrompt, MissingNarrativeText, MissingEventId, InvalidVersion, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionInput { pub story_session_id: String, pub runtime_session_id: String, pub actor_user_id: String, pub world_profile_id: String, pub initial_prompt: String, pub opening_summary: Option, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionSnapshot { pub story_session_id: String, pub runtime_session_id: String, pub actor_user_id: String, pub world_profile_id: String, pub initial_prompt: String, pub opening_summary: Option, pub latest_narrative_text: String, pub latest_choice_function_id: Option, pub status: StorySessionStatus, pub version: u32, 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 StoryContinueInput { pub story_session_id: String, pub event_id: String, pub narrative_text: String, pub choice_function_id: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionStateInput { pub story_session_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StoryEventSnapshot { pub event_id: String, pub story_session_id: String, pub event_kind: StoryEventKind, pub narrative_text: String, pub choice_function_id: Option, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionProcedureResult { pub ok: bool, pub session: Option, pub event: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct StorySessionStateProcedureResult { pub ok: bool, pub session: Option, pub events: Vec, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorySessionRecord { pub story_session_id: String, pub runtime_session_id: String, pub actor_user_id: String, pub world_profile_id: String, pub initial_prompt: String, pub opening_summary: Option, pub latest_narrative_text: String, pub latest_choice_function_id: Option, pub status: String, pub version: u32, pub created_at: String, pub updated_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoryEventRecord { pub event_id: String, pub story_session_id: String, pub event_kind: String, pub narrative_text: String, pub choice_function_id: Option, pub created_at: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorySessionResultRecord { pub session: StorySessionRecord, pub event: StoryEventRecord, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct StorySessionStateRecord { pub session: StorySessionRecord, pub events: Vec, } pub fn build_story_session_input( story_session_id: String, runtime_session_id: String, actor_user_id: String, world_profile_id: String, initial_prompt: String, opening_summary: Option, created_at_micros: i64, ) -> Result { let input = StorySessionInput { story_session_id: normalize_required_string(story_session_id).unwrap_or_default(), runtime_session_id: normalize_required_string(runtime_session_id).unwrap_or_default(), actor_user_id: normalize_required_string(actor_user_id).unwrap_or_default(), world_profile_id: normalize_required_string(world_profile_id).unwrap_or_default(), initial_prompt: normalize_required_string(initial_prompt).unwrap_or_default(), opening_summary: normalize_optional_value(opening_summary), created_at_micros, }; validate_story_session_input(&input)?; Ok(input) } pub fn build_story_session_state_input( story_session_id: String, ) -> Result { let input = StorySessionStateInput { story_session_id: normalize_required_string(story_session_id).unwrap_or_default(), }; validate_story_session_state_input(&input)?; Ok(input) } pub fn build_story_continue_input( story_session_id: String, event_id: String, narrative_text: String, choice_function_id: Option, updated_at_micros: i64, ) -> Result { let input = StoryContinueInput { story_session_id: normalize_required_string(story_session_id).unwrap_or_default(), event_id: normalize_required_string(event_id).unwrap_or_default(), narrative_text: normalize_required_string(narrative_text).unwrap_or_default(), choice_function_id: normalize_optional_value(choice_function_id), updated_at_micros, }; validate_story_continue_input(&input)?; Ok(input) } pub fn validate_story_session_input( input: &StorySessionInput, ) -> Result<(), StorySessionFieldError> { if normalize_required_string(&input.story_session_id).is_none() { return Err(StorySessionFieldError::MissingSessionId); } if normalize_required_string(&input.runtime_session_id).is_none() { return Err(StorySessionFieldError::MissingRuntimeSessionId); } if normalize_required_string(&input.actor_user_id).is_none() { return Err(StorySessionFieldError::MissingActorUserId); } if normalize_required_string(&input.world_profile_id).is_none() { return Err(StorySessionFieldError::MissingWorldProfileId); } if normalize_required_string(&input.initial_prompt).is_none() { return Err(StorySessionFieldError::MissingInitialPrompt); } Ok(()) } pub fn validate_story_session_state_input( input: &StorySessionStateInput, ) -> Result<(), StorySessionFieldError> { if normalize_required_string(&input.story_session_id).is_none() { return Err(StorySessionFieldError::MissingSessionId); } Ok(()) } pub fn validate_story_continue_input( input: &StoryContinueInput, ) -> Result<(), StorySessionFieldError> { if normalize_required_string(&input.story_session_id).is_none() { return Err(StorySessionFieldError::MissingSessionId); } if normalize_required_string(&input.event_id).is_none() { return Err(StorySessionFieldError::MissingEventId); } if normalize_required_string(&input.narrative_text).is_none() { return Err(StorySessionFieldError::MissingNarrativeText); } Ok(()) } pub fn build_story_session_snapshot(input: StorySessionInput) -> StorySessionSnapshot { StorySessionSnapshot { story_session_id: input.story_session_id, runtime_session_id: input.runtime_session_id, actor_user_id: input.actor_user_id, world_profile_id: input.world_profile_id, initial_prompt: input.initial_prompt, opening_summary: normalize_optional_value(input.opening_summary), latest_narrative_text: String::new(), latest_choice_function_id: None, status: StorySessionStatus::Active, version: INITIAL_STORY_SESSION_VERSION, created_at_micros: input.created_at_micros, updated_at_micros: input.created_at_micros, } } pub fn build_story_started_event(snapshot: &StorySessionSnapshot) -> StoryEventSnapshot { StoryEventSnapshot { event_id: generate_story_event_id(snapshot.created_at_micros), story_session_id: snapshot.story_session_id.clone(), event_kind: StoryEventKind::SessionStarted, narrative_text: snapshot .opening_summary .clone() .unwrap_or_else(|| snapshot.initial_prompt.clone()), choice_function_id: None, created_at_micros: snapshot.created_at_micros, } } pub fn apply_story_continue( current: StorySessionSnapshot, input: StoryContinueInput, ) -> Result<(StorySessionSnapshot, StoryEventSnapshot), StorySessionFieldError> { validate_story_continue_input(&input)?; if current.version == 0 { return Err(StorySessionFieldError::InvalidVersion); } let event = StoryEventSnapshot { event_id: input.event_id, story_session_id: current.story_session_id.clone(), event_kind: StoryEventKind::StoryContinued, narrative_text: input.narrative_text.clone(), choice_function_id: normalize_optional_value(input.choice_function_id), created_at_micros: input.updated_at_micros, }; let next = StorySessionSnapshot { latest_narrative_text: input.narrative_text, latest_choice_function_id: event.choice_function_id.clone(), version: current.version + 1, updated_at_micros: input.updated_at_micros, ..current }; Ok((next, event)) } pub fn generate_story_session_id(seed_micros: i64) -> String { build_prefixed_seed_id(STORY_SESSION_ID_PREFIX, seed_micros) } pub fn generate_story_event_id(seed_micros: i64) -> String { build_prefixed_seed_id(STORY_EVENT_ID_PREFIX, seed_micros) } pub fn build_story_session_record(snapshot: StorySessionSnapshot) -> StorySessionRecord { StorySessionRecord { story_session_id: snapshot.story_session_id, runtime_session_id: snapshot.runtime_session_id, actor_user_id: snapshot.actor_user_id, world_profile_id: snapshot.world_profile_id, initial_prompt: snapshot.initial_prompt, opening_summary: snapshot.opening_summary, latest_narrative_text: snapshot.latest_narrative_text, latest_choice_function_id: snapshot.latest_choice_function_id, status: snapshot.status.as_str().to_string(), version: snapshot.version, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), } } pub fn build_story_event_record(snapshot: StoryEventSnapshot) -> StoryEventRecord { StoryEventRecord { event_id: snapshot.event_id, story_session_id: snapshot.story_session_id, event_kind: snapshot.event_kind.as_str().to_string(), narrative_text: snapshot.narrative_text, choice_function_id: snapshot.choice_function_id, created_at: format_timestamp_micros(snapshot.created_at_micros), } } pub fn build_story_session_result_record( session: StorySessionSnapshot, event: StoryEventSnapshot, ) -> StorySessionResultRecord { StorySessionResultRecord { session: build_story_session_record(session), event: build_story_event_record(event), } } pub fn build_story_session_state_record( session: StorySessionSnapshot, events: Vec, ) -> StorySessionStateRecord { StorySessionStateRecord { session: build_story_session_record(session), events: events .into_iter() .map(build_story_event_record) .collect::>(), } } pub fn normalize_optional_value(value: Option) -> Option { normalize_shared_optional_string(value) } impl fmt::Display for StorySessionFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingSessionId => f.write_str("story_session.story_session_id 不能为空"), Self::MissingRuntimeSessionId => { f.write_str("story_session.runtime_session_id 不能为空") } Self::MissingActorUserId => f.write_str("story_session.actor_user_id 不能为空"), Self::MissingWorldProfileId => f.write_str("story_session.world_profile_id 不能为空"), Self::MissingInitialPrompt => f.write_str("story_session.initial_prompt 不能为空"), Self::MissingNarrativeText => f.write_str("story_event.narrative_text 不能为空"), Self::MissingEventId => f.write_str("story_event.event_id 不能为空"), Self::InvalidVersion => f.write_str("story_session.version 必须大于 0"), } } } impl Error for StorySessionFieldError {} #[cfg(test)] mod tests { use super::*; #[test] fn validate_story_session_input_accepts_minimal_contract() { let result = validate_story_session_input(&StorySessionInput { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), created_at_micros: 1_713_680_000_000_000, }); assert!(result.is_ok()); } #[test] fn validate_story_session_input_rejects_missing_required_fields() { let error = validate_story_session_input(&StorySessionInput { story_session_id: String::new(), runtime_session_id: String::new(), actor_user_id: String::new(), world_profile_id: String::new(), initial_prompt: String::new(), opening_summary: None, created_at_micros: 1, }) .expect_err("missing required story session fields should fail"); assert_eq!(error, StorySessionFieldError::MissingSessionId); } #[test] fn build_story_session_snapshot_uses_active_status_and_initial_version() { let snapshot = build_story_session_snapshot(StorySessionInput { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some(" ".to_string()), created_at_micros: 12, }); assert_eq!(snapshot.status, StorySessionStatus::Active); assert_eq!(snapshot.version, INITIAL_STORY_SESSION_VERSION); assert_eq!(snapshot.opening_summary, None); } #[test] fn build_story_session_input_normalizes_optional_summary() { let input = build_story_session_input( " storysess_001 ".to_string(), " runtime_001 ".to_string(), " user_001 ".to_string(), " profile_001 ".to_string(), " 进入营地 ".to_string(), Some(" ".to_string()), 12, ) .expect("story session input should build"); assert_eq!(input.story_session_id, "storysess_001"); assert_eq!(input.runtime_session_id, "runtime_001"); assert_eq!(input.actor_user_id, "user_001"); assert_eq!(input.world_profile_id, "profile_001"); assert_eq!(input.initial_prompt, "进入营地"); assert_eq!(input.opening_summary, None); } #[test] fn build_story_session_state_input_rejects_blank_session_id() { let error = build_story_session_state_input(" ".to_string()) .expect_err("blank story session id should fail"); assert_eq!(error, StorySessionFieldError::MissingSessionId); } #[test] fn build_story_session_state_record_maps_all_events() { let record = build_story_session_state_record( StorySessionSnapshot { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "你看见篝火边有人招手。".to_string(), latest_choice_function_id: Some("talk_to_npc".to_string()), status: StorySessionStatus::Active, version: 2, created_at_micros: 1_713_686_400_000_000, updated_at_micros: 1_713_686_401_234_567, }, vec![ StoryEventSnapshot { event_id: "storyevt_001".to_string(), story_session_id: "storysess_001".to_string(), event_kind: StoryEventKind::SessionStarted, narrative_text: "营地开场".to_string(), choice_function_id: None, created_at_micros: 1_713_686_400_000_000, }, StoryEventSnapshot { event_id: "storyevt_002".to_string(), story_session_id: "storysess_001".to_string(), event_kind: StoryEventKind::StoryContinued, narrative_text: "你看见篝火边有人招手。".to_string(), choice_function_id: Some("talk_to_npc".to_string()), created_at_micros: 1_713_686_401_234_567, }, ], ); assert_eq!(record.session.story_session_id, "storysess_001"); assert_eq!(record.events.len(), 2); assert_eq!(record.events[0].event_kind, "session_started"); assert_eq!(record.events[1].event_kind, "story_continued"); } #[test] fn build_story_session_result_record_formats_status_and_timestamps() { let record = build_story_session_result_record( StorySessionSnapshot { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), latest_narrative_text: "你看到营地中央的篝火。".to_string(), latest_choice_function_id: Some("inspect_campfire".to_string()), status: StorySessionStatus::Active, version: 2, created_at_micros: 1_713_686_400_000_000, updated_at_micros: 1_713_686_401_234_567, }, StoryEventSnapshot { event_id: "storyevt_001".to_string(), story_session_id: "storysess_001".to_string(), event_kind: StoryEventKind::StoryContinued, narrative_text: "你看到营地中央的篝火。".to_string(), choice_function_id: Some("inspect_campfire".to_string()), created_at_micros: 1_713_686_401_234_567, }, ); assert_eq!(record.session.status, "active"); assert_eq!(record.session.created_at, "1713686400.000000Z"); assert_eq!(record.session.updated_at, "1713686401.234567Z"); assert_eq!(record.event.event_kind, "story_continued"); assert_eq!(record.event.created_at, "1713686401.234567Z"); } #[test] fn apply_story_continue_updates_latest_narrative_and_emits_event() { let current = build_story_session_snapshot(StorySessionInput { story_session_id: "storysess_001".to_string(), runtime_session_id: "runtime_001".to_string(), actor_user_id: "user_001".to_string(), world_profile_id: "profile_001".to_string(), initial_prompt: "进入营地".to_string(), opening_summary: Some("营地开场".to_string()), created_at_micros: 10, }); let (next, event) = apply_story_continue( current, StoryContinueInput { story_session_id: "storysess_001".to_string(), event_id: "storyevt_001".to_string(), narrative_text: "你看见篝火边有人招手。".to_string(), choice_function_id: Some("talk_to_npc".to_string()), updated_at_micros: 20, }, ) .expect("continue story should succeed"); assert_eq!(next.latest_narrative_text, "你看见篝火边有人招手。"); assert_eq!( next.latest_choice_function_id.as_deref(), Some("talk_to_npc") ); assert_eq!(next.version, INITIAL_STORY_SESSION_VERSION + 1); assert_eq!(event.event_kind, StoryEventKind::StoryContinued); } }