1
This commit is contained in:
@@ -23,7 +23,7 @@ use crate::{
|
||||
},
|
||||
assets::{
|
||||
bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket,
|
||||
create_sts_upload_credentials, get_asset_read_url,
|
||||
create_sts_upload_credentials, get_asset_history, get_asset_read_url,
|
||||
},
|
||||
auth::{
|
||||
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
|
||||
@@ -394,6 +394,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/assets/history",
|
||||
get(get_asset_history).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
get(get_runtime_settings)
|
||||
|
||||
@@ -14,10 +14,11 @@ use platform_oss::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
AssetBindingPayload, AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest,
|
||||
BindAssetObjectResponse, ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest,
|
||||
ConfirmAssetObjectResponse, CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse,
|
||||
DirectUploadTicketPayload, GetAssetReadUrlResponse, GetReadUrlQuery,
|
||||
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
|
||||
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
|
||||
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
|
||||
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
|
||||
GetAssetReadUrlResponse, GetReadUrlQuery,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
@@ -111,6 +112,51 @@ pub async fn get_asset_read_url(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_asset_history(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Query(query): Query<AssetHistoryQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let asset_kind = query.kind.trim().to_string();
|
||||
if asset_kind != "character_visual" && asset_kind != "scene_image" {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"field": "kind",
|
||||
"message": "历史素材类型只支持 character_visual 或 scene_image",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let entries = state
|
||||
.spacetime_client()
|
||||
.list_asset_history(module_assets::AssetHistoryListInput {
|
||||
asset_kind,
|
||||
limit: query.limit.unwrap_or(120).clamp(1, 120),
|
||||
})
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AssetHistoryListResponse {
|
||||
assets: entries
|
||||
.into_iter()
|
||||
.map(|entry| AssetHistoryEntryPayload {
|
||||
owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()),
|
||||
asset_object_id: entry.asset_object_id,
|
||||
asset_kind: entry.asset_kind,
|
||||
image_src: entry.image_src,
|
||||
owner_user_id: entry.owner_user_id,
|
||||
profile_id: entry.profile_id,
|
||||
entity_id: entry.entity_id,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_sts_upload_credentials(
|
||||
Extension(_request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
@@ -232,6 +278,16 @@ fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
|
||||
.map(|value| value.trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
|
||||
let Some(owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return "未记录账号".to_string();
|
||||
};
|
||||
format!("账号 {owner_user_id}")
|
||||
}
|
||||
|
||||
async fn build_confirm_asset_object_upsert_input(
|
||||
oss_client: &platform_oss::OssClient,
|
||||
payload: ConfirmAssetObjectRequest,
|
||||
|
||||
@@ -189,7 +189,9 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
|
||||
|
||||
fn allows_internal_forwarded_auth(path: &str) -> bool {
|
||||
// Node 代理已经完成平台账号 JWT 校验,Rust 运行时只信任这些明确的内部转发路径。
|
||||
path.starts_with("/api/runtime/big-fish/") || path.starts_with("/api/runtime/puzzle/")
|
||||
path.starts_with("/api/runtime/big-fish/")
|
||||
|| path.starts_with("/api/runtime/chat/")
|
||||
|| path.starts_with("/api/runtime/puzzle/")
|
||||
}
|
||||
|
||||
fn try_build_internal_forwarded_claims(
|
||||
@@ -282,6 +284,9 @@ mod tests {
|
||||
assert!(allows_internal_forwarded_auth(
|
||||
"/api/runtime/big-fish/sessions"
|
||||
));
|
||||
assert!(allows_internal_forwarded_auth(
|
||||
"/api/runtime/chat/npc/turn/stream"
|
||||
));
|
||||
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
|
||||
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
.await?;
|
||||
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
|
||||
|
||||
let landmarks = generate_foundation_landmark_seed_entries(
|
||||
let generated_scene_entries = generate_foundation_landmark_seed_entries(
|
||||
llm_client,
|
||||
&framework,
|
||||
FOUNDATION_DRAFT_LANDMARK_COUNT,
|
||||
@@ -83,7 +83,7 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
framework["landmarks"] = JsonValue::Array(landmarks.clone());
|
||||
framework["landmarks"] = JsonValue::Array(generated_scene_entries.clone());
|
||||
|
||||
let playable_narrative = expand_foundation_role_entries(
|
||||
llm_client,
|
||||
@@ -137,7 +137,7 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
framework,
|
||||
playable_detailed,
|
||||
story_detailed,
|
||||
landmarks,
|
||||
generated_scene_entries,
|
||||
session,
|
||||
setting_text.as_str(),
|
||||
);
|
||||
@@ -304,6 +304,7 @@ async fn generate_foundation_landmark_seed_entries(
|
||||
}
|
||||
let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE);
|
||||
let forbidden_names = names_from_entries(&merged_entries);
|
||||
let is_opening_batch = batch_index == 0 && merged_entries.is_empty();
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
"生成关键场景",
|
||||
@@ -319,13 +320,19 @@ async fn generate_foundation_landmark_seed_entries(
|
||||
);
|
||||
let raw = request_foundation_json_stage(
|
||||
llm_client,
|
||||
build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names),
|
||||
build_custom_world_landmark_seed_batch_prompt(
|
||||
framework,
|
||||
batch_count,
|
||||
&forbidden_names,
|
||||
is_opening_batch,
|
||||
),
|
||||
format!("agent-foundation-landmark-seed-batch-{}", batch_index + 1).as_str(),
|
||||
|response_text| {
|
||||
build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
response_text,
|
||||
batch_count,
|
||||
&forbidden_names,
|
||||
is_opening_batch,
|
||||
)
|
||||
},
|
||||
format!(
|
||||
@@ -714,7 +721,7 @@ fn build_foundation_draft_profile_from_framework(
|
||||
framework: JsonValue,
|
||||
playable_detailed: Vec<JsonValue>,
|
||||
story_detailed: Vec<JsonValue>,
|
||||
landmarks: Vec<JsonValue>,
|
||||
generated_scene_entries: Vec<JsonValue>,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
setting_text: &str,
|
||||
) -> JsonMap<String, JsonValue> {
|
||||
@@ -793,9 +800,14 @@ fn build_foundation_draft_profile_from_framework(
|
||||
setting_text,
|
||||
),
|
||||
);
|
||||
let camp = framework.get("camp").cloned().unwrap_or_else(
|
||||
let fallback_camp = framework.get("camp").cloned().unwrap_or_else(
|
||||
|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
|
||||
);
|
||||
let playable_detailed = assign_role_ids(playable_detailed, "playable-npc");
|
||||
let story_detailed = assign_role_ids(story_detailed, "story-npc");
|
||||
let scene_role_refs = collect_scene_role_refs(&story_detailed);
|
||||
let (camp, landmarks) =
|
||||
split_generated_scenes_into_camp_and_landmarks(fallback_camp, generated_scene_entries);
|
||||
object.insert("camp".to_string(), camp.clone());
|
||||
object.insert(
|
||||
"playableNpcs".to_string(),
|
||||
@@ -803,7 +815,7 @@ fn build_foundation_draft_profile_from_framework(
|
||||
);
|
||||
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
|
||||
let scene_chapter_blueprints =
|
||||
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks);
|
||||
build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks, &scene_role_refs);
|
||||
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
|
||||
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
|
||||
object.insert(
|
||||
@@ -1095,9 +1107,50 @@ fn stable_ascii_slug(value: &str) -> String {
|
||||
format!("{hash:08x}")
|
||||
}
|
||||
|
||||
fn split_generated_scenes_into_camp_and_landmarks(
|
||||
fallback_camp: JsonValue,
|
||||
generated_scene_entries: Vec<JsonValue>,
|
||||
) -> (JsonValue, Vec<JsonValue>) {
|
||||
let mut entries = generated_scene_entries.into_iter();
|
||||
let opening_scene = entries.next().unwrap_or(fallback_camp);
|
||||
let camp = normalize_generated_opening_scene(opening_scene);
|
||||
let landmarks = entries.collect::<Vec<_>>();
|
||||
(camp, landmarks)
|
||||
}
|
||||
|
||||
fn normalize_generated_opening_scene(scene: JsonValue) -> JsonValue {
|
||||
let mut object = scene.as_object().cloned().unwrap_or_default();
|
||||
let name = object
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("开局归处")
|
||||
.to_string();
|
||||
let description = object
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("玩家进入世界后的第一处落脚点。")
|
||||
.to_string();
|
||||
object.insert("id".to_string(), JsonValue::String("camp-1".to_string()));
|
||||
object.insert("kind".to_string(), JsonValue::String("camp".to_string()));
|
||||
object.insert("name".to_string(), JsonValue::String(name));
|
||||
object.insert("description".to_string(), JsonValue::String(description));
|
||||
JsonValue::Object(object)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct SceneRoleRef {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn build_scene_chapter_blueprints_from_camp_and_landmarks(
|
||||
camp: &JsonValue,
|
||||
landmarks: &[JsonValue],
|
||||
scene_role_refs: &[SceneRoleRef],
|
||||
) -> Vec<JsonValue> {
|
||||
let mut blueprints = Vec::with_capacity(landmarks.len() + 1);
|
||||
blueprints.push(build_scene_chapter_blueprint_from_scene(
|
||||
@@ -1105,12 +1158,19 @@ fn build_scene_chapter_blueprints_from_camp_and_landmarks(
|
||||
0,
|
||||
"camp",
|
||||
"开局归处",
|
||||
scene_role_refs,
|
||||
));
|
||||
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(
|
||||
landmarks,
|
||||
scene_role_refs,
|
||||
));
|
||||
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(landmarks));
|
||||
blueprints
|
||||
}
|
||||
|
||||
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
|
||||
fn build_scene_chapter_blueprints_from_landmarks(
|
||||
landmarks: &[JsonValue],
|
||||
scene_role_refs: &[SceneRoleRef],
|
||||
) -> Vec<JsonValue> {
|
||||
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
|
||||
landmarks
|
||||
.iter()
|
||||
@@ -1121,6 +1181,7 @@ fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec
|
||||
chapter_index,
|
||||
"saved-landmark",
|
||||
"关键场景",
|
||||
scene_role_refs,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -1131,6 +1192,7 @@ fn build_scene_chapter_blueprint_from_scene(
|
||||
chapter_index: usize,
|
||||
id_prefix: &str,
|
||||
fallback_name_prefix: &str,
|
||||
scene_role_refs: &[SceneRoleRef],
|
||||
) -> JsonValue {
|
||||
let scene_name = json_text(scene, "name")
|
||||
.unwrap_or_else(|| format!("{}{}", fallback_name_prefix, chapter_index + 1));
|
||||
@@ -1144,6 +1206,13 @@ fn build_scene_chapter_blueprint_from_scene(
|
||||
let act_npc_names = json_string_array(scene, "actNPCNames")
|
||||
.or_else(|| json_string_array(scene, "sceneNpcNames"))
|
||||
.unwrap_or_default();
|
||||
let resolved_act_roles = resolve_scene_act_roles(&act_npc_names, scene_role_refs);
|
||||
let scene_npc_ids = dedupe_text_values(
|
||||
&resolved_act_roles
|
||||
.iter()
|
||||
.map(|role| role.id.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
json!({
|
||||
"id": scene_id.clone(),
|
||||
@@ -1152,13 +1221,14 @@ fn build_scene_chapter_blueprint_from_scene(
|
||||
"summary": summary,
|
||||
"sceneTaskDescription": scene_task_description,
|
||||
"linkedLandmarkIds": [scene_id.clone()],
|
||||
"sceneNpcIds": scene_npc_ids,
|
||||
"acts": (0..3)
|
||||
.map(|act_index| build_scene_act_blueprint_from_landmark(
|
||||
&scene_id,
|
||||
&summary,
|
||||
&act_prompts,
|
||||
&act_events,
|
||||
&act_npc_names,
|
||||
&resolved_act_roles,
|
||||
act_index,
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -1170,7 +1240,7 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
scene_summary: &str,
|
||||
act_prompts: &[String],
|
||||
act_events: &[String],
|
||||
act_npc_names: &[String],
|
||||
act_roles: &[SceneRoleRef],
|
||||
act_index: usize,
|
||||
) -> JsonValue {
|
||||
let act_title = if act_index == 0 {
|
||||
@@ -1184,10 +1254,17 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let opposite_npc_id = act_npc_names
|
||||
let opposite_role = act_roles
|
||||
.get(act_index)
|
||||
.or_else(|| act_npc_names.first())
|
||||
.cloned()
|
||||
.or_else(|| act_roles.first())
|
||||
.cloned();
|
||||
let opposite_npc_id = opposite_role
|
||||
.as_ref()
|
||||
.map(|role| role.id.clone())
|
||||
.unwrap_or_default();
|
||||
let opposite_role_name = opposite_role
|
||||
.as_ref()
|
||||
.map(|role| role.name.clone())
|
||||
.unwrap_or_default();
|
||||
let event_description = act_events
|
||||
.get(act_index)
|
||||
@@ -1196,12 +1273,16 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
build_default_act_event_description(scene_summary, opposite_npc_id.as_str(), act_index)
|
||||
build_default_act_event_description(
|
||||
scene_summary,
|
||||
opposite_role_name.as_str(),
|
||||
act_index,
|
||||
)
|
||||
});
|
||||
let background_prompt = prompt.unwrap_or_else(|| {
|
||||
build_default_act_background_prompt(
|
||||
scene_summary,
|
||||
opposite_npc_id.as_str(),
|
||||
opposite_role_name.as_str(),
|
||||
event_description.as_str(),
|
||||
act_index,
|
||||
)
|
||||
@@ -1212,21 +1293,23 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
"title": act_title,
|
||||
"summary": scene_summary,
|
||||
"backgroundPromptText": background_prompt,
|
||||
"encounterNpcIds": build_act_encounter_npc_ids(act_npc_names, opposite_npc_id.as_str()),
|
||||
"encounterNpcIds": build_act_encounter_npc_ids(act_roles, opposite_npc_id.as_str()),
|
||||
"primaryNpcId": opposite_npc_id,
|
||||
"oppositeNpcId": opposite_npc_id,
|
||||
"primaryRoleName": opposite_role_name,
|
||||
"oppositeRoleName": opposite_role_name,
|
||||
"eventDescription": event_description,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_act_encounter_npc_ids(act_npc_names: &[String], primary_npc_id: &str) -> Vec<String> {
|
||||
let mut names = Vec::with_capacity(act_npc_names.len().max(1));
|
||||
fn build_act_encounter_npc_ids(act_roles: &[SceneRoleRef], primary_npc_id: &str) -> Vec<String> {
|
||||
let mut names = Vec::with_capacity(act_roles.len().max(1));
|
||||
let primary = primary_npc_id.trim();
|
||||
if !primary.is_empty() {
|
||||
names.push(primary.to_string());
|
||||
}
|
||||
for name in act_npc_names {
|
||||
let normalized = name.trim();
|
||||
for role in act_roles {
|
||||
let normalized = role.id.trim();
|
||||
if normalized.is_empty() || names.iter().any(|item| item == normalized) {
|
||||
continue;
|
||||
}
|
||||
@@ -1235,6 +1318,98 @@ fn build_act_encounter_npc_ids(act_npc_names: &[String], primary_npc_id: &str) -
|
||||
names
|
||||
}
|
||||
|
||||
fn assign_role_ids(entries: Vec<JsonValue>, id_prefix: &str) -> Vec<JsonValue> {
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| assign_role_id(entry, id_prefix, index))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assign_role_id(mut entry: JsonValue, id_prefix: &str, index: usize) -> JsonValue {
|
||||
let name = json_text(&entry, "name").unwrap_or_else(|| format!("角色{}", index + 1));
|
||||
let fallback_id = format!("{}-{}", id_prefix, stable_ascii_slug(name.as_str()));
|
||||
let Some(object) = entry.as_object_mut() else {
|
||||
return json!({
|
||||
"id": fallback_id,
|
||||
"name": name,
|
||||
});
|
||||
};
|
||||
if object
|
||||
.get("id")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
object.insert("id".to_string(), JsonValue::String(fallback_id));
|
||||
}
|
||||
entry
|
||||
}
|
||||
|
||||
fn collect_scene_role_refs(entries: &[JsonValue]) -> Vec<SceneRoleRef> {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let name = json_text(entry, "name")?;
|
||||
let id = json_text(entry, "id")?;
|
||||
Some(SceneRoleRef { id, name })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resolve_scene_act_roles(
|
||||
requested_role_names: &[String],
|
||||
scene_role_refs: &[SceneRoleRef],
|
||||
) -> Vec<SceneRoleRef> {
|
||||
let mut resolved = requested_role_names
|
||||
.iter()
|
||||
.filter_map(|name| resolve_scene_role_ref(name, scene_role_refs))
|
||||
.collect::<Vec<_>>();
|
||||
if resolved.is_empty() {
|
||||
resolved.extend(scene_role_refs.iter().take(3).cloned());
|
||||
}
|
||||
dedupe_scene_role_refs(resolved)
|
||||
}
|
||||
|
||||
fn resolve_scene_role_ref(
|
||||
name_or_id: &str,
|
||||
scene_role_refs: &[SceneRoleRef],
|
||||
) -> Option<SceneRoleRef> {
|
||||
let normalized = name_or_id.trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
scene_role_refs
|
||||
.iter()
|
||||
.find(|role| role.name == normalized || role.id == normalized)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn dedupe_scene_role_refs(entries: Vec<SceneRoleRef>) -> Vec<SceneRoleRef> {
|
||||
let mut seen = Vec::new();
|
||||
let mut result = Vec::new();
|
||||
for entry in entries {
|
||||
if entry.id.trim().is_empty() || seen.iter().any(|id| id == &entry.id) {
|
||||
continue;
|
||||
}
|
||||
seen.push(entry.id.clone());
|
||||
result.push(entry);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn dedupe_text_values(values: &[String]) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for value in values {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() || result.iter().any(|item| item == normalized) {
|
||||
continue;
|
||||
}
|
||||
result.push(normalized.to_string());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
|
||||
if scene_summary.trim().is_empty() {
|
||||
return format!(
|
||||
@@ -1374,66 +1549,15 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
"description".to_string(),
|
||||
JsonValue::String(camp_description.clone()),
|
||||
);
|
||||
if !camp
|
||||
.get("sceneTaskDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
camp.insert(
|
||||
"sceneTaskDescription".to_string(),
|
||||
JsonValue::String(build_default_scene_task_description(
|
||||
camp_name.as_str(),
|
||||
camp_description.as_str(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
if !camp
|
||||
.get("actBackgroundPromptTexts")
|
||||
.and_then(JsonValue::as_array)
|
||||
.is_some_and(|items| items.len() == 3)
|
||||
{
|
||||
// 中文注释:开局场景也必须进入逐幕生图队列;模型漏字段时用 camp 信息生成可用的三幕画面描述。
|
||||
camp.insert(
|
||||
"actBackgroundPromptTexts".to_string(),
|
||||
JsonValue::Array(
|
||||
(0..3)
|
||||
.map(|index| {
|
||||
let event_description = build_default_act_event_description(
|
||||
camp_description.as_str(),
|
||||
"开局关键角色",
|
||||
index,
|
||||
);
|
||||
JsonValue::String(build_default_act_background_prompt(
|
||||
camp_description.as_str(),
|
||||
"开局关键角色",
|
||||
event_description.as_str(),
|
||||
index,
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !camp
|
||||
.get("actEventDescriptions")
|
||||
.and_then(JsonValue::as_array)
|
||||
.is_some_and(|items| items.len() == 3)
|
||||
{
|
||||
camp.insert(
|
||||
"actEventDescriptions".to_string(),
|
||||
JsonValue::Array(
|
||||
(0..3)
|
||||
.map(|index| {
|
||||
JsonValue::String(build_default_act_event_description(
|
||||
camp_description.as_str(),
|
||||
"开局关键角色",
|
||||
index,
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
// 中文注释:framework 只保留开局归处占位;完整开局场景任务与三幕内容统一交给场景批生成阶段。
|
||||
for generated_scene_key in [
|
||||
"sceneTaskDescription",
|
||||
"actBackgroundPromptTexts",
|
||||
"actEventDescriptions",
|
||||
"actNPCNames",
|
||||
"sceneNpcNames",
|
||||
] {
|
||||
camp.remove(generated_scene_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2024,7 +2148,18 @@ mod tests {
|
||||
"actNPCNames": ["灯童丁", "档吏庚", "灯童丁"]
|
||||
})];
|
||||
|
||||
let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
|
||||
let scene_role_refs = vec![
|
||||
SceneRoleRef {
|
||||
id: "story-npc-lamp-child".to_string(),
|
||||
name: "灯童丁".to_string(),
|
||||
},
|
||||
SceneRoleRef {
|
||||
id: "story-npc-archive-clerk".to_string(),
|
||||
name: "档吏庚".to_string(),
|
||||
},
|
||||
];
|
||||
let blueprints =
|
||||
build_scene_chapter_blueprints_from_landmarks(&landmarks, &scene_role_refs);
|
||||
let acts = blueprints[0]
|
||||
.get("acts")
|
||||
.and_then(JsonValue::as_array)
|
||||
@@ -2043,10 +2178,23 @@ mod tests {
|
||||
"首次进入雾港码头时,查明黑潮船骨与灯童丁目击证词的关联。"
|
||||
))
|
||||
);
|
||||
assert_eq!(acts[0].get("oppositeNpcId"), Some(&json!("灯童丁")));
|
||||
assert_eq!(acts[0].get("primaryNpcId"), Some(&json!("灯童丁")));
|
||||
assert_eq!(acts[1].get("oppositeNpcId"), Some(&json!("档吏庚")));
|
||||
assert_eq!(acts[1].get("primaryNpcId"), Some(&json!("档吏庚")));
|
||||
assert_eq!(
|
||||
acts[0].get("oppositeNpcId"),
|
||||
Some(&json!("story-npc-lamp-child"))
|
||||
);
|
||||
assert_eq!(
|
||||
acts[0].get("primaryNpcId"),
|
||||
Some(&json!("story-npc-lamp-child"))
|
||||
);
|
||||
assert_eq!(acts[0].get("primaryRoleName"), Some(&json!("灯童丁")));
|
||||
assert_eq!(
|
||||
acts[1].get("oppositeNpcId"),
|
||||
Some(&json!("story-npc-archive-clerk"))
|
||||
);
|
||||
assert_eq!(
|
||||
acts[1].get("primaryNpcId"),
|
||||
Some(&json!("story-npc-archive-clerk"))
|
||||
);
|
||||
assert_eq!(
|
||||
acts[0].get("eventDescription"),
|
||||
Some(&json!(
|
||||
@@ -2081,7 +2229,16 @@ mod tests {
|
||||
"actBackgroundPromptTexts": ["斗技台晨雾未散,石阶旁少年们列队观望。", "木桩与兵器架围出训练区,族徽旗帜在风里猎猎。", "暮色压下斗技场,中央擂台留出一对一交锋空间。"]
|
||||
})];
|
||||
|
||||
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(camp, &landmarks);
|
||||
let scene_role_refs = vec![SceneRoleRef {
|
||||
id: "story-npc-mentor".to_string(),
|
||||
name: "药师长老".to_string(),
|
||||
}];
|
||||
|
||||
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(
|
||||
camp,
|
||||
&landmarks,
|
||||
&scene_role_refs,
|
||||
);
|
||||
let opening_chapter = &blueprints[0];
|
||||
let opening_acts = opening_chapter
|
||||
.get("acts")
|
||||
@@ -2106,6 +2263,18 @@ mod tests {
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}));
|
||||
assert!(opening_acts.iter().all(|act| {
|
||||
act.get("primaryNpcId")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| value == "story-npc-mentor")
|
||||
}));
|
||||
assert!(opening_acts.iter().all(|act| {
|
||||
act.get("encounterNpcIds")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| value == "story-npc-mentor")
|
||||
}));
|
||||
assert_eq!(blueprints.len(), 2);
|
||||
}
|
||||
|
||||
@@ -2377,7 +2546,11 @@ mod tests {
|
||||
assert!(request_text.contains("attributeSchema"));
|
||||
assert!(request_text.contains("可扮演角色框架名单"));
|
||||
assert!(request_text.contains("场景角色框架名单"));
|
||||
assert!(request_text.contains("关键场景框架名单"));
|
||||
assert!(request_text.contains("场景框架名单"));
|
||||
assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景"));
|
||||
assert!(request_text.contains("camp 只表示玩家开局时的落脚处占位"));
|
||||
assert!(!request_text.contains("camp.sceneTaskDescription"));
|
||||
assert!(!request_text.contains("camp.actBackgroundPromptTexts"));
|
||||
assert!(request_text.contains("actNPCNames"));
|
||||
assert!(!request_text.contains("\"sceneNpcNames\""));
|
||||
assert!(request_text.contains("connectedLandmarkNames"));
|
||||
@@ -2434,6 +2607,43 @@ mod tests {
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("camp")
|
||||
.and_then(|entry| entry.get("name"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("旧灯塔")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("camp")
|
||||
.and_then(|entry| entry.get("id"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("camp-1")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("camp")
|
||||
.and_then(|entry| entry.get("sceneTaskDescription"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("首次进入旧灯塔时,追查被篡改的灯火航线记录。")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("landmarks")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("landmarks")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("name"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("沉船湾")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
@@ -2462,19 +2672,57 @@ mod tests {
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("灯童丁")
|
||||
Some("船魂戊")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.get(1))
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|acts| acts.get(1))
|
||||
.and_then(|act| act.get("primaryNpcId"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("档吏庚")
|
||||
Some("story-npc-0192680e")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|acts| acts.first())
|
||||
.and_then(|act| act.get("primaryNpcId"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("story-npc-01b5406b")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|acts| acts.first())
|
||||
.and_then(|act| act.get("encounterNpcIds"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("story-npc-01b5406b")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|acts| acts.first())
|
||||
.and_then(|act| act.get("primaryRoleName"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("灯童丁")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
@@ -2486,8 +2734,54 @@ mod tests {
|
||||
.and_then(|acts| acts.first())
|
||||
.and_then(|act| act.get("primaryNpcId"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("灯童丁")
|
||||
Some("story-npc-01fc0701")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.get(1))
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|acts| acts.get(1))
|
||||
.and_then(|act| act.get("primaryNpcId"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("story-npc-01acae6c")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_scene_batch_first_entry_becomes_opening_camp() {
|
||||
let fallback_camp = json!({
|
||||
"name": "世界骨架占位归处",
|
||||
"description": "只来自 framework 的轻量占位。"
|
||||
});
|
||||
let generated_scenes = vec![
|
||||
json!({
|
||||
"name": "旧灯塔",
|
||||
"description": "雾中仍亮着错位灯火",
|
||||
"sceneTaskDescription": "首次进入旧灯塔时,追查被篡改的灯火航线记录。",
|
||||
"actBackgroundPromptTexts": ["一", "二", "三"],
|
||||
"actEventDescriptions": ["甲", "乙", "丙"],
|
||||
}),
|
||||
json!({
|
||||
"name": "沉船湾",
|
||||
"description": "退潮后露出旧船骨"
|
||||
}),
|
||||
];
|
||||
|
||||
let (camp, landmarks) =
|
||||
split_generated_scenes_into_camp_and_landmarks(fallback_camp, generated_scenes);
|
||||
|
||||
assert_eq!(camp.get("id"), Some(&json!("camp-1")));
|
||||
assert_eq!(camp.get("kind"), Some(&json!("camp")));
|
||||
assert_eq!(camp.get("name"), Some(&json!("旧灯塔")));
|
||||
assert_eq!(
|
||||
camp.get("sceneTaskDescription"),
|
||||
Some(&json!("首次进入旧灯塔时,追查被篡改的灯火航线记录。"))
|
||||
);
|
||||
assert_eq!(landmarks.len(), 1);
|
||||
assert_eq!(landmarks[0].get("name"), Some(&json!("沉船湾")));
|
||||
}
|
||||
|
||||
fn llm_response(content: &str) -> String {
|
||||
|
||||
@@ -6,7 +6,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
|
||||
[
|
||||
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。".to_string(),
|
||||
"这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。".to_string(),
|
||||
"玩家设定:".to_string(),
|
||||
setting_text.trim().to_string(),
|
||||
"".to_string(),
|
||||
@@ -33,10 +33,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
|
||||
" },".to_string(),
|
||||
" \"camp\": {".to_string(),
|
||||
" \"name\": \"开局归处名称\",".to_string(),
|
||||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
|
||||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\"".to_string(),
|
||||
" }".to_string(),
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
@@ -45,10 +42,7 @@ pub(crate) fn build_custom_world_framework_prompt(setting_text: &str) -> String
|
||||
"- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。".to_string(),
|
||||
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
|
||||
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
|
||||
"- camp 必须表示玩家开局时的落脚处,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
|
||||
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应第 1/2/3 幕背景图画面内容描述;每条必须基于同序号 actEventDescriptions 和相关角色写出画面主体、站位空间、冲突痕迹与氛围,能直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。".to_string(),
|
||||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||||
"- attributeSchema 必须是本世界专属的角色六维属性体系,slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f,维度名必须是 2 到 4 个汉字且互不重复。".to_string(),
|
||||
@@ -68,7 +62,7 @@ pub(crate) fn build_custom_world_framework_json_repair_prompt(response_text: &st
|
||||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||||
"attributeSchema 必须是对象,且包含 schemaName 与 slots;slots 必须恰好 6 个,slotId 固定为 axis_a 到 axis_f。",
|
||||
"camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions。",
|
||||
"camp 必须是对象,且只包含:name、description。",
|
||||
"原始文本:",
|
||||
response_text.trim(),
|
||||
].join("\n")
|
||||
@@ -154,16 +148,26 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
|
||||
framework: &JsonValue,
|
||||
batch_count: usize,
|
||||
forbidden_names: &[String],
|
||||
is_opening_batch: bool,
|
||||
) -> String {
|
||||
let story_npc_names = names_from_entries(&array_field(framework, "storyNpcs"));
|
||||
[
|
||||
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
|
||||
"这一步必须一次性生成场景骨架、地点默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string(),
|
||||
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
|
||||
if is_opening_batch {
|
||||
"这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
|
||||
} else {
|
||||
"这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string()
|
||||
},
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("、")) },
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||||
if is_opening_batch {
|
||||
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
|
||||
} else {
|
||||
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
|
||||
},
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些场景已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||||
"".to_string(),
|
||||
"输出 JSON 模板:".to_string(),
|
||||
"{".to_string(),
|
||||
@@ -183,16 +187,21 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
|
||||
"}".to_string(),
|
||||
"".to_string(),
|
||||
"要求:".to_string(),
|
||||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
|
||||
if is_opening_batch {
|
||||
format!("- 必须生成恰好 {batch_count} 个场景,第 1 个必须是开局场景。")
|
||||
} else {
|
||||
format!("- 必须生成恰好 {batch_count} 个普通关键场景,不能包含开局场景。")
|
||||
},
|
||||
if is_opening_batch { "- 开局场景也必须按普通场景同级规则生成完整字段,不能只给 camp 简介。".to_string() } else { "".to_string() },
|
||||
"- 这是一个完全独立的自定义世界;场景名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1、开局场景 之类的占位名。".to_string(),
|
||||
"- 每个场景只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
|
||||
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||||
"- actNPCNames 只能引用上方可用场景角色名单中的名字,表示第 1/2/3 幕各自的主场景角色;如果名单为空,输出空数组。".to_string(),
|
||||
"- 可用场景角色名单非空时,actNPCNames 必须恰好 3 个;可以重复使用同一角色,但每一项都必须服务对应幕事件。".to_string(),
|
||||
"- actNPCNames[n] 会成为第 n+1 幕对面主角色;三幕事件和幕背景必须围绕对应角色的行动、阻碍、试探或求助展开。".to_string(),
|
||||
"- connectedLandmarkNames 优先引用本批或已知关键场景名称,每个地点 1 到 3 个;只有 1 个地点时可以输出空数组。".to_string(),
|
||||
"- connectedLandmarkNames 优先引用本批或已知场景名称,每个场景 1 到 3 个;只有 1 个场景时可以输出空数组。".to_string(),
|
||||
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
|
||||
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;第 1 幕负责铺垫,第 2 幕必须让冲突升级,第 3 幕必须形成高潮或关键抉择;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须基于同序号 actEventDescriptions、当前地点和可出场角色直接写出画面主体、站位空间、冲突痕迹与氛围,控制在 40 到 90 个汉字内。".to_string(),
|
||||
@@ -207,14 +216,16 @@ pub(crate) fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
response_text: &str,
|
||||
expected_count: usize,
|
||||
forbidden_names: &[String],
|
||||
is_opening_batch: bool,
|
||||
) -> String {
|
||||
[
|
||||
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||||
"下面这段文本本应是自定义世界场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||||
"请只输出修复后的 JSON 对象。".to_string(),
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||||
format!("必须保留恰好 {expected_count} 个场景对象。"),
|
||||
if is_opening_batch { "第一项必须是开局场景,且字段粒度与普通场景一致。".to_string() } else { "本批只保留普通关键场景,不要包含开局场景。".to_string() },
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
|
||||
"每个场景只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、actNPCNames、connectedLandmarkNames、entryHook。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts、actEventDescriptions、actNPCNames 和 connectedLandmarkNames 补空数组。".to_string(),
|
||||
"不要输出 items 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
|
||||
@@ -212,11 +212,10 @@ pub(super) async fn generate_reasoned_story_payload(
|
||||
}
|
||||
|
||||
pub(super) fn should_generate_reasoned_combat_story(
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
_battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> bool {
|
||||
battle
|
||||
.and_then(|presentation| presentation.outcome.as_deref())
|
||||
.is_some_and(|outcome| matches!(outcome, "victory" | "spar_complete" | "escaped"))
|
||||
// 战斗动作、逃跑、胜利、切磋结束与死亡都只走确定性结算,避免战斗链路再次触发剧情推理。
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_history(
|
||||
|
||||
@@ -1913,7 +1913,7 @@ fn runtime_story_quest_turn_in_marks_quest_rewards_and_affinity() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
|
||||
fn runtime_story_reasoned_combat_story_guard_blocks_all_battle_outcomes() {
|
||||
assert!(!should_generate_reasoned_combat_story(None));
|
||||
assert!(!should_generate_reasoned_combat_story(Some(
|
||||
&RuntimeBattlePresentation {
|
||||
@@ -1924,7 +1924,7 @@ fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
|
||||
outcome: Some("ongoing".to_string()),
|
||||
}
|
||||
)));
|
||||
assert!(should_generate_reasoned_combat_story(Some(
|
||||
assert!(!should_generate_reasoned_combat_story(Some(
|
||||
&RuntimeBattlePresentation {
|
||||
target_id: Some("npc_merchant_01".to_string()),
|
||||
target_name: Some("沈七".to_string()),
|
||||
@@ -1933,7 +1933,7 @@ fn runtime_story_reasoned_combat_story_guard_only_targets_terminal_outcomes() {
|
||||
outcome: Some("victory".to_string()),
|
||||
}
|
||||
)));
|
||||
assert!(should_generate_reasoned_combat_story(Some(
|
||||
assert!(!should_generate_reasoned_combat_story(Some(
|
||||
&RuntimeBattlePresentation {
|
||||
target_id: Some("npc_merchant_01".to_string()),
|
||||
target_name: Some("沈七".to_string()),
|
||||
|
||||
Reference in New Issue
Block a user