This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -8,7 +8,7 @@ use module_npc::{
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
build_relation_state as build_module_npc_relation_state,
};
use module_runtime::RuntimeSnapshotRecord;
use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros};
use module_runtime_story_compat::{
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
@@ -376,15 +376,28 @@ async fn persist_runtime_story_snapshot(
)
})?
.unwrap_or(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let updated_at_micros = offset_datetime_to_unix_micros(now);
if is_non_persistent_runtime_story_snapshot(&snapshot) {
return Ok(build_transient_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
snapshot.current_story,
updated_at_micros,
));
}
state
.put_runtime_snapshot_record(
user_id,
offset_datetime_to_unix_micros(saved_at),
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
snapshot.current_story,
offset_datetime_to_unix_micros(now),
updated_at_micros,
)
.await
.map_err(|error| {
@@ -392,6 +405,52 @@ async fn persist_runtime_story_snapshot(
})
}
fn build_transient_runtime_snapshot_record(
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> RuntimeSnapshotRecord {
// 中文注释:预览/测试只需要本次响应里的 hydrated snapshot不能写入正式存档表。
RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state_json: game_state.to_string(),
current_story_json: current_story.as_ref().map(Value::to_string),
game_state,
current_story,
created_at_micros: updated_at_micros,
updated_at_micros,
}
}
fn is_non_persistent_runtime_story_snapshot(snapshot: &RuntimeStorySnapshotPayload) -> bool {
let Some(game_state) = snapshot.game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
return Err("snapshot.bottomTab 不能为空".to_string());

View File

@@ -191,6 +191,133 @@ async fn runtime_story_routes_resolve_through_rust_route_boundary() {
);
}
#[tokio::test]
async fn runtime_story_preview_snapshot_returns_transient_response_without_overwriting_save() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let formal_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main",
"runtimeActionVersion": 1,
"worldType": "WUXIA",
"playerCharacter": { "id": "hero" },
"currentScene": "Story",
"runtimeStats": { "playTimeMs": 0 },
"storyHistory": []
},
"currentStory": {
"text": "正式存档里的故事。",
"options": []
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(formal_response.status(), StatusCode::OK);
let preview_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"clientVersion": 3,
"action": {
"type": "story_choice",
"functionId": "idle_rest_focus"
},
"snapshot": {
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main",
"runtimeActionVersion": 3,
"runtimeMode": "preview",
"runtimePersistenceDisabled": true,
"playerHp": 10,
"playerMaxHp": 30,
"playerMana": 2,
"playerMaxMana": 12,
"storyHistory": []
},
"currentStory": {
"text": "幕预览里的临时故事。",
"options": []
}
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(preview_response.status(), StatusCode::OK);
let preview_payload: Value = serde_json::from_slice(
&preview_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
assert_eq!(
preview_payload["data"]["snapshot"]["gameState"]["runtimeMode"],
json!("preview")
);
let saved_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(saved_response.status(), StatusCode::OK);
let saved_payload: Value = serde_json::from_slice(
&saved_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
assert_eq!(
saved_payload["data"]["currentStory"]["text"],
json!("正式存档里的故事。")
);
assert!(saved_payload["data"]["gameState"]["runtimeMode"].is_null());
}
#[tokio::test]
async fn runtime_story_action_resolve_rejects_client_version_conflict() {
let state = seed_authenticated_state().await;