Files
Genarrative/server-rs/crates/module-big-fish/src/application.rs
kdletters 8f4ca9abfa Merge remote-tracking branch 'origin/master' into codex/ddd
# Conflicts:
#	docs/technical/README.md
#	docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
#	docs/technical/SPACETIMEDB_TABLE_CATALOG.md
#	scripts/generate-spacetime-bindings.mjs
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/assets.rs
#	server-rs/crates/api-server/src/big_fish.rs
#	server-rs/crates/api-server/src/custom_world_ai.rs
#	server-rs/crates/api-server/src/llm.rs
#	server-rs/crates/api-server/src/main.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/api-server/src/runtime_profile.rs
#	server-rs/crates/api-server/src/runtime_story/compat/ai.rs
#	server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs
#	server-rs/crates/api-server/src/runtime_story/compat/presentation.rs
#	server-rs/crates/api-server/src/runtime_story/compat/tests.rs
#	server-rs/crates/api-server/src/state.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/module-big-fish/src/lib.rs
#	server-rs/crates/module-custom-world/src/lib.rs
#	server-rs/crates/module-puzzle/src/lib.rs
#	server-rs/crates/module-runtime/src/lib.rs
#	server-rs/crates/spacetime-client/src/big_fish.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	server-rs/crates/spacetime-client/src/mapper.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_redeem_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/advance_puzzle_next_level_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/append_ai_text_chunk_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/apply_chapter_progression_ledger_entry_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/attach_ai_result_reference_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/authorize_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/begin_story_session_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_run_type.rs
#	server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/cancel_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/clear_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_big_fish_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_custom_world_published_profile_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/compile_puzzle_agent_draft_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_stage_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/complete_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/consume_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/continue_story_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_battle_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_profile_recharge_order_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/create_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/delete_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/drag_puzzle_piece_or_group_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/execute_custom_world_agent_action_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_auth_store_snapshot_from_tables_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/fail_ai_task_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_big_fish_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_custom_world_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/finalize_puzzle_agent_message_turn_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/generate_big_fish_asset_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_battle_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_big_fish_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_chapter_progression_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_card_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_by_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_library_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_player_progression_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_dashboard_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_play_stats_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_profile_referral_invite_center_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_agent_session_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_inventory_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_setting_or_default_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_runtime_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/get_story_session_state_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/grant_player_progression_experience_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_file_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_asset_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_big_fish_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_gallery_entries_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_profiles_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_custom_world_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_platform_browse_history_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_save_archives_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_profile_wallet_ledger_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_gallery_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/list_puzzle_works_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/mod.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_big_fish_game_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_custom_world_world_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/publish_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_play_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_referral_invite_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/redeem_profile_reward_code_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/refund_profile_wallet_points_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_combat_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_battle_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_npc_social_action_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resolve_treasure_interaction_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/resume_profile_save_archive_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/revoke_database_migration_operator_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_generated_images_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/select_puzzle_cover_image_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/start_puzzle_run_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_big_fish_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_custom_world_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_agent_message_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/submit_puzzle_leaderboard_entry_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/swap_puzzle_pieces_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/unpublish_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/update_puzzle_work_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_chapter_progression_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_profile_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_npc_state_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_platform_browse_history_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_setting_and_return_procedure.rs
#	server-rs/crates/spacetime-client/src/module_bindings/upsert_runtime_snapshot_and_return_procedure.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	server-rs/crates/spacetime-module/src/custom_world/mod.rs
#	server-rs/crates/spacetime-module/src/lib.rs
#	server-rs/crates/spacetime-module/src/migration.rs
#	server-rs/crates/spacetime-module/src/puzzle.rs
#	server-rs/crates/spacetime-module/src/runtime/profile.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
#	src/services/aiService.ts
#	src/services/puzzle-runtime/puzzleRuntimeClient.ts
2026-05-02 03:35:59 +08:00

1267 lines
45 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 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<BigFishDomainEvent>,
}
/// 运行态推进应用结果。
#[derive(Clone, Debug, PartialEq)]
pub struct BigFishRuntimeResult {
pub snapshot: BigFishRuntimeSnapshot,
pub events: Vec<BigFishDomainEvent>,
}
/// 评估 Big Fish 作品是否具备发布条件。
///
/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图
/// 必须满足 `build_asset_coverage` 的统一口径。
pub fn evaluate_publish_readiness(
command: EvaluateBigFishPublishReadinessCommand,
asset_slots: &[BigFishAssetSlotSnapshot],
) -> Result<EvaluateBigFishPublishReadinessResult, BigFishApplicationError> {
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<BigFishRuntimeResult, BigFishApplicationError> {
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<BigFishRuntimeResult, BigFishApplicationError> {
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(&current_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::<Vec<_>>();
let mut events = snapshot.event_log;
let mut removed_wild = Vec::<String>::new();
let mut removed_owned = Vec::<String>::new();
let mut newly_owned = Vec::<BigFishRuntimeEntitySnapshot>::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<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 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<BigFishRuntimeEntitySnapshot>,
events: Vec<String>,
}
fn merge_owned_entities(
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
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::<Vec<_>>();
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::<Vec<_>>();
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<BigFishRuntimeEntitySnapshot>,
) -> Vec<BigFishRuntimeEntitySnapshot> {
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<String>) -> Vec<String> {
events
.into_iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
fn settlement_events(
snapshot: &BigFishRuntimeSnapshot,
owner_user_id: String,
occurred_at_micros: i64,
) -> Vec<BigFishDomainEvent> {
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<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 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<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)
}
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<_>>();
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<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)?;
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<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),
}
}
#[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("收编"))
);
}
}