//! 大鱼吃小鱼应用编排。 //! //! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。 use shared_kernel::normalize_required_string; use crate::{ commands::{ BigFishAssetGenerateInput, BigFishDraftCompileInput, BigFishInputSubmitInput, BigFishMessageFinalizeInput, BigFishMessageSubmitInput, BigFishPlayRecordInput, BigFishPublishInput, BigFishRunGetInput, BigFishRunStartInput, BigFishSessionCreateInput, BigFishSessionGetInput, BigFishWorkLikeRecordInput, BigFishWorkRemixInput, BigFishWorksListInput, EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand, }, domain::{ BIG_FISH_ASSET_SLOT_ID_PREFIX, BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_MERGE_COUNT_PER_UPGRADE, BIG_FISH_OFFSCREEN_CULL_SECONDS, BIG_FISH_TARGET_WILD_COUNT, BigFishAnchorItem, BigFishAnchorPack, BigFishAnchorStatus, BigFishAssetCoverage, BigFishAssetKind, BigFishAssetSlotSnapshot, BigFishAssetStatus, BigFishBackgroundBlueprint, BigFishGameDraft, BigFishLevelBlueprint, BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot, BigFishRuntimeParams, BigFishRuntimeSnapshot, BigFishVector2, }, errors::{BigFishApplicationError, BigFishFieldError}, events::BigFishDomainEvent, }; const VIEW_WIDTH: f32 = 720.0; const VIEW_HEIGHT: f32 = 1280.0; const WORLD_HALF_WIDTH: f32 = 1400.0; const WORLD_HALF_HEIGHT: f32 = 2400.0; const DEFAULT_WILD_COUNT: usize = 28; const LEADER_SPEED: f32 = 210.0; const FOLLOWER_SPEED: f32 = 170.0; const WILD_SPEED: f32 = 74.0; const TICK_SECONDS: f32 = 0.1; /// 发布门禁应用结果,供 adapter 持久化快照或转换成 API DTO。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct EvaluateBigFishPublishReadinessResult { pub readiness: BigFishPublishReadiness, pub events: Vec, } /// 运行态推进应用结果。 #[derive(Clone, Debug, PartialEq)] pub struct BigFishRuntimeResult { pub snapshot: BigFishRuntimeSnapshot, pub events: Vec, } /// 评估 Big Fish 作品是否具备发布条件。 /// /// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图 /// 必须满足 `build_asset_coverage` 的统一口径。 pub fn evaluate_publish_readiness( command: EvaluateBigFishPublishReadinessCommand, asset_slots: &[BigFishAssetSlotSnapshot], ) -> Result { let session_id = normalize_required_string(command.session_id) .ok_or(BigFishApplicationError::MissingSessionId)?; let owner_user_id = normalize_required_string(command.owner_user_id) .ok_or(BigFishApplicationError::MissingOwnerUserId)?; let coverage = build_asset_coverage(command.draft.as_ref(), asset_slots); let readiness = BigFishPublishReadiness { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), publish_ready: coverage.publish_ready, blockers: coverage.blockers.clone(), evaluated_at_micros: command.evaluated_at_micros, }; let event = BigFishDomainEvent::PublishReadinessEvaluated { session_id, owner_user_id, publish_ready: readiness.publish_ready, blockers: readiness.blockers.clone(), occurred_at_micros: readiness.evaluated_at_micros, }; Ok(EvaluateBigFishPublishReadinessResult { readiness, events: vec![event], }) } /// 开始一局 Big Fish 运行态。 /// /// 领域层生成初始实体池,adapter 只负责把快照序列化并写入运行表。 pub fn start_big_fish_run( command: StartBigFishRunCommand, ) -> Result { let run_id = normalize_required_string(command.run_id).ok_or(BigFishApplicationError::MissingRunId)?; let session_id = normalize_required_string(command.session_id) .ok_or(BigFishApplicationError::MissingSessionId)?; let owner_user_id = normalize_required_string(command.owner_user_id) .ok_or(BigFishApplicationError::MissingOwnerUserId)?; let win_level = command .draft .as_ref() .map(|draft| draft.runtime_params.win_level) .or(command.work_level_count) .unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT) .clamp(1, 32); let wild_count = command .draft .as_ref() .map(|draft| draft.runtime_params.spawn_target_count as usize) .unwrap_or(BIG_FISH_TARGET_WILD_COUNT) .max(DEFAULT_WILD_COUNT); let leader = build_entity("owned-1".to_string(), 1, 0.0, 0.0); let mut wild_entities = vec![ build_entity("wild-open-1".to_string(), 1, 92.0, 0.0), build_entity("wild-open-2".to_string(), 1, -118.0, 46.0), ]; while wild_entities.len() < wild_count { wild_entities.push(build_wild_entity( 0, wild_entities.len() as u64, 1, win_level, &leader.position, )); } let snapshot = BigFishRuntimeSnapshot { run_id: run_id.clone(), session_id: session_id.clone(), status: BigFishRunStatus::Running, tick: 0, player_level: 1, win_level, leader_entity_id: Some(leader.entity_id.clone()), owned_entities: vec![leader.clone()], wild_entities, camera_center: leader.position, last_input: BigFishVector2 { x: 0.0, y: 0.0 }, event_log: vec!["开局生成同级可收编目标".to_string()], updated_at_micros: command.started_at_micros, }; Ok(BigFishRuntimeResult { snapshot, events: vec![BigFishDomainEvent::RuntimeRunStarted { run_id, session_id, owner_user_id, occurred_at_micros: command.started_at_micros, }], }) } /// 根据最新输入推进一帧运行态。 /// /// 这里是 Big Fish 运行态真相源;前端只能提交输入并渲染返回快照。 pub fn submit_big_fish_input( command: SubmitBigFishInputCommand, ) -> Result { let owner_user_id = normalize_required_string(command.owner_user_id) .ok_or(BigFishApplicationError::MissingOwnerUserId)?; if !command.x.is_finite() || !command.y.is_finite() { return Err(BigFishApplicationError::InvalidRuntimeInput); } let mut snapshot = command.current_snapshot; if snapshot.status != BigFishRunStatus::Running { return Ok(BigFishRuntimeResult { snapshot, events: Vec::new(), }); } let next_tick = snapshot.tick.saturating_add(1); let normalized_input = normalize_vector(command.x, command.y); let mut sorted_owned = refresh_leader(std::mem::take(&mut snapshot.owned_entities)); let Some(current_leader) = sorted_owned.first().cloned() else { snapshot.status = BigFishRunStatus::Failed; snapshot.event_log = tail_events(vec!["己方实体归零,本局失败".to_string()]); snapshot.updated_at_micros = command.submitted_at_micros; return Ok(BigFishRuntimeResult { events: settlement_events(&snapshot, owner_user_id, command.submitted_at_micros), snapshot, }); }; let next_leader = move_leader(¤t_leader, &normalized_input); let mut owned_entities = vec![next_leader.clone()]; for (index, follower) in sorted_owned.drain(1..).enumerate() { owned_entities.push(move_follower(&follower, &next_leader, index + 1)); } let mut wild_entities = snapshot .wild_entities .into_iter() .map(|entity| move_wild_entity(&entity, next_tick)) .collect::>(); let mut events = snapshot.event_log; let mut removed_wild = Vec::::new(); let mut removed_owned = Vec::::new(); let mut newly_owned = Vec::::new(); for owned in &owned_entities { if removed_owned.contains(&owned.entity_id) { continue; } for wild in &wild_entities { if removed_wild.contains(&wild.entity_id) { continue; } if distance(owned, wild) > owned.radius + wild.radius { continue; } if owned.level >= wild.level { removed_wild.push(wild.entity_id.clone()); newly_owned.push(build_entity( format!("owned-from-{}-{next_tick}", wild.entity_id), wild.level, wild.position.x, wild.position.y, )); events.push(format!("收编 {} 级实体", wild.level)); } else { removed_owned.push(owned.entity_id.clone()); events.push(format!( "{} 级己方实体被 {} 级野生实体吃掉", owned.level, wild.level )); } } } owned_entities.retain(|entity| !removed_owned.contains(&entity.entity_id)); owned_entities.extend(newly_owned); wild_entities.retain(|entity| !removed_wild.contains(&entity.entity_id)); let merge_result = merge_owned_entities(owned_entities, next_tick); owned_entities = refresh_leader(merge_result.owned_entities); events.extend(merge_result.events); let player_level = owned_entities .iter() .map(|entity| entity.level) .max() .unwrap_or(0); let leader = owned_entities.first().cloned(); let camera_center = leader .as_ref() .map(|entity| entity.position.clone()) .unwrap_or(snapshot.camera_center); wild_entities = wild_entities .into_iter() .filter_map(|entity| { let should_cull = entity.level == player_level || entity.level >= player_level.saturating_add(3) || entity.level.saturating_add(3) <= player_level; let offscreen_seconds = if should_cull && is_offscreen(&entity, &camera_center) { entity.offscreen_seconds + TICK_SECONDS } else { 0.0 }; (offscreen_seconds < 3.0).then_some(BigFishRuntimeEntitySnapshot { offscreen_seconds, ..entity }) }) .collect(); while wild_entities.len() < DEFAULT_WILD_COUNT { wild_entities.push(build_wild_entity( next_tick, wild_entities.len() as u64 + next_tick, player_level.max(1), snapshot.win_level, &camera_center, )); } let status = if owned_entities.is_empty() { events.push("己方实体归零,本局失败".to_string()); BigFishRunStatus::Failed } else if player_level >= snapshot.win_level { events.push("获得最高等级实体,通关".to_string()); BigFishRunStatus::Won } else { BigFishRunStatus::Running }; let next_snapshot = BigFishRuntimeSnapshot { run_id: snapshot.run_id, session_id: snapshot.session_id, status, tick: next_tick, player_level, win_level: snapshot.win_level, leader_entity_id: leader.map(|entity| entity.entity_id), owned_entities, wild_entities, camera_center, last_input: normalized_input, event_log: tail_events(events), updated_at_micros: command.submitted_at_micros, }; let events = settlement_events(&next_snapshot, owner_user_id, command.submitted_at_micros); Ok(BigFishRuntimeResult { snapshot: next_snapshot, events, }) } pub fn serialize_runtime_snapshot( snapshot: &BigFishRuntimeSnapshot, ) -> Result { serde_json::to_string(snapshot) } pub fn deserialize_runtime_snapshot( value: &str, ) -> Result { serde_json::from_str(value) } fn build_entity(entity_id: String, level: u32, x: f32, y: f32) -> BigFishRuntimeEntitySnapshot { BigFishRuntimeEntitySnapshot { entity_id, level, position: BigFishVector2 { x, y }, radius: entity_radius(level), offscreen_seconds: 0.0, } } fn entity_radius(level: u32) -> f32 { 18.0 + level as f32 * 4.0 } fn normalize_vector(x: f32, y: f32) -> BigFishVector2 { let length = (x * x + y * y).sqrt(); if length <= 0.001 { return BigFishVector2 { x: 0.0, y: 0.0 }; } let capped = length.min(1.0); BigFishVector2 { x: (x / length) * capped, y: (y / length) * capped, } } fn distance(first: &BigFishRuntimeEntitySnapshot, second: &BigFishRuntimeEntitySnapshot) -> f32 { let x = first.position.x - second.position.x; let y = first.position.y - second.position.y; (x * x + y * y).sqrt() } fn clamp(value: f32, min: f32, max: f32) -> f32 { value.max(min).min(max) } fn spawn_level(player_level: u32, win_level: u32, index: u64) -> u32 { if player_level <= 1 && index % 4 < 2 { return 1; } let deltas = [-2_i32, -1, 1, 2]; let delta = deltas[(index as usize) % deltas.len()]; (player_level as i32 + delta).clamp(1, win_level as i32) as u32 } fn spawn_position(center: &BigFishVector2, index: u64) -> BigFishVector2 { let side = index % 4; let offset = ((index * 97) % 980) as f32 - 490.0; match side { 0 => BigFishVector2 { x: center.x - VIEW_WIDTH * 0.72, y: center.y + offset, }, 1 => BigFishVector2 { x: center.x + VIEW_WIDTH * 0.72, y: center.y + offset, }, 2 => BigFishVector2 { x: center.x + offset, y: center.y - VIEW_HEIGHT * 0.64, }, _ => BigFishVector2 { x: center.x + offset, y: center.y + VIEW_HEIGHT * 0.64, }, } } fn build_wild_entity( tick: u64, index: u64, player_level: u32, win_level: u32, center: &BigFishVector2, ) -> BigFishRuntimeEntitySnapshot { let level = spawn_level(player_level, win_level, index); let position = spawn_position(center, index); build_entity( format!("wild-{tick}-{index}"), level, position.x, position.y, ) } fn move_leader( leader: &BigFishRuntimeEntitySnapshot, input: &BigFishVector2, ) -> BigFishRuntimeEntitySnapshot { BigFishRuntimeEntitySnapshot { position: BigFishVector2 { x: clamp( leader.position.x + input.x * LEADER_SPEED * TICK_SECONDS, -WORLD_HALF_WIDTH, WORLD_HALF_WIDTH, ), y: clamp( leader.position.y + input.y * LEADER_SPEED * TICK_SECONDS, -WORLD_HALF_HEIGHT, WORLD_HALF_HEIGHT, ), }, ..leader.clone() } } fn move_follower( follower: &BigFishRuntimeEntitySnapshot, leader: &BigFishRuntimeEntitySnapshot, index: usize, ) -> BigFishRuntimeEntitySnapshot { let slot_y = (index as f32 * 0.7).sin() * 42.0; let target = BigFishVector2 { x: leader.position.x - 52.0 - index as f32 * 10.0, y: leader.position.y + slot_y, }; let delta_x = target.x - follower.position.x; let delta_y = target.y - follower.position.y; let direction = normalize_vector(delta_x, delta_y); let step = (FOLLOWER_SPEED * TICK_SECONDS).min((delta_x * delta_x + delta_y * delta_y).sqrt()); BigFishRuntimeEntitySnapshot { position: BigFishVector2 { x: follower.position.x + direction.x * step, y: follower.position.y + direction.y * step, }, ..follower.clone() } } fn move_wild_entity( entity: &BigFishRuntimeEntitySnapshot, tick: u64, ) -> BigFishRuntimeEntitySnapshot { let phase = tick as f32 * 0.23 + entity.level as f32 * 0.91 + entity.entity_id.len() as f32 * 0.13; BigFishRuntimeEntitySnapshot { position: BigFishVector2 { x: clamp( entity.position.x + phase.cos() * (WILD_SPEED + entity.level as f32 * 3.0) * TICK_SECONDS, -WORLD_HALF_WIDTH, WORLD_HALF_WIDTH, ), y: clamp( entity.position.y + (phase * 0.72).sin() * (WILD_SPEED + entity.level as f32 * 3.0) * TICK_SECONDS, -WORLD_HALF_HEIGHT, WORLD_HALF_HEIGHT, ), }, ..entity.clone() } } #[derive(Clone, Debug)] struct MergeOwnedEntitiesResult { owned_entities: Vec, events: Vec, } fn merge_owned_entities( mut owned_entities: Vec, tick: u64, ) -> MergeOwnedEntitiesResult { let mut events = Vec::new(); let mut changed = true; while changed { changed = false; for level in 1..32 { let same_level = owned_entities .iter() .enumerate() .filter(|(_, entity)| entity.level == level) .take(3) .map(|(index, entity)| (index, entity.clone())) .collect::>(); if same_level.len() < 3 { continue; } let center = same_level .iter() .fold(BigFishVector2 { x: 0.0, y: 0.0 }, |acc, (_, entity)| { BigFishVector2 { x: acc.x + entity.position.x / 3.0, y: acc.y + entity.position.y / 3.0, } }); let remove_indices = same_level .iter() .map(|(index, _)| *index) .collect::>(); owned_entities = owned_entities .into_iter() .enumerate() .filter_map(|(index, entity)| (!remove_indices.contains(&index)).then_some(entity)) .collect(); owned_entities.push(build_entity( format!("owned-merge-{}-{tick}", level + 1), level + 1, center.x, center.y, )); events.push(format!("3 个 {level} 级实体合成 {} 级", level + 1)); changed = true; break; } } MergeOwnedEntitiesResult { owned_entities, events, } } fn is_offscreen(entity: &BigFishRuntimeEntitySnapshot, camera_center: &BigFishVector2) -> bool { entity.position.x + entity.radius < camera_center.x - VIEW_WIDTH / 2.0 || entity.position.x - entity.radius > camera_center.x + VIEW_WIDTH / 2.0 || entity.position.y + entity.radius < camera_center.y - VIEW_HEIGHT / 2.0 || entity.position.y - entity.radius > camera_center.y + VIEW_HEIGHT / 2.0 } fn refresh_leader( mut owned_entities: Vec, ) -> Vec { owned_entities.sort_by(|left, right| { right .level .cmp(&left.level) .then_with(|| left.entity_id.cmp(&right.entity_id)) }); owned_entities } fn tail_events(events: Vec) -> Vec { events .into_iter() .rev() .take(5) .collect::>() .into_iter() .rev() .collect() } fn settlement_events( snapshot: &BigFishRuntimeSnapshot, owner_user_id: String, occurred_at_micros: i64, ) -> Vec { if snapshot.status == BigFishRunStatus::Running { return Vec::new(); } vec![BigFishDomainEvent::RuntimeRunSettled { run_id: snapshot.run_id.clone(), session_id: snapshot.session_id.clone(), owner_user_id, status: snapshot.status.as_str().to_string(), occurred_at_micros, }] } pub fn empty_anchor_pack() -> BigFishAnchorPack { BigFishAnchorPack { gameplay_promise: BigFishAnchorItem { key: "gameplayPromise".to_string(), label: "玩法承诺".to_string(), value: String::new(), status: BigFishAnchorStatus::Missing, }, ecology_visual_theme: BigFishAnchorItem { key: "ecologyVisualTheme".to_string(), label: "生态与视觉母题".to_string(), value: String::new(), status: BigFishAnchorStatus::Missing, }, growth_ladder: BigFishAnchorItem { key: "growthLadder".to_string(), label: "成长阶梯".to_string(), value: String::new(), status: BigFishAnchorStatus::Missing, }, risk_tempo: BigFishAnchorItem { key: "riskTempo".to_string(), label: "风险节奏".to_string(), value: "平衡".to_string(), status: BigFishAnchorStatus::Inferred, }, } } pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> BigFishAnchorPack { let source = normalize_required_string(latest_message.unwrap_or(seed_text)) .or_else(|| normalize_required_string(seed_text)) .unwrap_or_else(|| "深海弱小逆袭,逐级吞噬成长".to_string()); let mut pack = empty_anchor_pack(); pack.gameplay_promise.value = if source.contains("可爱") { "可爱生态成长".to_string() } else if source.contains("机械") { "机械微生物吞并进化".to_string() } else { "弱小逆袭和群体吞并".to_string() }; pack.gameplay_promise.status = BigFishAnchorStatus::Inferred; pack.ecology_visual_theme.value = if source.contains("机械") { "机械微生物水域".to_string() } else if source.contains("梦") { "梦境纸鱼生态".to_string() } else { "深海生物生态".to_string() }; pack.ecology_visual_theme.status = BigFishAnchorStatus::Inferred; pack.growth_ladder.value = "8 级连续进化,从幼小个体成长为终局巨兽".to_string(); pack.growth_ladder.status = BigFishAnchorStatus::Inferred; pack.risk_tempo.value = if source.contains("爽") { "偏爽快".to_string() } else if source.contains("压迫") { "偏压迫".to_string() } else { "平衡".to_string() }; pack } pub fn compile_default_draft(anchor_pack: &BigFishAnchorPack) -> BigFishGameDraft { let level_count = BIG_FISH_DEFAULT_LEVEL_COUNT; let theme = fallback_anchor_value(&anchor_pack.ecology_visual_theme, "深海生物生态"); let core_fun = fallback_anchor_value(&anchor_pack.gameplay_promise, "弱小逆袭和群体吞并"); let risk_tempo = fallback_anchor_value(&anchor_pack.risk_tempo, "平衡"); let levels = (1..=level_count) .map(|level| build_level_blueprint(level, level_count, &theme)) .collect(); BigFishGameDraft { title: format!("{theme} 大鱼吃小鱼"), subtitle: format!("{core_fun} · {risk_tempo}节奏"), core_fun, ecology_theme: theme.clone(), levels, background: BigFishBackgroundBlueprint { theme: theme.clone(), color_mood: "深蓝、青绿、带少量暖色生物光".to_string(), foreground_hints: "只保留少量漂浮颗粒和边缘水草,不遮挡中央操作区".to_string(), midground_composition: "中央留出大面积清晰活动区域,边缘只做出生缓冲层".to_string(), background_depth: "简洁纵深水域与极少量远处剪影".to_string(), safe_play_area_hint: "9:16 竖屏中央 80% 为主要活动区".to_string(), spawn_edge_hint: "四周边缘以少量暗礁或水草提示野生实体出生区".to_string(), background_prompt_seed: format!( "{theme},竖屏 9:16,全屏大场地游戏背景,元素少,中央开阔,无文字,无 UI 框" ), }, runtime_params: BigFishRuntimeParams { level_count, merge_count_per_upgrade: BIG_FISH_MERGE_COUNT_PER_UPGRADE, spawn_target_count: BIG_FISH_TARGET_WILD_COUNT as u32, leader_move_speed: 160.0, follower_catch_up_speed: 120.0, offscreen_cull_seconds: BIG_FISH_OFFSCREEN_CULL_SECONDS, prey_spawn_delta_levels: vec![1, 2], threat_spawn_delta_levels: vec![1, 2], win_level: level_count, }, } } pub fn build_asset_coverage( draft: Option<&BigFishGameDraft>, asset_slots: &[BigFishAssetSlotSnapshot], ) -> BigFishAssetCoverage { let required_level_count = draft .map(|value| value.runtime_params.level_count) .unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT); let main_ready = asset_slots .iter() .filter(|slot| { slot.asset_kind == BigFishAssetKind::LevelMainImage && slot.status == BigFishAssetStatus::Ready }) .count() as u32; let motion_ready = asset_slots .iter() .filter(|slot| { slot.asset_kind == BigFishAssetKind::LevelMotion && slot.status == BigFishAssetStatus::Ready }) .count() as u32; let background_ready = asset_slots.iter().any(|slot| { slot.asset_kind == BigFishAssetKind::StageBackground && slot.status == BigFishAssetStatus::Ready }); let required_motion_count = required_level_count * 2; let mut blockers = Vec::new(); if draft.is_none() { blockers.push("玩法草稿尚未编译".to_string()); } if main_ready < required_level_count { blockers.push(format!( "还缺少 {} 个等级主图", required_level_count.saturating_sub(main_ready) )); } if motion_ready < required_motion_count { blockers.push(format!( "还缺少 {} 个基础动作", required_motion_count.saturating_sub(motion_ready) )); } if !background_ready { blockers.push("还缺少活动区域背景图".to_string()); } BigFishAssetCoverage { level_main_image_ready_count: main_ready, level_motion_ready_count: motion_ready, background_ready, required_level_count, publish_ready: blockers.is_empty(), blockers, } } pub fn build_generated_asset_slot( session_id: &str, draft: &BigFishGameDraft, asset_kind: BigFishAssetKind, level: Option, motion_key: Option, asset_url: Option, updated_at_micros: i64, ) -> Result { let session_id = normalize_required_string(session_id).ok_or(BigFishFieldError::MissingSessionId)?; let prompt_snapshot = build_asset_prompt_snapshot(draft, asset_kind, level, motion_key.as_deref())?; let slot_id = build_asset_slot_id(&session_id, asset_kind, level, motion_key.as_deref()); let resolved_asset_url = normalize_required_string(asset_url.as_deref().unwrap_or_default()) .unwrap_or_else(|| build_placeholder_asset_url(asset_kind, level, updated_at_micros)); Ok(BigFishAssetSlotSnapshot { slot_id, session_id, asset_kind, level, motion_key, status: BigFishAssetStatus::Ready, asset_url: Some(resolved_asset_url), prompt_snapshot, updated_at_micros, }) } pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id) } pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> { if input.published_only { return Ok(()); } if normalize_required_string(&input.owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } Ok(()) } pub fn validate_session_create_input( input: &BigFishSessionCreateInput, ) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id)?; if normalize_required_string(&input.welcome_message_id).is_none() { return Err(BigFishFieldError::MissingMessageId); } Ok(()) } pub fn validate_message_submit_input( input: &BigFishMessageSubmitInput, ) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id)?; if normalize_required_string(&input.user_message_id).is_none() || normalize_required_string(&input.assistant_message_id).is_none() { return Err(BigFishFieldError::MissingMessageId); } if normalize_required_string(&input.user_message_text).is_none() { return Err(BigFishFieldError::MissingMessageText); } Ok(()) } pub fn validate_message_finalize_input( input: &BigFishMessageFinalizeInput, ) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id) } pub fn validate_draft_compile_input( input: &BigFishDraftCompileInput, ) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id) } pub fn validate_asset_generate_input( input: &BigFishAssetGenerateInput, draft: &BigFishGameDraft, ) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id)?; match input.asset_kind { BigFishAssetKind::LevelMainImage => validate_level(input.level, draft), BigFishAssetKind::LevelMotion => { validate_level(input.level, draft)?; match input.motion_key.as_deref() { Some("idle_float" | "move_swim") => Ok(()), _ => Err(BigFishFieldError::InvalidAssetKind), } } BigFishAssetKind::StageBackground => Ok(()), } } pub fn validate_publish_input(input: &BigFishPublishInput) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id) } pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(), BigFishFieldError> { if normalize_required_string(&input.session_id).is_none() { return Err(BigFishFieldError::MissingSessionId); } if normalize_required_string(&input.user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } Ok(()) } pub fn validate_like_record_input( input: &BigFishWorkLikeRecordInput, ) -> Result<(), BigFishFieldError> { if normalize_required_string(&input.session_id).is_none() { return Err(BigFishFieldError::MissingSessionId); } if normalize_required_string(&input.user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } Ok(()) } pub fn validate_work_remix_input(input: &BigFishWorkRemixInput) -> Result<(), BigFishFieldError> { if normalize_required_string(&input.source_session_id).is_none() || normalize_required_string(&input.target_session_id).is_none() { return Err(BigFishFieldError::MissingSessionId); } if normalize_required_string(&input.target_owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } if normalize_required_string(&input.welcome_message_id).is_none() { return Err(BigFishFieldError::MissingMessageId); } Ok(()) } pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> { validate_session_owner(&input.session_id, &input.owner_user_id)?; if normalize_required_string(&input.run_id).is_none() { return Err(BigFishFieldError::MissingRunId); } Ok(()) } pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> { if normalize_required_string(&input.run_id).is_none() { return Err(BigFishFieldError::MissingRunId); } if normalize_required_string(&input.owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } Ok(()) } pub fn validate_input_submit_input( input: &BigFishInputSubmitInput, ) -> Result<(), BigFishFieldError> { if normalize_required_string(&input.run_id).is_none() { return Err(BigFishFieldError::MissingRunId); } if normalize_required_string(&input.owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } if !input.x.is_finite() || !input.y.is_finite() { return Err(BigFishFieldError::InvalidRuntimeInput); } Ok(()) } pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result { serde_json::to_string(anchor_pack) } pub fn deserialize_anchor_pack(value: &str) -> Result { serde_json::from_str(value) } pub fn serialize_draft(draft: &BigFishGameDraft) -> Result { serde_json::to_string(draft) } pub fn deserialize_draft(value: &str) -> Result { serde_json::from_str(value) } pub fn serialize_asset_coverage( coverage: &BigFishAssetCoverage, ) -> Result { serde_json::to_string(coverage) } pub fn deserialize_asset_coverage(value: &str) -> Result { serde_json::from_str(value) } fn fallback_anchor_value(anchor: &BigFishAnchorItem, fallback: &str) -> String { normalize_required_string(&anchor.value).unwrap_or_else(|| fallback.to_string()) } fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLevelBlueprint { let prey_window = (1..level) .rev() .take(2) .collect::>() .into_iter() .rev() .collect(); let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::>(); let size_ratio = 1.0 + (level.saturating_sub(1) as f32 * 0.22); let name = format!("{theme} L{level}"); let one_line_fantasy = if level == level_count { "终局巨兽形态,获得即可通关".to_string() } else { format!("第 {level} 阶实体,继续吞噬同级和低级个体成长") }; let text_description = if level == 1 { format!( "{name} 是这套 {theme} 等级阶梯的起点个体,体型最小、动作轻盈,会在谨慎试探中寻找第一个可吞噬目标。" ) } else if level == level_count { format!( "{name} 是这套 {theme} 生态中的终局霸主形态,体格巨大、压迫感最强,一旦成型就代表本局成长链已经完成。" ) } else { format!( "{name} 是 {theme} 生态里的第 {level} 阶进化体,已经具备更鲜明的轮廓、猎食性和压迫感,会继续通过吞并同级与低级实体向上跃迁。" ) }; let visual_description = if level == 1 { format!( "{theme} 风格的小型初始鱼形生物,体态轻巧,轮廓圆润,局部带少量发光纹路或主题特征,明显呈现弱小但灵动的开局形象。" ) } else if level == level_count { format!( "{theme} 风格的终局巨型鱼形霸主,体长与鳍面明显扩张,轮廓锋利或威严,层次细节最丰富,拥有一眼可辨识的终局统治感。" ) } else { format!( "{theme} 风格的第 {level} 级进化鱼形生物,相比上一阶段更大、更强、更成熟,身体主轮廓更清晰,局部装饰、鳍面结构和主题特征都更明显。" ) }; let idle_motion_description = if level == level_count { "待机时缓慢悬停,身体主体保持稳定,尾鳍与侧鳍做低频摆动,呈现强者从容压场的漂浮感。" .to_string() } else { format!( "待机时保持轻微漂浮与呼吸感摆动,尾鳍和侧鳍以小幅度节奏晃动,体现 Lv.{level} 生物在水中蓄势观察的状态。" ) }; let move_motion_description = if level == level_count { "移动时身体前倾,尾鳍和背鳍形成强力推进姿态,带出稳定而有压迫感的高速巡游动势。".to_string() } else { format!( "移动时身体向前游动,尾鳍形成清晰摆尾推进,整体节奏比待机更主动,体现 Lv.{level} 生物追逐猎物时的连续游动感。" ) }; BigFishLevelBlueprint { level, name, one_line_fantasy, text_description, silhouette_direction: format!( "体型约为初始的 {:.1} 倍,轮廓更清晰", 1.0 + level as f32 * 0.22 ), size_ratio, visual_description: visual_description.clone(), visual_prompt_seed: format!( "{visual_description} 透明背景,单体完整入镜,适合作为竖屏吞噬成长玩法的等级主图。" ), idle_motion_description: idle_motion_description.clone(), move_motion_description: move_motion_description.clone(), motion_prompt_seed: format!( "待机动作:{idle_motion_description} 移动动作:{move_motion_description}" ), merge_source_level: if level == 1 { None } else { Some(level - 1) }, prey_window, threat_window, is_final_level: level == level_count, } } fn build_asset_prompt_snapshot( draft: &BigFishGameDraft, asset_kind: BigFishAssetKind, level: Option, motion_key: Option<&str>, ) -> Result { match asset_kind { BigFishAssetKind::LevelMainImage => { let level = level.ok_or(BigFishFieldError::InvalidLevel)?; let blueprint = draft .levels .iter() .find(|item| item.level == level) .ok_or(BigFishFieldError::InvalidLevel)?; Ok(blueprint.visual_prompt_seed.clone()) } BigFishAssetKind::LevelMotion => { let level = level.ok_or(BigFishFieldError::InvalidLevel)?; let blueprint = draft .levels .iter() .find(|item| item.level == level) .ok_or(BigFishFieldError::InvalidLevel)?; let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?; let motion_description = match motion_key { "idle_float" => blueprint.idle_motion_description.as_str(), "move_swim" => blueprint.move_motion_description.as_str(), _ => return Err(BigFishFieldError::InvalidAssetKind), }; Ok(format!( "{} 动作位:{}。{} 透明背景,单体完整入镜。", blueprint.motion_prompt_seed, motion_key, motion_description )) } BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()), } } fn build_asset_slot_id( session_id: &str, asset_kind: BigFishAssetKind, level: Option, motion_key: Option<&str>, ) -> String { let level_part = level .map(|value| value.to_string()) .unwrap_or_else(|| "stage".to_string()); let motion_part = motion_key.unwrap_or("main"); format!( "{BIG_FISH_ASSET_SLOT_ID_PREFIX}{session_id}_{}_{}_{}", asset_kind.as_str(), level_part, motion_part ) } fn build_placeholder_asset_url( asset_kind: BigFishAssetKind, level: Option, seed_micros: i64, ) -> String { let level_part = level .map(|value| format!("level-{value}")) .unwrap_or_else(|| "stage".to_string()); format!( "/generated-big-fish/{}/{}/{}.png", asset_kind.as_str(), level_part, seed_micros ) } fn validate_session_owner(session_id: &str, owner_user_id: &str) -> Result<(), BigFishFieldError> { if normalize_required_string(session_id).is_none() { return Err(BigFishFieldError::MissingSessionId); } if normalize_required_string(owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } Ok(()) } fn validate_level(level: Option, draft: &BigFishGameDraft) -> Result<(), BigFishFieldError> { match level { Some(value) if (1..=draft.runtime_params.level_count).contains(&value) => Ok(()), _ => Err(BigFishFieldError::InvalidLevel), } } #[cfg(test)] mod tests { use super::*; use crate::{ BigFishAssetKind, build_generated_asset_slot, compile_default_draft, infer_anchor_pack, }; fn build_command() -> EvaluateBigFishPublishReadinessCommand { EvaluateBigFishPublishReadinessCommand { session_id: "big-fish-session-1".to_string(), owner_user_id: "user-1".to_string(), draft: Some(compile_default_draft(&infer_anchor_pack("机械深海", None))), evaluated_at_micros: 1_713_680_000_000_000, } } #[test] fn evaluate_publish_readiness_reports_blockers_when_assets_missing() { let result = evaluate_publish_readiness(build_command(), &[]).expect("result"); assert!(!result.readiness.publish_ready); assert!( result .readiness .blockers .iter() .any(|item| item.contains("等级主图")) ); assert_eq!(result.events.len(), 1); } #[test] fn evaluate_publish_readiness_accepts_complete_assets() { let command = build_command(); let draft = command.draft.clone().expect("draft"); let mut slots = Vec::new(); for level in 1..=draft.runtime_params.level_count { slots.push( build_generated_asset_slot( &command.session_id, &draft, BigFishAssetKind::LevelMainImage, Some(level), None, Some(format!("/assets/level-{level}.png")), command.evaluated_at_micros + level as i64, ) .expect("main image slot"), ); for motion_key in ["idle_float", "move_swim"] { slots.push( build_generated_asset_slot( &command.session_id, &draft, BigFishAssetKind::LevelMotion, Some(level), Some(motion_key.to_string()), Some(format!("/assets/level-{level}-{motion_key}.webm")), command.evaluated_at_micros + 100 + level as i64, ) .expect("motion slot"), ); } } slots.push( build_generated_asset_slot( &command.session_id, &draft, BigFishAssetKind::StageBackground, None, None, Some("/assets/bg.png".to_string()), command.evaluated_at_micros + 1_000, ) .expect("background slot"), ); let result = evaluate_publish_readiness(command, &slots).expect("result"); assert!(result.readiness.publish_ready); assert!(result.readiness.blockers.is_empty()); } #[test] fn start_big_fish_run_builds_server_owned_initial_snapshot() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let result = start_big_fish_run(StartBigFishRunCommand { run_id: "big-fish-run-1".to_string(), session_id: "big-fish-session-1".to_string(), owner_user_id: "user-1".to_string(), draft: Some(draft), work_level_count: None, started_at_micros: 1, }) .expect("run"); assert_eq!(result.snapshot.status, BigFishRunStatus::Running); assert_eq!(result.snapshot.player_level, 1); assert_eq!(result.snapshot.win_level, 8); assert!(!result.snapshot.wild_entities.is_empty()); assert_eq!(result.events.len(), 1); } #[test] fn submit_big_fish_input_advances_and_keeps_runtime_truth_in_domain() { let mut result = start_big_fish_run(StartBigFishRunCommand { run_id: "big-fish-run-2".to_string(), session_id: "big-fish-session-2".to_string(), owner_user_id: "user-1".to_string(), draft: None, work_level_count: Some(3), started_at_micros: 1, }) .expect("run"); result.snapshot.wild_entities = vec![BigFishRuntimeEntitySnapshot { entity_id: "wild-touching".to_string(), level: 1, position: BigFishVector2 { x: 10.0, y: 0.0 }, radius: 22.0, offscreen_seconds: 0.0, }]; let advanced = submit_big_fish_input(SubmitBigFishInputCommand { owner_user_id: "user-1".to_string(), x: 0.0, y: 0.0, submitted_at_micros: 2, current_snapshot: result.snapshot, }) .expect("advanced"); assert_eq!(advanced.snapshot.tick, 1); assert!(advanced.snapshot.owned_entities.len() >= 2); assert!( advanced .snapshot .event_log .iter() .any(|event| event.contains("收编")) ); } }