后端重写提交

This commit is contained in:
2026-04-22 12:34:49 +08:00
parent cf8da3f50f
commit 997a8daada
438 changed files with 53355 additions and 865 deletions

View File

@@ -0,0 +1,593 @@
use axum::{
Json,
extract::{Extension, State},
http::StatusCode,
response::Response,
};
use serde_json::{Value, json};
use shared_contracts::runtime_story::{
RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel,
RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
pub async fn resolve_runtime_story_state(
State(_state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryStateResolveRequest>,
) -> Result<Json<Value>, Response> {
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
runtime_story_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"field": "sessionId",
"message": "sessionId 不能为空",
})),
)
})?;
let snapshot = payload.snapshot.ok_or_else(|| {
runtime_story_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "runtime-story",
"field": "snapshot",
"message": "当前首版兼容状态桥要求随请求提交 snapshot",
})),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_state_response(
&session_id,
payload.client_version,
snapshot,
),
))
}
fn build_runtime_story_state_response(
requested_session_id: &str,
client_version: Option<u32>,
snapshot: RuntimeStorySnapshotPayload,
) -> RuntimeStoryActionResponse {
let session_id = read_runtime_session_id(&snapshot.game_state)
.unwrap_or_else(|| requested_session_id.to_string());
let options = build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
let story_text =
read_story_text(snapshot.current_story.as_ref()).unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
let server_version =
read_u32_field(&snapshot.game_state, "runtimeActionVersion").or(client_version).unwrap_or(0);
RuntimeStoryActionResponse {
session_id,
server_version,
view_model: RuntimeStoryViewModel {
player: RuntimeStoryPlayerViewModel {
hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0),
max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1),
mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0),
max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1),
},
encounter: build_runtime_story_encounter(&snapshot.game_state),
companions: build_runtime_story_companions(&snapshot.game_state),
available_options: options.clone(),
status: RuntimeStoryStatusViewModel {
in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false),
npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive")
.unwrap_or(false),
current_npc_battle_mode: read_optional_string_field(
&snapshot.game_state,
"currentNpcBattleMode",
),
current_npc_battle_outcome: read_optional_string_field(
&snapshot.game_state,
"currentNpcBattleOutcome",
),
},
},
presentation: RuntimeStoryPresentation {
action_text: String::new(),
result_text: String::new(),
story_text,
options,
toast: None,
battle: None,
},
patches: Vec::new(),
snapshot,
}
}
fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
read_array_field(game_state, "companions")
.into_iter()
.filter_map(|entry| {
let npc_id = read_required_string_field(entry, "npcId")?;
Some(RuntimeStoryCompanionViewModel {
npc_id,
character_id: read_optional_string_field(entry, "characterId"),
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
})
})
.collect()
}
fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
let encounter = read_object_field(game_state, "currentEncounter")?;
let npc_name = read_required_string_field(encounter, "npcName")?;
let encounter_id = read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
Some(RuntimeStoryEncounterViewModel {
id: encounter_id,
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
npc_name,
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
})
}
fn resolve_current_encounter_npc_state<'a>(
game_state: &'a Value,
encounter_id: &str,
npc_name: &str,
) -> Option<&'a Value> {
let npc_states = read_object_field(game_state, "npcStates")?;
npc_states
.get(encounter_id)
.or_else(|| npc_states.get(npc_name))
}
fn build_runtime_story_options(
current_story: Option<&Value>,
game_state: &Value,
) -> Vec<RuntimeStoryOptionView> {
if let Some(story) = current_story {
let prefers_deferred = read_required_string_field(story, "displayMode")
.is_some_and(|value| value == "dialogue")
&& !read_array_field(story, "deferredOptions").is_empty();
let source = if prefers_deferred {
read_array_field(story, "deferredOptions")
} else {
read_array_field(story, "options")
};
let compiled = source
.into_iter()
.filter_map(build_runtime_story_option_from_story_option)
.collect::<Vec<_>>();
if !compiled.is_empty() {
return compiled;
}
}
build_fallback_runtime_story_options(game_state)
}
fn build_runtime_story_option_from_story_option(value: &Value) -> Option<RuntimeStoryOptionView> {
let function_id = read_required_string_field(value, "functionId")?;
let action_text = read_required_string_field(value, "actionText")
.or_else(|| read_required_string_field(value, "text"))
.unwrap_or_else(|| function_id.clone());
Some(RuntimeStoryOptionView {
scope: infer_option_scope(function_id.as_str()).to_string(),
detail_text: read_optional_string_field(value, "detailText"),
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
payload: read_field(value, "runtimePayload").cloned(),
disabled: read_bool_field(value, "disabled"),
reason: read_optional_string_field(value, "disabledReason")
.or_else(|| read_optional_string_field(value, "reason")),
function_id,
action_text,
})
}
fn build_runtime_story_option_interaction(
value: Option<&Value>,
) -> Option<RuntimeStoryOptionInteraction> {
let interaction = value?;
match read_required_string_field(interaction, "kind")?.as_str() {
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
npc_id: read_required_string_field(interaction, "npcId")?,
action: read_required_string_field(interaction, "action")?,
quest_id: read_optional_string_field(interaction, "questId"),
}),
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
action: read_required_string_field(interaction, "action")?,
}),
_ => None,
}
}
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
return vec![
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
];
}
let encounter = read_object_field(game_state, "currentEncounter");
if let Some(encounter) = encounter {
match read_required_string_field(encounter, "kind").as_deref() {
Some("npc") => {
let interaction_active =
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
if interaction_active {
return vec![
build_static_runtime_story_option("npc_chat", "继续交谈", "npc"),
build_static_runtime_story_option("npc_help", "请求援手", "npc"),
build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"),
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
];
}
return vec![
build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"),
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
];
}
Some("treasure") => {
return vec![
build_static_runtime_story_option("treasure_secure", "直接收取", "story"),
build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"),
build_static_runtime_story_option("treasure_leave", "先记下位置", "story"),
];
}
_ => {}
}
}
vec![
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
build_static_runtime_story_option("story_continue_adventure", "继续推进冒险", "story"),
]
}
fn build_static_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
function_id: function_id.to_string(),
action_text: action_text.to_string(),
detail_text: None,
scope: scope.to_string(),
interaction: None,
payload: None,
disabled: None,
reason: None,
}
}
fn infer_option_scope(function_id: &str) -> &'static str {
if function_id.starts_with("battle_") || function_id == "inventory_use" {
"combat"
} else if function_id.starts_with("npc_") {
"npc"
} else {
"story"
}
}
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
current_story.and_then(|story| read_optional_string_field(story, "text"))
}
fn build_fallback_story_text(game_state: &Value) -> String {
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
let encounter_name = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
.unwrap_or_else(|| "眼前的敌人".to_string());
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
}
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
{
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
}
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
}
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
read_optional_string_field(game_state, "runtimeSessionId")
}
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
value.as_object()?.get(key)
}
fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let field = read_field(value, key)?;
field.is_object().then_some(field)
}
fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
read_field(value, key)
.and_then(Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
}
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
normalize_required_string(read_field(value, key)?.as_str()?)
}
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
}
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
read_field(value, key).and_then(Value::as_bool)
}
fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
read_field(value, key)
.and_then(Value::as_i64)
.and_then(|number| i32::try_from(number).ok())
}
fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
read_field(value, key)
.and_then(Value::as_u64)
.and_then(|number| u32::try_from(number).ok())
}
fn normalize_required_string(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(normalize_required_string)
}
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn runtime_story_state_resolve_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/state/resolve")
.header("content-type", "application/json")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"snapshot": {
"savedAt": "2026-04-22T12:00:00.000Z",
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main"
},
"currentStory": null
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn runtime_story_state_resolve_rejects_missing_snapshot() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/state/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn runtime_story_state_resolve_returns_compiled_snapshot_response() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/state/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": 7,
"snapshot": {
"savedAt": "2026-04-22T12:00:00.000Z",
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main",
"runtimeActionVersion": 7,
"playerHp": 32,
"playerMaxHp": 40,
"playerMana": 18,
"playerMaxMana": 20,
"inBattle": false,
"npcInteractionActive": true,
"currentEncounter": {
"id": "npc_camp_firekeeper",
"kind": "npc",
"npcName": "守火人",
"hostile": false
},
"npcStates": {
"npc_camp_firekeeper": {
"affinity": 12,
"recruited": false
}
},
"companions": [{
"npcId": "npc_companion_001",
"characterId": "char_companion_001",
"joinedAtAffinity": 64
}]
},
"currentStory": {
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
"displayMode": "dialogue",
"options": [{
"functionId": "story_continue_adventure",
"actionText": "继续冒险"
}],
"deferredOptions": [{
"functionId": "npc_chat",
"actionText": "继续交谈",
"detailText": "围绕当前话题继续推进关系判断。",
"interaction": {
"kind": "npc",
"npcId": "npc_camp_firekeeper",
"action": "chat"
},
"runtimePayload": {
"note": "server-runtime-test"
}
}]
}
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(payload["data"]["sessionId"], json!("runtime-main"));
assert_eq!(payload["data"]["serverVersion"], json!(7));
assert_eq!(
payload["data"]["viewModel"]["encounter"]["npcName"],
json!("守火人")
);
assert_eq!(
payload["data"]["viewModel"]["availableOptions"][0]["functionId"],
json!("npc_chat")
);
assert_eq!(
payload["data"]["presentation"]["options"][0]["interaction"]["npcId"],
json!("npc_camp_firekeeper")
);
assert_eq!(
payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"],
json!("npc_chat")
);
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(AppConfig::default()).expect("state should build");
state
.password_entry_service()
.execute(module_auth::PasswordEntryInput {
username: "runtime_story_state_user".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed");
state
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: "sess_runtime_story_state".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 1,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("运行时剧情状态用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}