1
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user