This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -11,35 +11,38 @@ use module_npc::{
use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros};
use module_runtime_story_compat::{
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
add_player_currency, add_player_inventory_items, append_story_history,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, RuntimeStoryPromptContextExtras,
StoryResolution, add_player_currency, add_player_inventory_items, append_story_history,
apply_equipment_loadout_to_state, battle_mode_text, build_battle_runtime_story_options,
build_current_build_toast, build_disabled_runtime_story_option, build_npc_gift_result_text,
build_runtime_story_option_from_story_option, build_runtime_story_view_model,
build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option,
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
current_encounter_id, current_encounter_name, current_world_type,
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
find_player_inventory_entry, format_currency_text, format_now_rfc3339,
grant_player_progression_experience, has_giftable_player_inventory, increment_runtime_stat,
normalize_equipment_slot_id, normalize_equipped_item, normalize_required_string,
npc_buyback_price, npc_purchase_price, read_array_field, read_bool_field, read_field,
build_runtime_story_option_from_story_option, build_runtime_story_prompt_context,
build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch,
build_story_option_from_runtime_option, clear_encounter_only, clear_encounter_state,
clone_inventory_item_with_quantity, current_encounter_id, current_encounter_name,
current_world_type, ensure_inventory_action_available, ensure_json_object,
equipment_slot_label, finalize_post_battle_resolution, find_player_inventory_entry,
format_currency_text, format_now_rfc3339, grant_player_progression_experience,
has_giftable_player_inventory, increment_runtime_stat, normalize_equipment_slot_id,
normalize_equipped_item, normalize_required_string, npc_buyback_price, npc_purchase_price,
project_story_engine_after_action, read_array_field, read_bool_field, read_field,
read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field,
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
read_u32_field, recruit_companion_to_party, remove_player_inventory_item, resolve_action_text,
resolve_battle_action, resolve_current_encounter_npc_state, resolve_equipment_slot_for_item,
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution,
trade_quantity_suffix, write_bool_field, write_i32_field, write_null_field,
write_player_equipment_item, write_string_field, write_u32_field,
resolve_npc_gift_affinity_gain, resolve_post_battle_story_options, restore_player_resource,
simple_story_resolution, trade_quantity_suffix, write_bool_field, write_i32_field,
write_null_field, write_player_equipment_item, write_runtime_npc_interaction_view,
write_string_field, write_u32_field,
};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map, Value, json};
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryBootstrapRequest,
RuntimeStoryBootstrapResponse, RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
RuntimeStoryPatch, RuntimeStoryPresentation, RuntimeStorySnapshotPayload,
RuntimeStoryStateResolveRequest,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
@@ -51,12 +54,14 @@ use crate::{
};
mod ai;
mod bootstrap;
mod equipment_actions;
mod game_state;
mod npc_actions;
mod presentation;
mod quest_actions;
pub use self::bootstrap::begin_runtime_story_session;
use self::{
ai::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
};
@@ -184,6 +189,7 @@ pub async fn resolve_runtime_story_action(
"运行时版本已变化,请先同步最新快照后再提交动作",
)?;
let previous_game_state = snapshot.game_state.clone();
let current_story_before = snapshot.current_story.clone();
let mut game_state = snapshot.game_state.clone();
let mut resolution = resolve_runtime_story_choice_action(
@@ -229,17 +235,26 @@ pub async fn resolve_runtime_story_action(
.saved_current_story
.take()
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
if let Some(generated_payload) = generate_action_story_payload(
&state,
&game_state,
&payload,
&function_id,
resolution.action_text.as_str(),
resolution.result_text.as_str(),
&options,
let post_battle_finalized = finalize_runtime_story_resolution_for_response(
&mut game_state,
&mut story_text,
&mut history_result_text,
&mut options,
&mut saved_current_story,
resolution.battle.as_ref(),
)
.await
);
if !post_battle_finalized
&& let Some(generated_payload) = generate_action_story_payload(
&state,
&game_state,
&payload,
&function_id,
resolution.action_text.as_str(),
resolution.result_text.as_str(),
&options,
resolution.battle.as_ref(),
)
.await
{
story_text = generated_payload.story_text;
history_result_text = generated_payload.history_result_text;
@@ -251,6 +266,17 @@ pub async fn resolve_runtime_story_action(
resolution.action_text.as_str(),
history_result_text.as_str(),
);
project_story_engine_after_action(
&previous_game_state,
&mut game_state,
resolution.action_text.as_str(),
history_result_text.as_str(),
function_id.as_str(),
resolution
.battle
.as_ref()
.and_then(|battle| battle.outcome.as_deref()),
);
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
action_text: resolution.action_text.clone(),
@@ -290,9 +316,18 @@ pub async fn resolve_runtime_story_action(
pub async fn generate_runtime_story_initial(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryAiRequest>,
) -> Result<Json<Value>, Response> {
let payload = hydrate_runtime_story_ai_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
payload,
true,
)
.await?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_ai_response(&state, payload, true).await,
@@ -302,15 +337,97 @@ pub async fn generate_runtime_story_initial(
pub async fn generate_runtime_story_continue(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RuntimeStoryAiRequest>,
) -> Result<Json<Value>, Response> {
let payload = hydrate_runtime_story_ai_request_from_session(
&state,
&request_context,
authenticated.claims().user_id().to_string(),
payload,
false,
)
.await?;
Ok(json_success_body(
Some(&request_context),
build_runtime_story_ai_response(&state, payload, false).await,
))
}
async fn hydrate_runtime_story_ai_request_from_session(
state: &AppState,
request_context: &RequestContext,
user_id: String,
mut payload: RuntimeStoryAiRequest,
initial: bool,
) -> Result<RuntimeStoryAiRequest, Response> {
let Some(session_id) = payload
.session_id
.as_deref()
.and_then(normalize_required_string)
else {
// 中文注释:旧测试或兼容入口可能仍传 worldType/character/context
// 没有 sessionId 时只保留反序列化兼容,不作为新主链。
return Ok(payload);
};
let snapshot = resolve_snapshot_for_request(state, request_context, user_id, None).await?;
validate_client_version(
request_context,
payload.client_version,
&snapshot.game_state,
"运行时版本已变化,请先同步最新快照后再生成剧情",
)?;
let snapshot_session_id =
read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.clone());
if snapshot_session_id != session_id {
return Err(runtime_story_error_response(
request_context,
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
"provider": "runtime-story",
"message": "请求的运行时会话与服务端快照不一致,请重新进入游戏",
"sessionId": session_id,
"snapshotSessionId": snapshot_session_id,
})),
));
}
let extras = RuntimeStoryPromptContextExtras {
pending_scene_encounter: false,
last_function_id: payload.last_function_id.clone(),
observe_signs_requested: payload.observe_signs_requested,
recent_action_result: payload.recent_action_result.clone(),
opening_camp_background: None,
opening_camp_dialogue: None,
};
payload.world_type = current_world_type(&snapshot.game_state).unwrap_or_default();
payload.character = read_field(&snapshot.game_state, "playerCharacter")
.cloned()
.unwrap_or(Value::Null);
payload.monsters = read_array_field(&snapshot.game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect();
payload.history = if initial {
Vec::new()
} else {
read_array_field(&snapshot.game_state, "storyHistory")
.into_iter()
.rev()
.take(12)
.collect::<Vec<_>>()
.into_iter()
.rev()
.cloned()
.collect()
};
payload.context = build_runtime_story_prompt_context(&snapshot.game_state, extras);
Ok(payload)
}
async fn resolve_snapshot_for_request(
state: &AppState,
request_context: &RequestContext,
@@ -380,22 +497,24 @@ async fn persist_runtime_story_snapshot(
let updated_at_micros = offset_datetime_to_unix_micros(now);
if is_non_persistent_runtime_story_snapshot(&snapshot) {
let game_state = canonicalize_runtime_story_game_state_for_persistence(snapshot.game_state);
return Ok(build_transient_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
game_state,
snapshot.current_story,
updated_at_micros,
));
}
let game_state = canonicalize_runtime_story_game_state_for_persistence(snapshot.game_state);
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
game_state,
snapshot.current_story,
updated_at_micros,
)
@@ -405,6 +524,39 @@ async fn persist_runtime_story_snapshot(
})
}
fn canonicalize_runtime_story_game_state_for_persistence(mut game_state: Value) -> Value {
if let Some(root) = game_state.as_object_mut() {
// 中文注释NPC 交易/赠礼 view 是响应时派生的展示层数据,不能写回正式快照真相。
root.remove("runtimeNpcInteraction");
}
game_state
}
fn finalize_runtime_story_resolution_for_response(
game_state: &mut Value,
story_text: &mut String,
history_result_text: &mut String,
options: &mut Vec<RuntimeStoryOptionView>,
saved_current_story: &mut Value,
battle: Option<&RuntimeBattlePresentation>,
) -> bool {
let battle_outcome = battle.and_then(|battle| battle.outcome.as_deref());
let post_battle_options = resolve_post_battle_story_options(game_state);
if let Some(post_battle) = finalize_post_battle_resolution(
game_state,
story_text.as_str(),
battle_outcome,
post_battle_options,
) {
*story_text = post_battle.story_text;
*history_result_text = story_text.clone();
*options = post_battle.presentation_options;
*saved_current_story = post_battle.saved_current_story;
return true;
}
false
}
fn build_transient_runtime_snapshot_record(
user_id: String,
saved_at_micros: i64,
@@ -472,10 +624,13 @@ fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(
fn runtime_snapshot_payload_from_record(
record: &RuntimeSnapshotRecord,
) -> RuntimeStorySnapshotPayload {
let mut game_state = record.game_state.clone();
write_runtime_npc_interaction_view(&mut game_state);
RuntimeStorySnapshotPayload {
saved_at: Some(record.saved_at.clone()),
bottom_tab: record.bottom_tab.clone(),
game_state: record.game_state.clone(),
game_state,
current_story: record.current_story.clone(),
}
}
@@ -562,23 +717,7 @@ fn resolve_runtime_story_choice_action(
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
))
}
"idle_travel_next_scene" => {
clear_encounter_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
Ok(StoryResolution {
action_text: resolve_action_text("前往相邻场景", request),
result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: None,
toast: None,
})
}
"idle_travel_next_scene" => resolve_idle_travel_next_scene_action(game_state, request),
"npc_preview_talk" => resolve_npc_preview_action(game_state, request),
"npc_chat" => resolve_npc_chat_action(game_state, request),
"npc_help" => resolve_npc_help_action(game_state, request),
@@ -662,6 +801,122 @@ fn resolve_continue_adventure_action(
})
}
fn resolve_idle_travel_next_scene_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
) -> Result<StoryResolution, String> {
let previous_scene_name = read_object_field(game_state, "currentScenePreset")
.and_then(|scene| read_optional_string_field(scene, "name"))
.unwrap_or_else(|| "当前位置".to_string());
let target_scene = resolve_next_scene_preset(game_state);
let target_scene_name = target_scene
.as_ref()
.and_then(|scene| read_optional_string_field(scene, "name"))
.unwrap_or_else(|| "相邻场景".to_string());
if let Some(scene) = target_scene {
ensure_json_object(game_state).insert("currentScenePreset".to_string(), scene);
}
clear_encounter_state(game_state);
increment_runtime_stat(game_state, "scenesTraveled", 1);
write_i32_field(game_state, "playerX", 0);
write_i32_field(game_state, "playerOffsetY", 0);
write_string_field(game_state, "playerFacing", "right");
write_string_field(game_state, "animationState", "idle");
write_string_field(game_state, "playerActionMode", "idle");
write_bool_field(game_state, "scrollWorld", false);
write_null_field(game_state, "lastObserveSignsSceneId");
write_null_field(game_state, "lastObserveSignsReport");
write_null_field(game_state, "currentBattleNpcId");
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "sparReturnEncounter");
write_null_field(game_state, "sparPlayerHpBefore");
write_null_field(game_state, "sparPlayerMaxHpBefore");
write_null_field(game_state, "sparStoryHistoryBefore");
ensure_json_object(game_state).insert("activeCombatEffects".to_string(), Value::Array(vec![]));
ensure_scene_encounter_preview(game_state);
Ok(StoryResolution {
action_text: resolve_action_text(&format!("前往{target_scene_name}"), request),
result_text: format!("你离开{previous_scene_name},前往{target_scene_name}。"),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged {
encounter_id: read_object_field(game_state, "currentEncounter")
.and_then(|encounter| read_optional_string_field(encounter, "id")),
},
],
battle: None,
toast: None,
})
}
fn resolve_next_scene_preset(game_state: &Value) -> Option<Value> {
let current_scene = read_object_field(game_state, "currentScenePreset")?;
let current_scene_id = read_optional_string_field(current_scene, "id");
let target_scene_id =
read_optional_string_field(current_scene, "forwardSceneId").or_else(|| {
read_array_field(current_scene, "connections")
.into_iter()
.find_map(|connection| {
read_optional_string_field(connection, "sceneId")
.filter(|scene_id| Some(scene_id) != current_scene_id.as_ref())
})
})?;
find_scene_preset_in_runtime_profile(game_state, target_scene_id.as_str()).or_else(|| {
let mut scene = json!({
"id": target_scene_id,
"name": "相邻场景",
"description": "你抵达了一处新的区域,周围的动静仍在继续变化。",
"imageSrc": "",
"connectedSceneIds": [current_scene_id.unwrap_or_else(|| "previous-scene".to_string())],
"connections": [],
"treasureHints": [],
"npcs": []
});
if let Some(world_type) = current_world_type(game_state) {
ensure_json_object(&mut scene)
.insert("worldType".to_string(), Value::String(world_type));
}
Some(scene)
})
}
fn find_scene_preset_in_runtime_profile(game_state: &Value, scene_id: &str) -> Option<Value> {
let profile = read_object_field(game_state, "customWorldProfile")?;
bootstrap::build_custom_scene_preset(
profile,
bootstrap::resolve_custom_runtime_scene_id(profile, scene_id).as_str(),
)
}
fn ensure_scene_encounter_preview(game_state: &mut Value) {
if read_bool_field(game_state, "inBattle").unwrap_or(false)
|| !read_array_field(game_state, "sceneHostileNpcs").is_empty()
|| read_object_field(game_state, "currentEncounter").is_some()
{
return;
}
let Some(scene) = read_object_field(game_state, "currentScenePreset") else {
return;
};
let Some(npc) = read_array_field(scene, "npcs").into_iter().find(|npc| {
!read_bool_field(npc, "hostile").unwrap_or(false)
&& read_optional_string_field(npc, "monsterPresetId").is_none()
}) else {
return;
};
let encounter = bootstrap::build_encounter_from_scene_npc(npc);
ensure_json_object(game_state).insert("currentEncounter".to_string(), encounter);
write_bool_field(game_state, "npcInteractionActive", false);
}
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),

