Files
Genarrative/server-rs/crates/api-server/src/big_fish_draft_compiler.rs
2026-04-30 17:49:07 +08:00

307 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"));
}
}