641 lines
23 KiB
Rust
641 lines
23 KiB
Rust
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||
use serde_json::{Map as JsonMap, Value as JsonValue};
|
||
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
|
||
use spacetime_client::CustomWorldAgentSessionRecord;
|
||
|
||
const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT: &str =
|
||
"你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。";
|
||
const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT: &str =
|
||
"你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。";
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct CustomWorldGeneratedEntitiesResult {
|
||
pub payload_json: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum CustomWorldGeneratedEntitiesPayloadError {
|
||
SerializePayload(String),
|
||
InvalidPayloadShape,
|
||
}
|
||
|
||
pub async fn generate_custom_world_agent_entities(
|
||
llm_client: &LlmClient,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||
) -> Result<CustomWorldGeneratedEntitiesResult, String> {
|
||
let action = payload.action.trim();
|
||
let draft_profile = session
|
||
.draft_profile
|
||
.as_object()
|
||
.ok_or_else(|| format!("{action} requires an existing draft foundation"))?;
|
||
let count = ensure_count(payload.count);
|
||
let prompt_seed = payload
|
||
.prompt_text
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("没有额外要求,围绕当前底稿自然扩展。");
|
||
let anchor_summary = build_anchor_summary(
|
||
draft_profile,
|
||
payload.anchor_card_ids.as_deref().unwrap_or(&[]),
|
||
);
|
||
let creator_intent_summary = session
|
||
.anchor_pack
|
||
.get("creatorIntentSummary")
|
||
.and_then(JsonValue::as_str)
|
||
.or_else(|| {
|
||
session
|
||
.creator_intent
|
||
.get("worldHook")
|
||
.and_then(JsonValue::as_str)
|
||
})
|
||
.or_else(|| draft_profile.get("summary").and_then(JsonValue::as_str))
|
||
.unwrap_or_default();
|
||
let world_name = draft_profile
|
||
.get("name")
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or("未命名世界");
|
||
let world_summary = draft_profile
|
||
.get("summary")
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or_default();
|
||
|
||
let (system_prompt, user_prompt, result_key) = match action {
|
||
"generate_characters" => (
|
||
CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
|
||
build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams {
|
||
world_name,
|
||
world_summary,
|
||
creator_intent_summary,
|
||
anchor_summary: anchor_summary.as_str(),
|
||
existing_names: existing_character_names(draft_profile),
|
||
count,
|
||
prompt_seed,
|
||
}),
|
||
"generatedCharacters",
|
||
),
|
||
"generate_landmarks" => (
|
||
CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
|
||
build_custom_world_agent_landmark_expansion_prompt(ExpansionPromptParams {
|
||
world_name,
|
||
world_summary,
|
||
creator_intent_summary,
|
||
anchor_summary: anchor_summary.as_str(),
|
||
existing_names: existing_landmark_names(draft_profile),
|
||
count,
|
||
prompt_seed,
|
||
}),
|
||
"generatedLandmarks",
|
||
),
|
||
_ => return Err(format!("unsupported generated entity action: {action}")),
|
||
};
|
||
|
||
let response = llm_client
|
||
.request_text(LlmTextRequest::new(vec![
|
||
LlmMessage::system(system_prompt),
|
||
LlmMessage::user(user_prompt),
|
||
]))
|
||
.await
|
||
.map_err(|error| format!("{action} LLM 请求失败:{error}"))?;
|
||
let generated_entities = parse_json_array_response(response.content.as_str())
|
||
.map_err(|error| format!("{action} JSON 解析失败:{error}"))?;
|
||
let normalized_entities =
|
||
normalize_generated_entities(action, generated_entities, draft_profile, count);
|
||
let payload_json =
|
||
build_generated_entities_action_payload_json(payload, result_key, normalized_entities)
|
||
.map_err(|error| match error {
|
||
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(message) => message,
|
||
CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape => {
|
||
"action payload 必须是 object".to_string()
|
||
}
|
||
})?;
|
||
|
||
Ok(CustomWorldGeneratedEntitiesResult { payload_json })
|
||
}
|
||
|
||
pub fn build_generated_entities_action_payload_json(
|
||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||
result_key: &str,
|
||
generated_entities: Vec<JsonValue>,
|
||
) -> Result<String, CustomWorldGeneratedEntitiesPayloadError> {
|
||
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
|
||
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!(
|
||
"action payload JSON 序列化失败:{error}"
|
||
))
|
||
})?;
|
||
let payload_object = payload_value
|
||
.as_object_mut()
|
||
.ok_or(CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape)?;
|
||
if payload.action.trim() == "generate_characters" {
|
||
payload_object.insert(
|
||
"roleType".to_string(),
|
||
JsonValue::String(resolve_role_type(payload.role_type.as_deref()).to_string()),
|
||
);
|
||
}
|
||
payload_object.insert(result_key.to_string(), JsonValue::Array(generated_entities));
|
||
serde_json::to_string(&payload_value).map_err(|error| {
|
||
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!(
|
||
"action payload JSON 序列化失败:{error}"
|
||
))
|
||
})
|
||
}
|
||
|
||
struct ExpansionPromptParams<'a> {
|
||
world_name: &'a str,
|
||
world_summary: &'a str,
|
||
creator_intent_summary: &'a str,
|
||
anchor_summary: &'a str,
|
||
existing_names: Vec<String>,
|
||
count: u32,
|
||
prompt_seed: &'a str,
|
||
}
|
||
|
||
fn build_custom_world_agent_character_expansion_prompt(
|
||
params: ExpansionPromptParams<'_>,
|
||
) -> String {
|
||
[
|
||
format!("当前世界:{}", params.world_name),
|
||
format!("世界摘要:{}", params.world_summary),
|
||
format!("创作意图摘要:{}", params.creator_intent_summary),
|
||
format!("参考锚点:{}", params.anchor_summary),
|
||
format!(
|
||
"已有角色:{}",
|
||
if params.existing_names.is_empty() {
|
||
"暂无".to_string()
|
||
} else {
|
||
params.existing_names.join("、")
|
||
}
|
||
),
|
||
format!("数量:{}", params.count),
|
||
format!(
|
||
"补充要求:{}",
|
||
if params.prompt_seed.trim().is_empty() {
|
||
"没有额外要求,围绕当前底稿自然扩展。"
|
||
} else {
|
||
params.prompt_seed
|
||
}
|
||
),
|
||
"返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。".to_string(),
|
||
"threadIds 必须优先引用现有线程 id。".to_string(),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptParams<'_>) -> String {
|
||
[
|
||
format!("当前世界:{}", params.world_name),
|
||
format!("世界摘要:{}", params.world_summary),
|
||
format!("创作意图摘要:{}", params.creator_intent_summary),
|
||
format!("参考锚点:{}", params.anchor_summary),
|
||
format!(
|
||
"已有地点:{}",
|
||
if params.existing_names.is_empty() {
|
||
"暂无".to_string()
|
||
} else {
|
||
params.existing_names.join("、")
|
||
}
|
||
),
|
||
format!("数量:{}", params.count),
|
||
format!(
|
||
"补充要求:{}",
|
||
if params.prompt_seed.trim().is_empty() {
|
||
"没有额外要求,围绕当前底稿自然扩展。"
|
||
} else {
|
||
params.prompt_seed
|
||
}
|
||
),
|
||
"返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, secret, summary, threadIds, characterIds。".to_string(),
|
||
"threadIds / characterIds 必须优先引用现有对象 id。".to_string(),
|
||
]
|
||
.join("\n")
|
||
}
|
||
|
||
fn ensure_count(count: Option<u32>) -> u32 {
|
||
count.unwrap_or(1).clamp(1, 3)
|
||
}
|
||
|
||
fn resolve_role_type(role_type: Option<&str>) -> &'static str {
|
||
match role_type.map(str::trim) {
|
||
Some("playable") => "playable",
|
||
_ => "story",
|
||
}
|
||
}
|
||
|
||
fn parse_json_array_response(text: &str) -> Result<Vec<JsonValue>, serde_json::Error> {
|
||
let trimmed = text.trim();
|
||
if let Some(start) = trimmed.find('[')
|
||
&& let Some(end) = trimmed.rfind(']')
|
||
&& end >= start
|
||
{
|
||
return serde_json::from_str::<Vec<JsonValue>>(&trimmed[start..=end]);
|
||
}
|
||
serde_json::from_str::<Vec<JsonValue>>(trimmed)
|
||
}
|
||
|
||
fn normalize_generated_entities(
|
||
action: &str,
|
||
entities: Vec<JsonValue>,
|
||
draft_profile: &JsonMap<String, JsonValue>,
|
||
count: u32,
|
||
) -> Vec<JsonValue> {
|
||
let mut existing_names = if action == "generate_characters" {
|
||
existing_character_names(draft_profile)
|
||
} else {
|
||
existing_landmark_names(draft_profile)
|
||
};
|
||
entities
|
||
.into_iter()
|
||
.filter_map(|entry| entry.as_object().cloned())
|
||
.filter_map(|mut object| {
|
||
let name = object
|
||
.get("name")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())?
|
||
.to_string();
|
||
if existing_names.iter().any(|entry| entry == &name) {
|
||
return None;
|
||
}
|
||
existing_names.push(name.clone());
|
||
let prefix = if action == "generate_characters" {
|
||
"character"
|
||
} else {
|
||
"landmark"
|
||
};
|
||
object.entry("id".to_string()).or_insert_with(|| {
|
||
JsonValue::String(create_stable_id(
|
||
prefix,
|
||
name.as_str(),
|
||
existing_names.len(),
|
||
))
|
||
});
|
||
normalize_generated_entity_profile_fields(action, &mut object);
|
||
Some(JsonValue::Object(object))
|
||
})
|
||
.take(count as usize)
|
||
.collect()
|
||
}
|
||
|
||
fn normalize_generated_entity_profile_fields(
|
||
action: &str,
|
||
object: &mut JsonMap<String, JsonValue>,
|
||
) {
|
||
if action == "generate_characters" {
|
||
normalize_generated_character_profile_fields(object);
|
||
} else {
|
||
normalize_generated_landmark_profile_fields(object);
|
||
}
|
||
}
|
||
|
||
fn normalize_generated_character_profile_fields(object: &mut JsonMap<String, JsonValue>) {
|
||
let name = read_object_text(object, "name").unwrap_or_else(|| "新场景角色".to_string());
|
||
let role = read_object_text(object, "role").unwrap_or_else(|| "场景角色".to_string());
|
||
let summary = read_first_object_text(object, &["description", "summary", "publicMask"])
|
||
.unwrap_or_else(|| format!("{name}是围绕当前世界新补出的{role}。"));
|
||
let hidden_hook = read_object_text(object, "hiddenHook").unwrap_or_else(|| summary.clone());
|
||
|
||
insert_text_if_missing(object, "title", role.as_str());
|
||
insert_text_if_missing(object, "description", summary.as_str());
|
||
insert_text_if_missing(object, "backstory", hidden_hook.as_str());
|
||
insert_text_if_missing(object, "personality", "待在后续互动中揭示");
|
||
insert_text_if_missing(object, "motivation", hidden_hook.as_str());
|
||
insert_text_if_missing(object, "combatStyle", "围绕自身身份采取行动");
|
||
object
|
||
.entry("initialAffinity".to_string())
|
||
.or_insert_with(|| JsonValue::Number(6.into()));
|
||
|
||
if !object
|
||
.get("relationshipHooks")
|
||
.is_some_and(JsonValue::is_array)
|
||
{
|
||
let mut hooks = Vec::new();
|
||
if let Some(relation) = read_object_text(object, "relationToPlayer") {
|
||
hooks.push(JsonValue::String(relation));
|
||
}
|
||
if hooks.is_empty() {
|
||
hooks.push(JsonValue::String("等待玩家接触".to_string()));
|
||
}
|
||
object.insert("relationshipHooks".to_string(), JsonValue::Array(hooks));
|
||
}
|
||
|
||
if !object.get("tags").is_some_and(JsonValue::is_array) {
|
||
object.insert(
|
||
"tags".to_string(),
|
||
JsonValue::Array(vec![JsonValue::String(role)]),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn normalize_generated_landmark_profile_fields(object: &mut JsonMap<String, JsonValue>) {
|
||
let name = read_object_text(object, "name").unwrap_or_else(|| "新场景".to_string());
|
||
let description = read_first_object_text(object, &["description", "summary", "purpose"])
|
||
.unwrap_or_else(|| format!("{name}是围绕当前世界新补出的关键场景。"));
|
||
let mut description_parts = vec![description];
|
||
for key in ["mood", "secret"] {
|
||
if let Some(text) = read_object_text(object, key)
|
||
&& !description_parts.iter().any(|entry| entry == &text)
|
||
{
|
||
description_parts.push(text);
|
||
}
|
||
}
|
||
|
||
insert_text_if_missing(object, "description", description_parts.join(" ").as_str());
|
||
object
|
||
.entry("connections".to_string())
|
||
.or_insert_with(|| JsonValue::Array(Vec::new()));
|
||
|
||
if !object.get("sceneNpcIds").is_some_and(JsonValue::is_array) {
|
||
let npc_ids = object
|
||
.get("characterIds")
|
||
.and_then(JsonValue::as_array)
|
||
.map(|items| {
|
||
items
|
||
.iter()
|
||
.filter_map(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| JsonValue::String(value.to_string()))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
object.insert("sceneNpcIds".to_string(), JsonValue::Array(npc_ids));
|
||
}
|
||
}
|
||
|
||
fn read_object_text(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
|
||
object
|
||
.get(key)
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn read_first_object_text(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||
keys.iter().find_map(|key| read_object_text(object, key))
|
||
}
|
||
|
||
fn insert_text_if_missing(object: &mut JsonMap<String, JsonValue>, key: &str, value: &str) {
|
||
if read_object_text(object, key).is_none() {
|
||
object.insert(key.to_string(), JsonValue::String(value.to_string()));
|
||
}
|
||
}
|
||
|
||
fn existing_character_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
|
||
["playableNpcs", "storyNpcs"]
|
||
.into_iter()
|
||
.flat_map(|key| object_array_names(draft_profile.get(key)))
|
||
.take(10)
|
||
.collect()
|
||
}
|
||
|
||
fn existing_landmark_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
|
||
object_array_names(draft_profile.get("landmarks"))
|
||
.into_iter()
|
||
.take(10)
|
||
.collect()
|
||
}
|
||
|
||
fn object_array_names(value: Option<&JsonValue>) -> Vec<String> {
|
||
value
|
||
.and_then(JsonValue::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
.filter_map(|entry| entry.get("name").and_then(JsonValue::as_str))
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.collect()
|
||
}
|
||
|
||
fn build_anchor_summary(
|
||
draft_profile: &JsonMap<String, JsonValue>,
|
||
anchor_card_ids: &[String],
|
||
) -> String {
|
||
let selected = anchor_card_ids
|
||
.iter()
|
||
.filter_map(|card_id| find_anchor_summary(draft_profile, card_id))
|
||
.collect::<Vec<_>>();
|
||
if !selected.is_empty() {
|
||
return selected.join(";");
|
||
}
|
||
draft_profile
|
||
.get("summary")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("围绕当前世界底稿自然扩展。")
|
||
.to_string()
|
||
}
|
||
|
||
fn find_anchor_summary(
|
||
draft_profile: &JsonMap<String, JsonValue>,
|
||
card_id: &str,
|
||
) -> Option<String> {
|
||
for key in ["playableNpcs", "storyNpcs", "landmarks", "threads"] {
|
||
for entry in draft_profile.get(key)?.as_array()? {
|
||
let object = entry.as_object()?;
|
||
let id = object
|
||
.get("id")
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or_default();
|
||
if id != card_id {
|
||
continue;
|
||
}
|
||
let name = object
|
||
.get("name")
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or_default();
|
||
let summary = object
|
||
.get("summary")
|
||
.or_else(|| object.get("description"))
|
||
.or_else(|| object.get("publicMask"))
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or_default();
|
||
return Some(format!("{name}:{summary}"));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn create_stable_id(prefix: &str, name: &str, index: usize) -> String {
|
||
let slug = name
|
||
.trim()
|
||
.to_lowercase()
|
||
.chars()
|
||
.map(|ch| {
|
||
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch) {
|
||
ch
|
||
} else {
|
||
'-'
|
||
}
|
||
})
|
||
.collect::<String>()
|
||
.trim_matches('-')
|
||
.to_string();
|
||
format!(
|
||
"{prefix}-{}-{index}",
|
||
if slug.is_empty() {
|
||
"entry"
|
||
} else {
|
||
slug.as_str()
|
||
}
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn character_expansion_prompt_keeps_node_contract_text() {
|
||
let prompt = build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams {
|
||
world_name: "雾港归航",
|
||
world_summary: "守灯人追查旧案。",
|
||
creator_intent_summary: "悬疑航海",
|
||
anchor_summary: "旧灯塔:灯火错位",
|
||
existing_names: vec!["岑灯".to_string()],
|
||
count: 2,
|
||
prompt_seed: "补一个敌对角色",
|
||
});
|
||
|
||
assert!(prompt.contains("返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。"));
|
||
assert!(prompt.contains("threadIds 必须优先引用现有线程 id。"));
|
||
}
|
||
|
||
#[test]
|
||
fn generated_entities_payload_injects_expected_key() {
|
||
let payload = ExecuteCustomWorldAgentActionRequest {
|
||
action: "generate_landmarks".to_string(),
|
||
profile_id: None,
|
||
draft_profile: None,
|
||
legacy_result_profile: None,
|
||
setting_text: None,
|
||
card_id: None,
|
||
sections: None,
|
||
profile: None,
|
||
count: Some(1),
|
||
role_type: None,
|
||
prompt_text: Some("补地点".to_string()),
|
||
anchor_card_ids: None,
|
||
role_ids: None,
|
||
role_id: None,
|
||
portrait_path: None,
|
||
generated_visual_asset_id: None,
|
||
generated_animation_set_id: None,
|
||
animation_map: None,
|
||
scene_ids: None,
|
||
scene_id: None,
|
||
scene_kind: None,
|
||
image_src: None,
|
||
generated_scene_asset_id: None,
|
||
generated_scene_prompt: None,
|
||
generated_scene_model: None,
|
||
checkpoint_id: None,
|
||
};
|
||
|
||
let payload_json = build_generated_entities_action_payload_json(
|
||
&payload,
|
||
"generatedLandmarks",
|
||
vec![json!({ "name": "沉船湾" })],
|
||
)
|
||
.expect("payload should build");
|
||
let value = serde_json::from_str::<JsonValue>(&payload_json).expect("payload should parse");
|
||
|
||
assert_eq!(value.get("action"), Some(&json!("generate_landmarks")));
|
||
assert_eq!(
|
||
value
|
||
.get("generatedLandmarks")
|
||
.and_then(JsonValue::as_array)
|
||
.map(Vec::len),
|
||
Some(1)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_character_payload_fills_result_profile_fields() {
|
||
let draft_profile = json!({
|
||
"playableNpcs": [],
|
||
"storyNpcs": [],
|
||
"landmarks": []
|
||
});
|
||
let entities = normalize_generated_entities(
|
||
"generate_characters",
|
||
vec![json!({
|
||
"name": "潮雾证人",
|
||
"role": "旧案目击者",
|
||
"publicMask": "总在码头边缘售卖旧航图。",
|
||
"hiddenHook": "他记得沉钟第一次响起时失踪的人。",
|
||
"relationToPlayer": "掌握玩家亲族旧案线索"
|
||
})],
|
||
draft_profile
|
||
.as_object()
|
||
.expect("draft profile should be object"),
|
||
1,
|
||
);
|
||
let character = entities[0]
|
||
.as_object()
|
||
.expect("generated character should be object");
|
||
|
||
assert_eq!(
|
||
character.get("description").and_then(JsonValue::as_str),
|
||
Some("总在码头边缘售卖旧航图。")
|
||
);
|
||
assert_eq!(
|
||
character.get("backstory").and_then(JsonValue::as_str),
|
||
Some("他记得沉钟第一次响起时失踪的人。")
|
||
);
|
||
assert_eq!(
|
||
character
|
||
.get("relationshipHooks")
|
||
.and_then(JsonValue::as_array)
|
||
.and_then(|items| items.first())
|
||
.and_then(JsonValue::as_str),
|
||
Some("掌握玩家亲族旧案线索")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_landmark_payload_fills_scene_profile_fields() {
|
||
let draft_profile = json!({
|
||
"playableNpcs": [],
|
||
"storyNpcs": [{ "id": "character-witness", "name": "潮雾证人" }],
|
||
"landmarks": []
|
||
});
|
||
let entities = normalize_generated_entities(
|
||
"generate_landmarks",
|
||
vec![json!({
|
||
"name": "沉钟码头",
|
||
"purpose": "玩家第一次追查沉钟旧案的入口。",
|
||
"mood": "潮湿、压抑、灯火忽明忽暗。",
|
||
"secret": "码头木桩下藏着改写航道的符牌。",
|
||
"characterIds": ["character-witness"]
|
||
})],
|
||
draft_profile
|
||
.as_object()
|
||
.expect("draft profile should be object"),
|
||
1,
|
||
);
|
||
let landmark = entities[0]
|
||
.as_object()
|
||
.expect("generated landmark should be object");
|
||
|
||
assert!(
|
||
landmark
|
||
.get("description")
|
||
.and_then(JsonValue::as_str)
|
||
.is_some_and(|text| text.contains("沉钟旧案") && text.contains("符牌"))
|
||
);
|
||
assert_eq!(
|
||
landmark
|
||
.get("sceneNpcIds")
|
||
.and_then(JsonValue::as_array)
|
||
.and_then(|items| items.first())
|
||
.and_then(JsonValue::as_str),
|
||
Some("character-witness")
|
||
);
|
||
}
|
||
}
|