307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
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<String>) -> 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<BigFishLevelBlueprint>,
|
||
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<BigFishGameDraft, BigFishDraftCompileError> {
|
||
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<JsonValue, BigFishDraftCompileError> {
|
||
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"));
|
||
}
|
||
}
|