This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View 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"));
}
}