use std::{error::Error, fmt}; use serde::{Deserialize, Serialize}; use shared_kernel::normalize_required_string; #[cfg(feature = "spacetime-types")] use spacetimedb::SpacetimeType; pub const BIG_FISH_SESSION_ID_PREFIX: &str = "big-fish-session-"; pub const BIG_FISH_MESSAGE_ID_PREFIX: &str = "big-fish-message-"; pub const BIG_FISH_OPERATION_ID_PREFIX: &str = "big-fish-operation-"; pub const BIG_FISH_ASSET_SLOT_ID_PREFIX: &str = "big-fish-asset-"; pub const BIG_FISH_RUN_ID_PREFIX: &str = "big-fish-run-"; pub const BIG_FISH_DEFAULT_LEVEL_COUNT: u32 = 8; pub const BIG_FISH_MIN_LEVEL_COUNT: u32 = 6; pub const BIG_FISH_MAX_LEVEL_COUNT: u32 = 12; pub const BIG_FISH_MERGE_COUNT_PER_UPGRADE: u32 = 3; pub const BIG_FISH_OFFSCREEN_CULL_SECONDS: f32 = 3.0; pub const BIG_FISH_TARGET_WILD_COUNT: usize = 12; pub const BIG_FISH_VIEW_WIDTH: f32 = 720.0; pub const BIG_FISH_VIEW_HEIGHT: f32 = 1280.0; pub const BIG_FISH_WORLD_HALF_WIDTH: f32 = 900.0; pub const BIG_FISH_WORLD_HALF_HEIGHT: f32 = 1600.0; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishCreationStage { CollectingAnchors, DraftReady, AssetRefining, ReadyToPublish, Published, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishAnchorStatus { Confirmed, Inferred, Missing, Locked, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishAgentMessageRole { User, Assistant, System, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishAgentMessageKind { Chat, Summary, ActionResult, Warning, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishAssetKind { LevelMainImage, LevelMotion, StageBackground, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishAssetStatus { Missing, Ready, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BigFishRunStatus { Running, Won, Failed, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishAnchorItem { pub key: String, pub label: String, pub value: String, pub status: BigFishAnchorStatus, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishAnchorPack { pub gameplay_promise: BigFishAnchorItem, pub ecology_visual_theme: BigFishAnchorItem, pub growth_ladder: BigFishAnchorItem, pub risk_tempo: BigFishAnchorItem, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishLevelBlueprint { pub level: u32, pub name: String, pub one_line_fantasy: String, pub silhouette_direction: String, pub size_ratio: f32, pub visual_prompt_seed: String, pub motion_prompt_seed: String, pub merge_source_level: Option, pub prey_window: Vec, pub threat_window: Vec, pub is_final_level: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishBackgroundBlueprint { pub theme: String, pub color_mood: String, pub foreground_hints: String, pub midground_composition: String, pub background_depth: String, pub safe_play_area_hint: String, pub spawn_edge_hint: String, pub background_prompt_seed: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRuntimeParams { pub level_count: u32, pub merge_count_per_upgrade: u32, pub spawn_target_count: u32, pub leader_move_speed: f32, pub follower_catch_up_speed: f32, pub offscreen_cull_seconds: f32, pub prey_spawn_delta_levels: Vec, pub threat_spawn_delta_levels: Vec, pub win_level: u32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishGameDraft { pub title: String, pub subtitle: String, pub core_fun: String, pub ecology_theme: String, pub levels: Vec, pub background: BigFishBackgroundBlueprint, pub runtime_params: BigFishRuntimeParams, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishAgentMessageSnapshot { pub message_id: String, pub session_id: String, pub role: BigFishAgentMessageRole, pub kind: BigFishAgentMessageKind, pub text: String, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishAssetSlotSnapshot { pub slot_id: String, pub session_id: String, pub asset_kind: BigFishAssetKind, pub level: Option, pub motion_key: Option, pub status: BigFishAssetStatus, pub asset_url: Option, pub prompt_snapshot: String, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishAssetCoverage { pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, pub required_level_count: u32, pub publish_ready: bool, pub blockers: Vec, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishSessionSnapshot { pub session_id: String, pub owner_user_id: String, pub seed_text: String, pub current_turn: u32, pub progress_percent: u32, pub stage: BigFishCreationStage, pub anchor_pack: BigFishAnchorPack, pub draft: Option, pub asset_slots: Vec, pub asset_coverage: BigFishAssetCoverage, pub messages: Vec, pub last_assistant_reply: Option, pub publish_ready: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishVector2 { pub x: f32, pub y: f32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRuntimeEntity { pub entity_id: String, pub level: u32, pub position: BigFishVector2, pub radius: f32, pub offscreen_seconds: f32, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRuntimeSnapshot { pub run_id: String, pub session_id: String, pub status: BigFishRunStatus, pub tick: u64, pub player_level: u32, pub win_level: u32, pub leader_entity_id: Option, pub owned_entities: Vec, pub wild_entities: Vec, pub camera_center: BigFishVector2, pub last_input: BigFishVector2, pub event_log: Vec, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishSessionProcedureResult { pub ok: bool, pub session: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorkSummarySnapshot { pub work_id: String, pub source_session_id: String, pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, pub cover_image_src: Option, pub status: String, pub updated_at_micros: i64, pub publish_ready: bool, pub level_count: u32, pub level_main_image_ready_count: u32, pub level_motion_ready_count: u32, pub background_ready: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksListInput { pub owner_user_id: String, pub published_only: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorkDeleteInput { pub session_id: String, pub owner_user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksProcedureResult { pub ok: bool, pub items_json: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRunProcedureResult { pub ok: bool, pub run: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishSessionCreateInput { pub session_id: String, pub owner_user_id: String, pub seed_text: String, pub welcome_message_id: String, pub welcome_message_text: String, pub created_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishSessionGetInput { pub session_id: String, pub owner_user_id: String, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishMessageSubmitInput { pub session_id: String, pub owner_user_id: String, pub user_message_id: String, pub user_message_text: String, pub assistant_message_id: String, pub submitted_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishMessageFinalizeInput { pub session_id: String, pub owner_user_id: String, pub assistant_message_id: Option, pub assistant_reply_text: Option, pub stage: BigFishCreationStage, pub progress_percent: u32, pub anchor_pack_json: String, pub error_message: Option, pub updated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishDraftCompileInput { pub session_id: String, pub owner_user_id: String, pub compiled_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishAssetGenerateInput { pub session_id: String, pub owner_user_id: String, pub asset_kind: BigFishAssetKind, pub level: Option, pub motion_key: Option, pub asset_url: Option, pub generated_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishPublishInput { pub session_id: String, pub owner_user_id: String, pub published_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishRunStartInput { pub run_id: String, pub session_id: String, pub owner_user_id: String, pub started_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRunInputSubmitInput { pub run_id: String, pub owner_user_id: String, pub input_x: f32, pub input_y: f32, pub submitted_at_micros: i64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishRunGetInput { pub run_id: String, pub owner_user_id: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum BigFishFieldError { MissingSessionId, MissingOwnerUserId, MissingMessageId, MissingMessageText, MissingRunId, MissingDraft, InvalidLevel, InvalidAssetKind, InvalidRunState, } impl BigFishCreationStage { pub fn as_str(self) -> &'static str { match self { Self::CollectingAnchors => "collecting_anchors", Self::DraftReady => "draft_ready", Self::AssetRefining => "asset_refining", Self::ReadyToPublish => "ready_to_publish", Self::Published => "published", } } } impl BigFishAnchorStatus { pub fn as_str(self) -> &'static str { match self { Self::Confirmed => "confirmed", Self::Inferred => "inferred", Self::Missing => "missing", Self::Locked => "locked", } } } impl BigFishAgentMessageRole { pub fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Assistant => "assistant", Self::System => "system", } } } impl BigFishAgentMessageKind { pub fn as_str(self) -> &'static str { match self { Self::Chat => "chat", Self::Summary => "summary", Self::ActionResult => "action_result", Self::Warning => "warning", } } } impl BigFishAssetKind { pub fn as_str(self) -> &'static str { match self { Self::LevelMainImage => "level_main_image", Self::LevelMotion => "level_motion", Self::StageBackground => "stage_background", } } } impl BigFishAssetStatus { pub fn as_str(self) -> &'static str { match self { Self::Missing => "missing", Self::Ready => "ready", } } } impl BigFishRunStatus { pub fn as_str(self) -> &'static str { match self { Self::Running => "running", Self::Won => "won", Self::Failed => "failed", } } } 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 竖屏中央 70% 为主要活动区".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 build_initial_runtime_snapshot( run_id: String, session_id: String, draft: &BigFishGameDraft, now_micros: i64, ) -> BigFishRuntimeSnapshot { let mut snapshot = BigFishRuntimeSnapshot { run_id, session_id, status: BigFishRunStatus::Running, tick: 0, player_level: 1, win_level: draft.runtime_params.win_level, leader_entity_id: Some("owned-1".to_string()), owned_entities: vec![BigFishRuntimeEntity { entity_id: "owned-1".to_string(), level: 1, position: BigFishVector2 { x: 0.0, y: 0.0 }, radius: entity_radius(1), offscreen_seconds: 0.0, }], wild_entities: vec![ BigFishRuntimeEntity { entity_id: "wild-open-1".to_string(), level: 1, position: BigFishVector2 { x: 72.0, y: 0.0 }, radius: entity_radius(1), offscreen_seconds: 0.0, }, BigFishRuntimeEntity { entity_id: "wild-open-2".to_string(), level: 1, position: BigFishVector2 { x: -88.0, y: 30.0 }, radius: entity_radius(1), offscreen_seconds: 0.0, }, ], camera_center: BigFishVector2 { x: 0.0, y: 0.0 }, last_input: BigFishVector2 { x: 0.0, y: 0.0 }, event_log: vec!["开局生成 2 个同级可收编目标".to_string()], updated_at_micros: now_micros, }; maintain_wild_pool(&mut snapshot, &draft.runtime_params); snapshot } pub fn advance_runtime_snapshot( mut snapshot: BigFishRuntimeSnapshot, params: &BigFishRuntimeParams, input_x: f32, input_y: f32, now_micros: i64, ) -> BigFishRuntimeSnapshot { if snapshot.status != BigFishRunStatus::Running { return snapshot; } let step_seconds = resolve_step_seconds(&snapshot, now_micros); snapshot.tick = snapshot.tick.saturating_add(1); snapshot.last_input = normalize_input(input_x, input_y); move_owned_entities(&mut snapshot, params, step_seconds); resolve_collisions(&mut snapshot, params); apply_chain_merges(&mut snapshot, params); refresh_player_leader(&mut snapshot); apply_win_or_fail(&mut snapshot, params); update_wild_culling(&mut snapshot, params, step_seconds); maintain_wild_pool(&mut snapshot, params); snapshot.updated_at_micros = now_micros; snapshot } 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_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_run_input_submit_input( input: &BigFishRunInputSubmitInput, ) -> 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 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) } 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 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::>(); BigFishLevelBlueprint { level, name: format!("{theme} L{level}"), one_line_fantasy: if level == level_count { "终局巨兽形态,获得即可通关".to_string() } else { format!("第 {level} 阶实体,继续吞噬同级和低级个体成长") }, silhouette_direction: format!( "体型约为初始的 {:.1} 倍,轮廓更清晰", 1.0 + level as f32 * 0.22 ), size_ratio: 1.0 + (level.saturating_sub(1) as f32 * 0.22), visual_prompt_seed: format!("{theme} 第 {level} 级实体主图,透明背景,清晰轮廓"), motion_prompt_seed: format!("{theme} 第 {level} 级实体 idle_float 与 move_swim 动作"), 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)?; Ok(format!( "{},动作位:{}", blueprint.motion_prompt_seed, motion_key )) } 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), } } fn normalize_input(x: f32, y: f32) -> BigFishVector2 { let length = (x * x + y * y).sqrt(); if length <= 1.0 { return BigFishVector2 { x, y }; } BigFishVector2 { x: x / length, y: y / length, } } /// 运行态仍由 `POST input` 触发推进,因此“屏外 3 秒”这类规则必须按真实秒数累计, /// 否则会随着输入频率变化而漂移。 fn resolve_step_seconds(snapshot: &BigFishRuntimeSnapshot, now_micros: i64) -> f32 { ((now_micros - snapshot.updated_at_micros).max(0) as f32) / 1_000_000.0 } fn move_owned_entities( snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams, step_seconds: f32, ) { let input = snapshot.last_input.clone(); if let Some(leader) = snapshot.owned_entities.first_mut() { leader.position.x = clamp_world( leader.position.x + input.x * params.leader_move_speed * step_seconds, true, ); leader.position.y = clamp_world( leader.position.y + input.y * params.leader_move_speed * step_seconds, false, ); snapshot.camera_center = leader.position.clone(); } let leader_position = snapshot.camera_center.clone(); for (index, follower) in snapshot.owned_entities.iter_mut().enumerate().skip(1) { let slot_offset = ((index as f32) * 0.7).sin() * 36.0; let target = BigFishVector2 { x: leader_position.x - 42.0 - index as f32 * 8.0, y: leader_position.y + slot_offset, }; let delta_x = target.x - follower.position.x; let delta_y = target.y - follower.position.y; let distance = (delta_x * delta_x + delta_y * delta_y).sqrt(); if distance <= f32::EPSILON { continue; } let catch_up_ratio = (params.follower_catch_up_speed * step_seconds / distance).clamp(0.0, 1.0); follower.position.x += delta_x * catch_up_ratio; follower.position.y += delta_y * catch_up_ratio; } } fn resolve_collisions(snapshot: &mut BigFishRuntimeSnapshot, _params: &BigFishRuntimeParams) { let mut owned_to_remove = Vec::new(); let mut wild_to_remove = Vec::new(); let mut newly_owned = Vec::new(); for (owned_index, owned) in snapshot.owned_entities.iter().enumerate() { for (wild_index, wild) in snapshot.wild_entities.iter().enumerate() { if wild_to_remove.contains(&wild_index) || owned_to_remove.contains(&owned_index) { continue; } if distance(&owned.position, &wild.position) > owned.radius + wild.radius { continue; } if owned.level >= wild.level { wild_to_remove.push(wild_index); newly_owned.push(BigFishRuntimeEntity { entity_id: format!("owned-from-{}-{}", wild.entity_id, snapshot.tick), level: wild.level, position: wild.position.clone(), radius: entity_radius(wild.level), offscreen_seconds: 0.0, }); snapshot .event_log .push(format!("收编 {} 级实体", wild.level)); } else { owned_to_remove.push(owned_index); snapshot.event_log.push(format!( "{} 级己方实体被 {} 级野生实体吃掉", owned.level, wild.level )); } } } remove_indices(&mut snapshot.wild_entities, &wild_to_remove); remove_indices(&mut snapshot.owned_entities, &owned_to_remove); snapshot.owned_entities.extend(newly_owned); } fn apply_chain_merges(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) { loop { let mut merged = false; for level in 1..params.win_level { let indices = snapshot .owned_entities .iter() .enumerate() .filter_map(|(index, entity)| (entity.level == level).then_some(index)) .take(params.merge_count_per_upgrade as usize) .collect::>(); if indices.len() < params.merge_count_per_upgrade as usize { continue; } let center = average_position(&indices, &snapshot.owned_entities); remove_indices(&mut snapshot.owned_entities, &indices); snapshot.owned_entities.push(BigFishRuntimeEntity { entity_id: format!("owned-merge-{}-{}", level + 1, snapshot.tick), level: level + 1, position: center, radius: entity_radius(level + 1), offscreen_seconds: 0.0, }); snapshot .event_log .push(format!("3 个 {} 级实体合成 {} 级", level, level + 1)); merged = true; break; } if !merged { break; } } } fn refresh_player_leader(snapshot: &mut BigFishRuntimeSnapshot) { snapshot.owned_entities.sort_by(|left, right| { right .level .cmp(&left.level) .then_with(|| { distance(&left.position, &snapshot.camera_center) .partial_cmp(&distance(&right.position, &snapshot.camera_center)) .unwrap_or(std::cmp::Ordering::Equal) }) .then_with(|| left.entity_id.cmp(&right.entity_id)) }); snapshot.leader_entity_id = snapshot .owned_entities .first() .map(|entity| entity.entity_id.clone()); snapshot.player_level = snapshot .owned_entities .iter() .map(|entity| entity.level) .max() .unwrap_or(0); if let Some(leader) = snapshot.owned_entities.first() { snapshot.camera_center = leader.position.clone(); } } fn apply_win_or_fail(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) { if snapshot.owned_entities.is_empty() { snapshot.status = BigFishRunStatus::Failed; snapshot .event_log .push("己方实体归零,本局失败".to_string()); return; } if snapshot.player_level >= params.win_level { snapshot.status = BigFishRunStatus::Won; snapshot .event_log .push("获得最高等级实体,通关".to_string()); } } fn update_wild_culling( snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams, step_seconds: f32, ) { let player_level = snapshot.player_level; for wild in &mut snapshot.wild_entities { let should_cull_level = wild.level == player_level || wild.level >= player_level.saturating_add(3) || wild.level.saturating_add(3) <= player_level; if !should_cull_level { wild.offscreen_seconds = 0.0; continue; } if is_offscreen(&wild.position, &snapshot.camera_center, wild.radius) { wild.offscreen_seconds += step_seconds; } else { wild.offscreen_seconds = 0.0; } } snapshot .wild_entities .retain(|wild| wild.offscreen_seconds < params.offscreen_cull_seconds); } fn maintain_wild_pool(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) { if snapshot.status != BigFishRunStatus::Running { return; } let mut next_index = snapshot.wild_entities.len() + snapshot.tick as usize; while snapshot.wild_entities.len() < params.spawn_target_count as usize { let level = next_spawn_level(snapshot.player_level.max(1), params.win_level, next_index); snapshot.wild_entities.push(BigFishRuntimeEntity { entity_id: format!("wild-{}-{}", snapshot.tick, next_index), level, position: spawn_position(&snapshot.camera_center, next_index), radius: entity_radius(level), offscreen_seconds: 0.0, }); next_index += 1; } } fn next_spawn_level(player_level: u32, win_level: u32, index: usize) -> u32 { if player_level == 1 && index % 4 < 2 { return 1; } let deltas = [-2_i32, -1, 1, 2]; let delta = deltas[index % deltas.len()]; (player_level as i32 + delta).clamp(1, win_level as i32) as u32 } fn spawn_position(center: &BigFishVector2, index: usize) -> BigFishVector2 { let side = index % 4; let offset = ((index as f32 * 37.0) % 420.0) - 210.0; match side { 0 => BigFishVector2 { x: center.x - BIG_FISH_VIEW_WIDTH * 0.62, y: center.y + offset, }, 1 => BigFishVector2 { x: center.x + BIG_FISH_VIEW_WIDTH * 0.62, y: center.y + offset, }, 2 => BigFishVector2 { x: center.x + offset, y: center.y - BIG_FISH_VIEW_HEIGHT * 0.58, }, _ => BigFishVector2 { x: center.x + offset, y: center.y + BIG_FISH_VIEW_HEIGHT * 0.58, }, } } fn remove_indices(items: &mut Vec, indices: &[usize]) { let mut sorted = indices.to_vec(); sorted.sort_unstable(); sorted.dedup(); for index in sorted.into_iter().rev() { if index < items.len() { items.remove(index); } } } fn average_position(indices: &[usize], entities: &[BigFishRuntimeEntity]) -> BigFishVector2 { let mut x = 0.0; let mut y = 0.0; for index in indices { x += entities[*index].position.x; y += entities[*index].position.y; } let count = indices.len().max(1) as f32; BigFishVector2 { x: x / count, y: y / count, } } fn distance(left: &BigFishVector2, right: &BigFishVector2) -> f32 { let dx = left.x - right.x; let dy = left.y - right.y; (dx * dx + dy * dy).sqrt() } fn is_offscreen(position: &BigFishVector2, camera: &BigFishVector2, radius: f32) -> bool { let half_w = BIG_FISH_VIEW_WIDTH / 2.0; let half_h = BIG_FISH_VIEW_HEIGHT / 2.0; position.x + radius < camera.x - half_w || position.x - radius > camera.x + half_w || position.y + radius < camera.y - half_h || position.y - radius > camera.y + half_h } fn clamp_world(value: f32, horizontal: bool) -> f32 { let limit = if horizontal { BIG_FISH_WORLD_HALF_WIDTH } else { BIG_FISH_WORLD_HALF_HEIGHT }; value.clamp(-limit, limit) } fn entity_radius(level: u32) -> f32 { 18.0 + level as f32 * 4.0 } impl fmt::Display for BigFishFieldError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"), Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"), Self::MissingMessageId => f.write_str("big_fish.message_id 不能为空"), Self::MissingMessageText => f.write_str("big_fish.message_text 不能为空"), Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"), Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"), Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"), Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"), Self::InvalidRunState => f.write_str("big_fish.run 当前状态不允许推进"), } } } impl Error for BigFishFieldError {} #[cfg(test)] mod tests { use super::*; #[test] fn default_draft_compiles_eight_levels_with_fixed_runtime_params() { let draft = compile_default_draft(&infer_anchor_pack("机械深海,节奏偏爽", None)); assert_eq!(draft.levels.len(), BIG_FISH_DEFAULT_LEVEL_COUNT as usize); assert_eq!(draft.runtime_params.merge_count_per_upgrade, 3); assert_eq!(draft.runtime_params.offscreen_cull_seconds, 3.0); assert_eq!(draft.runtime_params.prey_spawn_delta_levels, vec![1, 2]); assert_eq!(draft.runtime_params.threat_spawn_delta_levels, vec![1, 2]); assert!( draft .levels .last() .is_some_and(|level| level.is_final_level) ); } #[test] fn asset_coverage_requires_main_images_two_motions_and_background() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let coverage = build_asset_coverage(Some(&draft), &[]); assert!(!coverage.publish_ready); assert_eq!(coverage.required_level_count, 8); assert!( coverage .blockers .iter() .any(|item| item.contains("等级主图")) ); assert!( coverage .blockers .iter() .any(|item| item.contains("基础动作")) ); assert!(coverage.blockers.iter().any(|item| item.contains("背景图"))); } #[test] fn same_level_wild_entity_can_be_collected_at_start() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let mut snapshot = build_initial_runtime_snapshot("run-1".to_string(), "session-1".to_string(), &draft, 1); snapshot.wild_entities[0].position = BigFishVector2 { x: 1.0, y: 0.0 }; let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2); assert!(next.owned_entities.len() >= 2); assert!( next.event_log .iter() .any(|event| event.contains("收编 1 级实体")) ); } #[test] fn three_owned_entities_merge_into_next_level() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let mut snapshot = build_initial_runtime_snapshot( "run-merge".to_string(), "session-merge".to_string(), &draft, 1, ); snapshot.wild_entities.clear(); snapshot.owned_entities.push(BigFishRuntimeEntity { entity_id: "owned-2".to_string(), level: 1, position: BigFishVector2 { x: 4.0, y: 0.0 }, radius: entity_radius(1), offscreen_seconds: 0.0, }); snapshot.owned_entities.push(BigFishRuntimeEntity { entity_id: "owned-3".to_string(), level: 1, position: BigFishVector2 { x: 8.0, y: 0.0 }, radius: entity_radius(1), offscreen_seconds: 0.0, }); let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2); assert!(next.owned_entities.iter().any(|entity| entity.level == 2)); } #[test] fn final_level_immediately_wins() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let mut snapshot = build_initial_runtime_snapshot( "run-win".to_string(), "session-win".to_string(), &draft, 1, ); snapshot.owned_entities[0].level = draft.runtime_params.win_level; let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2); assert_eq!(next.status, BigFishRunStatus::Won); } #[test] fn offscreen_same_level_wild_entity_is_removed_after_three_seconds() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let mut snapshot = build_initial_runtime_snapshot( "run-cull".to_string(), "session-cull".to_string(), &draft, 1, ); snapshot.wild_entities.clear(); snapshot.wild_entities.push(BigFishRuntimeEntity { entity_id: "wild-cull".to_string(), level: 1, position: BigFishVector2 { x: 1000.0, y: 0.0 }, radius: entity_radius(1), offscreen_seconds: 2.8, }); snapshot.updated_at_micros = 1_000_000; let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 1_250_000); assert!( !next .wild_entities .iter() .any(|entity| entity.entity_id == "wild-cull") ); } #[test] fn offscreen_same_level_wild_entity_is_kept_before_three_seconds_elapsed() { let draft = compile_default_draft(&infer_anchor_pack("深海", None)); let mut snapshot = build_initial_runtime_snapshot( "run-cull-safe".to_string(), "session-cull-safe".to_string(), &draft, 1, ); snapshot.wild_entities.clear(); snapshot.wild_entities.push(BigFishRuntimeEntity { entity_id: "wild-cull-safe".to_string(), level: 1, position: BigFishVector2 { x: 1000.0, y: 0.0 }, radius: entity_radius(1), offscreen_seconds: 2.7, }); snapshot.updated_at_micros = 1_000_000; let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 1_200_000); assert!( next.wild_entities .iter() .any(|entity| entity.entity_id == "wild-cull-safe") ); } }