use module_big_fish::{ BIG_FISH_MAX_LEVEL_COUNT, BIG_FISH_MIN_LEVEL_COUNT, BigFishAnchorPack, BigFishGameDraft, BigFishLevelBlueprint, BigFishRuntimeParams, compile_default_draft, }; use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::Value as JsonValue; use crate::creation_agent_llm_turn::parse_json_response_text; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。 你必须直接输出单个 JSON 对象,不要输出 Markdown、代码块、解释、前言或补充说明。 硬约束: 1. 所有文案必须是中文。 2. 必须产出 6 到 12 级的连续等级阶梯,默认优先 8 级。 3. 每一级都必须有:name、oneLineFantasy、textDescription、visualDescription、idleMotionDescription、moveMotionDescription。 4. 每一级都必须体现等级递进关系:越高等级越大、越强、越有压迫感。 5. `visualPromptSeed` 必须能直接作为主图默认提示词。 6. `motionPromptSeed` 必须是该等级动作方向总提示词摘要,但不能替代具体 idle / move 描述。 7. `preyWindow` 和 `threatWindow` 必须是合法等级数组,围绕当前等级形成可玩窗口。 8. `background` 必须是竖屏 9:16 游戏背景口径,不出现主体和 UI。 9. `runtimeParams.levelCount` 必须与 levels 长度一致,`winLevel` 必须等于最高等级。 "#; const BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。"; #[derive(Debug)] pub(crate) struct BigFishDraftCompileError(String); impl BigFishDraftCompileError { fn new(message: impl Into) -> Self { Self(message.into()) } } impl std::fmt::Display for BigFishDraftCompileError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.0) } } impl std::error::Error for BigFishDraftCompileError {} #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BigFishDraftModelOutput { title: String, subtitle: String, core_fun: String, ecology_theme: String, levels: Vec, background: module_big_fish::BigFishBackgroundBlueprint, runtime_params: BigFishRuntimeParams, } pub(crate) async fn compile_big_fish_draft_with_fallback( llm_client: Option<&LlmClient>, anchor_pack: &BigFishAnchorPack, ) -> BigFishGameDraft { let fallback = compile_default_draft(anchor_pack); let Some(llm_client) = llm_client else { return fallback; }; match request_big_fish_draft(llm_client, anchor_pack).await { Ok(draft) => draft, Err(error) => { tracing::warn!(error = %error, "大鱼吃小鱼草稿结构化编译失败,回退到 deterministic fallback"); fallback } } } async fn request_big_fish_draft( llm_client: &LlmClient, anchor_pack: &BigFishAnchorPack, ) -> Result { let user_prompt = build_big_fish_draft_user_prompt(anchor_pack); let parsed = request_big_fish_json_stage( llm_client, user_prompt, "big-fish-draft-compile", "大鱼吃小鱼草稿编译没有返回有效内容。", ) .await?; let output: BigFishDraftModelOutput = serde_json::from_value(parsed).map_err(|error| { BigFishDraftCompileError::new(format!("大鱼吃小鱼草稿 JSON 结构非法:{error}")) })?; validate_big_fish_draft_output(&output)?; Ok(BigFishGameDraft { title: output.title, subtitle: output.subtitle, core_fun: output.core_fun, ecology_theme: output.ecology_theme, levels: output.levels, background: output.background, runtime_params: output.runtime_params, }) } async fn request_big_fish_json_stage( llm_client: &LlmClient, user_prompt: String, debug_label: &str, empty_response_message: &str, ) -> Result { let response = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT), LlmMessage::user(user_prompt), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api() .with_web_search(true), ) .await .map_err(|error| { BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}")) })?; let text = response.content.trim(); if text.is_empty() { return Err(BigFishDraftCompileError::new(empty_response_message)); } match parse_json_response_text(text) { Ok(value) => Ok(value), Err(_) => { let repaired = llm_client .request_text( LlmTextRequest::new(vec![ LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT), LlmMessage::user(format!( "请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}" )), ]) .with_model(CREATION_TEMPLATE_LLM_MODEL) .with_responses_api(), ) .await .map_err(|error| { BigFishDraftCompileError::new(format!( "{debug_label} JSON 修复请求失败:{error}" )) })?; parse_json_response_text(repaired.content.as_str()).map_err(|error| { BigFishDraftCompileError::new(format!("{debug_label} JSON 解析失败:{error}")) }) } } } fn build_big_fish_draft_user_prompt(anchor_pack: &BigFishAnchorPack) -> String { format!( r#"请基于下面的大鱼吃小鱼玩法锚点,直接生成首版结果页草稿。 玩法承诺:{gameplay_promise} 生态与视觉母题:{ecology_visual_theme} 成长阶梯:{growth_ladder} 风险节奏:{risk_tempo} 请严格输出下列 JSON 结构: {{ "title": "", "subtitle": "", "coreFun": "", "ecologyTheme": "", "levels": [ {{ "level": 1, "name": "", "oneLineFantasy": "", "textDescription": "", "silhouetteDirection": "", "sizeRatio": 1.0, "visualDescription": "", "visualPromptSeed": "", "idleMotionDescription": "", "moveMotionDescription": "", "motionPromptSeed": "", "mergeSourceLevel": null, "preyWindow": [1], "threatWindow": [2], "isFinalLevel": false }} ], "background": {{ "theme": "", "colorMood": "", "foregroundHints": "", "midgroundComposition": "", "backgroundDepth": "", "safePlayAreaHint": "", "spawnEdgeHint": "", "backgroundPromptSeed": "" }}, "runtimeParams": {{ "levelCount": 8, "mergeCountPerUpgrade": 3, "spawnTargetCount": 12, "leaderMoveSpeed": 160, "followerCatchUpSpeed": 120, "offscreenCullSeconds": 3, "preySpawnDeltaLevels": [1, 2], "threatSpawnDeltaLevels": [1, 2], "winLevel": 8 }} }} 补充要求: 1. `title`、`subtitle`、`coreFun` 必须适合结果页直接展示。 2. 每一级 `textDescription` 要解释这一等级在成长链中的定位。 3. `visualDescription` 要能直接填入主图工坊。 4. `idleMotionDescription` 和 `moveMotionDescription` 要分别对应待机动作与移动动作工坊。 5. `visualPromptSeed` 必须是主图生成用的中文提示词,不要只写关键词。 6. `motionPromptSeed` 必须是该等级动作生成的总提示词摘要,要同时覆盖待机和移动方向。 7. 如果锚点没有明确等级数量,默认输出 8 级。 "#, gameplay_promise = anchor_pack.gameplay_promise.value, ecology_visual_theme = anchor_pack.ecology_visual_theme.value, growth_ladder = anchor_pack.growth_ladder.value, risk_tempo = anchor_pack.risk_tempo.value, ) } fn validate_big_fish_draft_output( output: &BigFishDraftModelOutput, ) -> Result<(), BigFishDraftCompileError> { let level_count = output.levels.len() as u32; if !(BIG_FISH_MIN_LEVEL_COUNT..=BIG_FISH_MAX_LEVEL_COUNT).contains(&level_count) { return Err(BigFishDraftCompileError::new(format!( "大鱼吃小鱼草稿等级数非法:{level_count}" ))); } if output.runtime_params.level_count != level_count { return Err(BigFishDraftCompileError::new( "runtimeParams.levelCount 必须与 levels 数量一致", )); } if output.runtime_params.win_level != level_count { return Err(BigFishDraftCompileError::new( "runtimeParams.winLevel 必须等于最高等级", )); } for (index, level) in output.levels.iter().enumerate() { let expected_level = (index + 1) as u32; if level.level != expected_level { return Err(BigFishDraftCompileError::new(format!( "等级序号不连续:期望 {expected_level},实际 {}", level.level ))); } ensure_non_empty(level.name.as_str(), "level.name")?; ensure_non_empty(level.one_line_fantasy.as_str(), "level.oneLineFantasy")?; ensure_non_empty(level.text_description.as_str(), "level.textDescription")?; ensure_non_empty(level.visual_description.as_str(), "level.visualDescription")?; ensure_non_empty( level.idle_motion_description.as_str(), "level.idleMotionDescription", )?; ensure_non_empty( level.move_motion_description.as_str(), "level.moveMotionDescription", )?; ensure_non_empty(level.visual_prompt_seed.as_str(), "level.visualPromptSeed")?; ensure_non_empty(level.motion_prompt_seed.as_str(), "level.motionPromptSeed")?; } ensure_non_empty(output.title.as_str(), "title")?; ensure_non_empty(output.subtitle.as_str(), "subtitle")?; ensure_non_empty(output.core_fun.as_str(), "coreFun")?; ensure_non_empty(output.ecology_theme.as_str(), "ecologyTheme")?; Ok(()) } fn ensure_non_empty(value: &str, field_name: &str) -> Result<(), BigFishDraftCompileError> { if value.trim().is_empty() { return Err(BigFishDraftCompileError::new(format!( "{field_name} 不能为空" ))); } Ok(()) } #[cfg(test)] mod tests { use module_big_fish::infer_anchor_pack; use super::build_big_fish_draft_user_prompt; #[test] fn big_fish_draft_prompt_requires_all_level_descriptions() { let prompt = build_big_fish_draft_user_prompt(&infer_anchor_pack("深海机械鱼", None)); assert!(prompt.contains("textDescription")); assert!(prompt.contains("visualDescription")); assert!(prompt.contains("idleMotionDescription")); assert!(prompt.contains("moveMotionDescription")); assert!(prompt.contains("visualPromptSeed")); assert!(prompt.contains("motionPromptSeed")); } }