611 lines
22 KiB
Rust
611 lines
22 KiB
Rust
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<String>,
|
|
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<String>,
|
|
pub latest_narrative_text: String,
|
|
pub latest_choice_function_id: Option<String>,
|
|
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<String>,
|
|
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<String>,
|
|
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<StorySessionSnapshot>,
|
|
pub event: Option<StoryEventSnapshot>,
|
|
pub error_message: Option<String>,
|
|
}
|
|
|
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct StorySessionStateProcedureResult {
|
|
pub ok: bool,
|
|
pub session: Option<StorySessionSnapshot>,
|
|
pub events: Vec<StoryEventSnapshot>,
|
|
pub error_message: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub latest_narrative_text: String,
|
|
pub latest_choice_function_id: Option<String>,
|
|
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<String>,
|
|
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<StoryEventRecord>,
|
|
}
|
|
|
|
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<String>,
|
|
created_at_micros: i64,
|
|
) -> Result<StorySessionInput, StorySessionFieldError> {
|
|
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<StorySessionStateInput, StorySessionFieldError> {
|
|
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<String>,
|
|
updated_at_micros: i64,
|
|
) -> Result<StoryContinueInput, StorySessionFieldError> {
|
|
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<StoryEventSnapshot>,
|
|
) -> StorySessionStateRecord {
|
|
StorySessionStateRecord {
|
|
session: build_story_session_record(session),
|
|
events: events
|
|
.into_iter()
|
|
.map(build_story_event_record)
|
|
.collect::<Vec<_>>(),
|
|
}
|
|
}
|
|
|
|
pub fn normalize_optional_value(value: Option<String>) -> Option<String> {
|
|
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);
|
|
}
|
|
}
|