Files
Genarrative/server-rs/crates/module-big-fish/src/lib.rs
kdletters 615d828add
Some checks failed
CI / verify (push) Has been cancelled
fix: show published big fish works in gallery
2026-04-27 00:10:15 +08:00

1493 lines
50 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 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<u32>,
pub prey_window: Vec<u32>,
pub threat_window: Vec<u32>,
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<u32>,
pub threat_spawn_delta_levels: Vec<u32>,
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<BigFishLevelBlueprint>,
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<u32>,
pub motion_key: Option<String>,
pub status: BigFishAssetStatus,
pub asset_url: Option<String>,
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<String>,
}
#[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<BigFishGameDraft>,
pub asset_slots: Vec<BigFishAssetSlotSnapshot>,
pub asset_coverage: BigFishAssetCoverage,
pub messages: Vec<BigFishAgentMessageSnapshot>,
pub last_assistant_reply: Option<String>,
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<String>,
pub owned_entities: Vec<BigFishRuntimeEntity>,
pub wild_entities: Vec<BigFishRuntimeEntity>,
pub camera_center: BigFishVector2,
pub last_input: BigFishVector2,
pub event_log: Vec<String>,
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<BigFishSessionSnapshot>,
pub error_message: Option<String>,
}
#[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<String>,
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<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
pub ok: bool,
pub run: Option<BigFishRuntimeSnapshot>,
pub error_message: Option<String>,
}
#[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<String>,
pub assistant_reply_text: Option<String>,
pub stage: BigFishCreationStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
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<u32>,
pub motion_key: Option<String>,
pub asset_url: Option<String>,
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<u32>,
motion_key: Option<String>,
asset_url: Option<String>,
updated_at_micros: i64,
) -> Result<BigFishAssetSlotSnapshot, BigFishFieldError> {
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<String, serde_json::Error> {
serde_json::to_string(anchor_pack)
}
pub fn deserialize_anchor_pack(value: &str) -> Result<BigFishAnchorPack, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_draft(draft: &BigFishGameDraft) -> Result<String, serde_json::Error> {
serde_json::to_string(draft)
}
pub fn deserialize_draft(value: &str) -> Result<BigFishGameDraft, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_asset_coverage(
coverage: &BigFishAssetCoverage,
) -> Result<String, serde_json::Error> {
serde_json::to_string(coverage)
}
pub fn deserialize_asset_coverage(value: &str) -> Result<BigFishAssetCoverage, serde_json::Error> {
serde_json::from_str(value)
}
pub fn serialize_runtime_snapshot(
snapshot: &BigFishRuntimeSnapshot,
) -> Result<String, serde_json::Error> {
serde_json::to_string(snapshot)
}
pub fn deserialize_runtime_snapshot(
value: &str,
) -> Result<BigFishRuntimeSnapshot, serde_json::Error> {
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::<Vec<_>>()
.into_iter()
.rev()
.collect();
let threat_window = ((level + 1)..=(level + 2).min(level_count)).collect::<Vec<_>>();
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<u32>,
motion_key: Option<&str>,
) -> Result<String, BigFishFieldError> {
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<u32>,
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<u32>,
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<u32>, 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::<Vec<_>>();
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<T>(items: &mut Vec<T>, 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")
);
}
}