File diff suppressed because it is too large Load Diff

View File

@@ -110,11 +110,23 @@ pub(super) fn resolve_npc_battle_entry_action(
} else {
"fight"
};
let return_encounter = read_object_field(game_state, "currentEncounter").cloned();
let resolved_formation =
resolve_npc_battle_formation(game_state, return_encounter.as_ref(), battle_mode);
write_bool_field(game_state, "inBattle", true);
write_bool_field(game_state, "npcInteractionActive", false);
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
write_null_field(game_state, "currentNpcBattleOutcome");
write_null_field(game_state, "currentEncounter");
ensure_json_object(game_state).insert(
"sceneHostileNpcs".to_string(),
Value::Array(resolved_formation),
);
if let Some(return_encounter) = return_encounter {
ensure_json_object(game_state).insert("sparReturnEncounter".to_string(), return_encounter);
}
Ok(StoryResolution {
action_text: resolve_action_text(
@@ -144,6 +156,117 @@ pub(super) fn resolve_npc_battle_entry_action(
})
}
fn resolve_npc_battle_formation(
game_state: &Value,
encounter: Option<&Value>,
battle_mode: &str,
) -> Vec<Value> {
let visible_formation = read_array_field(game_state, "sceneHostileNpcs")
.into_iter()
.cloned()
.collect::<Vec<_>>();
if !visible_formation.is_empty() {
return visible_formation
.into_iter()
.map(|monster| normalize_npc_battle_monster(monster, battle_mode))
.collect();
}
encounter
.map(|encounter| {
vec![build_npc_battle_monster_from_encounter(
game_state,
encounter,
battle_mode,
3.2,
0,
)]
})
.unwrap_or_default()
}
fn normalize_npc_battle_monster(mut monster: Value, battle_mode: &str) -> Value {
let Some(monster_object) = monster.as_object_mut() else {
return monster;
};
monster_object
.entry("animation".to_string())
.or_insert_with(|| Value::String("idle".to_string()));
monster_object
.entry("facing".to_string())
.or_insert_with(|| Value::String("left".to_string()));
monster_object
.entry("renderKind".to_string())
.or_insert_with(|| Value::String("npc".to_string()));
monster_object
.entry("attackRange".to_string())
.or_insert_with(|| json!(1.8));
monster_object
.entry("speed".to_string())
.or_insert_with(|| json!(7));
let max_hp = monster_object
.get("maxHp")
.and_then(Value::as_i64)
.unwrap_or_else(|| if battle_mode == "spar" { 10 } else { 80 });
monster_object
.entry("hp".to_string())
.or_insert_with(|| json!(max_hp));
monster
}
fn build_npc_battle_monster_from_encounter(
game_state: &Value,
encounter: &Value,
battle_mode: &str,
x_meters: f64,
y_offset: i32,
) -> Value {
let npc_id = read_optional_string_field(encounter, "id")
.unwrap_or_else(|| current_encounter_name(game_state));
let npc_name = current_encounter_name(game_state);
let npc_state =
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str());
let affinity = npc_state
.and_then(|state| read_i32_field(state, "affinity"))
.or_else(|| read_i32_field(encounter, "initialAffinity"))
.unwrap_or(0);
let base_hp = if battle_mode == "spar" {
10
} else {
(80 + affinity).max(24)
};
let monster_id = read_optional_string_field(encounter, "monsterPresetId")
.unwrap_or_else(|| format!("npc-opponent-{npc_id}"));
let mut battle_encounter = encounter.clone();
if let Some(entry) = battle_encounter.as_object_mut() {
entry.insert("hostile".to_string(), Value::Bool(true));
entry.insert("xMeters".to_string(), json!(x_meters));
}
json!({
"id": monster_id,
"name": npc_name,
"action": if battle_mode == "spar" {
"抱拳行礼,准备点到为止地切磋武艺"
} else {
"摆开架势,随时准备出手"
},
"description": read_optional_string_field(encounter, "npcDescription").unwrap_or_default(),
"animation": "idle",
"xMeters": x_meters,
"yOffset": y_offset,
"facing": "left",
"attackRange": 1.8,
"speed": 7,
"hp": base_hp,
"maxHp": base_hp,
"renderKind": "npc",
"levelProfile": read_field(encounter, "levelProfile").cloned(),
"experienceReward": read_i32_field(encounter, "experienceReward").unwrap_or(0),
"encounter": battle_encounter
})
}
pub(super) fn resolve_npc_recruit_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
@@ -232,8 +355,10 @@ pub(super) fn resolve_npc_trade_action(
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
let quantity = payload
.and_then(|value| read_i32_field(value, "quantity"))
.unwrap_or(1)
.max(1);
.unwrap_or(1);
if quantity <= 0 {
return Err("npc_trade.quantity 必须大于 0".to_string());
}
if mode == "buy" {
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())

View File

@@ -6,6 +6,7 @@ pub(super) fn build_runtime_story_state_response(
mut snapshot: RuntimeStorySnapshotPayload,
) -> RuntimeStoryActionResponse {
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
write_runtime_npc_interaction_view(&mut snapshot.game_state);
let session_id = read_runtime_session_id(&snapshot.game_state)
.unwrap_or_else(|| requested_session_id.to_string());
let options =

File diff suppressed because it is too large Load Diff