# 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
1267 lines
45 KiB
Rust
1267 lines
45 KiB
Rust
//! 大鱼吃小鱼应用编排。
|
||
//!
|
||
//! 这里只组合领域规则并返回结果或事件,不直接调用外部图片、视频或存储服务。
|
||
|
||
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(¤t_leader, &normalized_input);
|
||
let mut owned_entities = vec![next_leader.clone()];
|
||
for (index, follower) in sorted_owned.drain(1..).enumerate() {
|
||
owned_entities.push(move_follower(&follower, &next_leader, index + 1));
|
||
}
|
||
let mut wild_entities = snapshot
|
||
.wild_entities
|
||
.into_iter()
|
||
.map(|entity| move_wild_entity(&entity, next_tick))
|
||
.collect::<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("收编"))
|
||
);
|
||
}
|
||
}
|