1
This commit is contained in:
296
server-rs/crates/api-server/src/big_fish_draft_compiler.rs
Normal file
296
server-rs/crates/api-server/src/big_fish_draft_compiler.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
|
||||
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),
|
||||
]))
|
||||
.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}"
|
||||
)),
|
||||
]))
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user