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
This commit is contained in:
879
server-rs/crates/module-match3d/src/application.rs
Normal file
879
server-rs/crates/module-match3d/src/application.rs
Normal file
@@ -0,0 +1,879 @@
|
||||
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
||||
|
||||
use crate::commands::{default_tags_for_theme, validate_result_publish_fields};
|
||||
use crate::{
|
||||
MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN,
|
||||
MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR,
|
||||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS,
|
||||
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason,
|
||||
Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot,
|
||||
Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot,
|
||||
Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
|
||||
};
|
||||
|
||||
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
|
||||
let game_name = format!("{}抓大鹅", config.theme_text);
|
||||
let summary = format!(
|
||||
"{}主题,{} 次消除目标,难度 {}。",
|
||||
config.theme_text, config.clear_count, config.difficulty
|
||||
);
|
||||
let tags = default_tags_for_theme(&config.theme_text);
|
||||
let mut draft = Match3DResultDraft {
|
||||
game_name,
|
||||
theme_text: config.theme_text.clone(),
|
||||
summary,
|
||||
tags,
|
||||
cover_image_src: None,
|
||||
reference_image_src: config.reference_image_src.clone(),
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
};
|
||||
draft.blockers = validate_result_publish_fields(&draft);
|
||||
draft.publish_ready = draft.blockers.is_empty();
|
||||
|
||||
draft
|
||||
}
|
||||
|
||||
/// 校验发布所需基础字段;试玩通关不是首版发布门槛。
|
||||
|
||||
pub fn create_work_profile(
|
||||
work_id: String,
|
||||
profile_id: String,
|
||||
owner_user_id: String,
|
||||
source_session_id: Option<String>,
|
||||
draft: &Match3DResultDraft,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<Match3DWorkProfile, Match3DFieldError> {
|
||||
let work_id = normalize_required_string(work_id).ok_or(Match3DFieldError::MissingText)?;
|
||||
let profile_id =
|
||||
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||
let owner_user_id =
|
||||
normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?;
|
||||
|
||||
Ok(Match3DWorkProfile {
|
||||
work_id,
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
source_session_id: normalize_optional_string(source_session_id),
|
||||
game_name: draft.game_name.clone(),
|
||||
theme_text: draft.theme_text.clone(),
|
||||
summary: draft.summary.clone(),
|
||||
tags: normalize_string_list(draft.tags.clone()),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
reference_image_src: draft.reference_image_src.clone(),
|
||||
clear_count: draft.clear_count,
|
||||
difficulty: draft.difficulty,
|
||||
publication_status: Match3DPublicationStatus::Draft,
|
||||
play_count: 0,
|
||||
updated_at_micros,
|
||||
published_at_micros: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 发布作品时只改变发布状态和时间戳,不在领域层写数据库。
|
||||
pub fn publish_work_profile(
|
||||
profile: &Match3DWorkProfile,
|
||||
published_at_micros: i64,
|
||||
) -> Result<Match3DWorkProfile, Match3DFieldError> {
|
||||
if profile.clear_count == 0 {
|
||||
return Err(Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&profile.difficulty) {
|
||||
return Err(Match3DFieldError::InvalidDifficulty);
|
||||
}
|
||||
|
||||
let mut next = profile.clone();
|
||||
next.publication_status = Match3DPublicationStatus::Published;
|
||||
next.updated_at_micros = published_at_micros;
|
||||
next.published_at_micros = Some(published_at_micros);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// 用确定性 seed 生成单局初始快照,便于后端权威复现和测试。
|
||||
pub fn start_run_with_seed_at(
|
||||
run_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
config: &Match3DCreatorConfig,
|
||||
seed: u64,
|
||||
started_at_ms: u64,
|
||||
) -> Result<Match3DRunSnapshot, Match3DFieldError> {
|
||||
let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?;
|
||||
let owner_user_id =
|
||||
normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?;
|
||||
let profile_id =
|
||||
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||
|
||||
let total_item_count = config
|
||||
.clear_count
|
||||
.checked_mul(MATCH3D_ITEMS_PER_CLEAR)
|
||||
.ok_or(Match3DFieldError::InvalidClearCount)?;
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id,
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: config.clear_count,
|
||||
total_item_count,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: build_initial_items(
|
||||
config.clear_count,
|
||||
config.difficulty,
|
||||
seed,
|
||||
&config.theme_text,
|
||||
),
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
refresh_clickable_flags(&mut run);
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
/// 后端权威确认一次点击:校验版本、可点击性、入槽、三消和胜负。
|
||||
pub fn confirm_click_at(
|
||||
run: &Match3DRunSnapshot,
|
||||
input: &Match3DClickInput,
|
||||
) -> Result<Match3DClickConfirmation, Match3DFieldError> {
|
||||
let item_instance_id = normalize_required_string(&input.item_instance_id)
|
||||
.ok_or(Match3DFieldError::MissingItemId)?;
|
||||
let client_action_id = normalize_required_string(&input.client_action_id)
|
||||
.unwrap_or_else(|| "match3d-action-unknown".to_string());
|
||||
|
||||
let mut next = resolve_run_timer_at(run, input.clicked_at_ms);
|
||||
if next.status != Match3DRunStatus::Running {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::RunNotActive));
|
||||
}
|
||||
if input.snapshot_version != next.board_version {
|
||||
return Ok(rejected(
|
||||
next,
|
||||
Match3DClickRejectReason::SnapshotVersionMismatch,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(item_index) = next
|
||||
.items
|
||||
.iter()
|
||||
.position(|item| item.item_instance_id == item_instance_id)
|
||||
else {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotFound));
|
||||
};
|
||||
|
||||
if next.items[item_index].state != Match3DItemState::InBoard {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotInBoard));
|
||||
}
|
||||
if !next.items[item_index].clickable {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
|
||||
}
|
||||
|
||||
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
|
||||
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
|
||||
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
|
||||
};
|
||||
|
||||
let item_type_id = next.items[item_index].item_type_id.clone();
|
||||
next.items[item_index].state = Match3DItemState::InTray;
|
||||
next.items[item_index].clickable = false;
|
||||
next.items[item_index].tray_slot_index = Some(slot_index);
|
||||
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
|
||||
|
||||
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
|
||||
compact_tray(&mut next);
|
||||
next.cleared_item_count = next
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.state == Match3DItemState::Cleared)
|
||||
.count() as u32;
|
||||
|
||||
if next.cleared_item_count >= next.total_item_count {
|
||||
next.status = Match3DRunStatus::Won;
|
||||
} else if first_empty_slot_index(&next.tray_slots).is_none() {
|
||||
next.status = Match3DRunStatus::Failed;
|
||||
next.failure_reason = Some(Match3DFailureReason::TrayFull);
|
||||
}
|
||||
|
||||
refresh_clickable_flags(&mut next);
|
||||
next.board_version += 1;
|
||||
next.last_confirmed_action_id = Some(client_action_id);
|
||||
|
||||
Ok(Match3DClickConfirmation {
|
||||
accepted: true,
|
||||
reject_reason: None,
|
||||
entered_slot_index: Some(slot_index),
|
||||
cleared_item_instance_ids,
|
||||
run: next,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据权威时间刷新剩余时间;前端本地倒计时归零后仍需走后端确认。
|
||||
pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot {
|
||||
let mut next = run.clone();
|
||||
if next.status != Match3DRunStatus::Running {
|
||||
return next;
|
||||
}
|
||||
let elapsed_ms = now_ms.saturating_sub(next.started_at_ms);
|
||||
next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms);
|
||||
if next.remaining_ms == 0 {
|
||||
next.status = Match3DRunStatus::Failed;
|
||||
next.failure_reason = Some(Match3DFailureReason::TimeUp);
|
||||
next.board_version += 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// 停止当前运行态,用于试玩或玩家主动退出。
|
||||
pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot {
|
||||
let mut next = run.clone();
|
||||
if next.status == Match3DRunStatus::Running {
|
||||
next.status = Match3DRunStatus::Stopped;
|
||||
next.board_version += 1;
|
||||
next.last_confirmed_action_id = normalize_required_string(stopped_action_id);
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// 以 2D 圆形近似判断遮挡:完全被更高层物品覆盖的物品不可点击。
|
||||
pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) {
|
||||
let board_items = run
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.state == Match3DItemState::InBoard)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for item in &mut run.items {
|
||||
if item.state != Match3DItemState::InBoard {
|
||||
item.clickable = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
item.clickable = !board_items.iter().any(|cover| {
|
||||
cover.layer > item.layer
|
||||
&& fully_covers(cover.x, cover.y, cover.radius, item.x, item.y, item.radius)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_initial_items(
|
||||
clear_count: u32,
|
||||
difficulty: u32,
|
||||
seed: u64,
|
||||
theme_text: &str,
|
||||
) -> Vec<Match3DItemSnapshot> {
|
||||
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||||
let base_radius = resolve_item_radius(difficulty);
|
||||
let visual_keys = visual_keys_for_theme(theme_text);
|
||||
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
|
||||
|
||||
for clear_index in 0..clear_count {
|
||||
let visual_index = (clear_index as usize) % visual_keys.len();
|
||||
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||||
let visual_key = visual_keys[visual_index].to_string();
|
||||
|
||||
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||||
let radius =
|
||||
resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index);
|
||||
let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius));
|
||||
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
|
||||
items.push(Match3DItemSnapshot {
|
||||
item_instance_id: format!("match3d-item-{instance_index:04}"),
|
||||
item_type_id: item_type_id.clone(),
|
||||
visual_key: visual_key.clone(),
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
layer: instance_index,
|
||||
state: Match3DItemState::InBoard,
|
||||
clickable: true,
|
||||
tray_slot_index: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 洗牌只改变层级顺序,不改变每组三个的可通关性。
|
||||
for index in (1..items.len()).rev() {
|
||||
let swap_index = (rng.next_u32() as usize) % (index + 1);
|
||||
items.swap(index, swap_index);
|
||||
}
|
||||
for (layer, item) in items.iter_mut().enumerate() {
|
||||
item.layer = layer as u32;
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] {
|
||||
if is_fruit_theme(theme_text) {
|
||||
&MATCH3D_FRUIT_VISUAL_KEYS
|
||||
} else {
|
||||
&MATCH3D_SHAPE_VISUAL_KEYS
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fruit_theme(theme_text: &str) -> bool {
|
||||
let normalized = theme_text.trim().to_lowercase();
|
||||
[
|
||||
"水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "桃",
|
||||
"李", "柠", "橙", "梨",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||
let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
|
||||
let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055;
|
||||
radius.max(0.052)
|
||||
}
|
||||
|
||||
fn resolve_item_radius_variant(
|
||||
base_radius: f32,
|
||||
visual_key: &str,
|
||||
visual_index: usize,
|
||||
copy_index: u32,
|
||||
) -> f32 {
|
||||
let copy_delta = (copy_index as f32 - 1.0) * 0.002;
|
||||
if is_fruit_visual_key(visual_key) {
|
||||
return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13);
|
||||
}
|
||||
|
||||
let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004;
|
||||
(base_radius + type_delta + copy_delta).clamp(0.045, 0.12)
|
||||
}
|
||||
|
||||
fn is_fruit_visual_key(visual_key: &str) -> bool {
|
||||
matches!(
|
||||
visual_key,
|
||||
"watermelon-green"
|
||||
| "apple-red"
|
||||
| "banana-yellow"
|
||||
| "grape-purple"
|
||||
| "melon-green"
|
||||
| "berry-blue"
|
||||
| "peach-pink"
|
||||
| "plum-indigo"
|
||||
| "lime-lime"
|
||||
| "orange-orange"
|
||||
| "pear-cyan"
|
||||
)
|
||||
}
|
||||
|
||||
fn fruit_visual_size_scale(visual_key: &str) -> f32 {
|
||||
match visual_key {
|
||||
"watermelon-green" => 1.24,
|
||||
"melon-green" => 1.12,
|
||||
"banana-yellow" => 1.04,
|
||||
"apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0,
|
||||
"plum-indigo" | "lime-lime" => 0.86,
|
||||
"grape-purple" | "berry-blue" => 0.78,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn max_spawn_offset(radius: f32) -> f32 {
|
||||
(MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN - radius).max(0.0)
|
||||
}
|
||||
|
||||
fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) {
|
||||
for _ in 0..24 {
|
||||
let x = rng.next_unit_signed() * max_radius;
|
||||
let y = rng.next_unit_signed() * max_radius;
|
||||
if x * x + y * y <= max_radius * max_radius {
|
||||
return (MATCH3D_BOARD_CENTER + x, MATCH3D_BOARD_CENTER + y);
|
||||
}
|
||||
}
|
||||
(MATCH3D_BOARD_CENTER, MATCH3D_BOARD_CENTER)
|
||||
}
|
||||
|
||||
fn fully_covers(
|
||||
cover_x: f32,
|
||||
cover_y: f32,
|
||||
cover_radius: f32,
|
||||
item_x: f32,
|
||||
item_y: f32,
|
||||
item_radius: f32,
|
||||
) -> bool {
|
||||
let dx = cover_x - item_x;
|
||||
let dy = cover_y - item_y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
distance + item_radius <= cover_radius * 0.96
|
||||
}
|
||||
|
||||
fn empty_tray_slots() -> Vec<Match3DTraySlot> {
|
||||
(0..MATCH3D_TRAY_SLOT_COUNT)
|
||||
.map(|slot_index| Match3DTraySlot {
|
||||
slot_index,
|
||||
item_instance_id: None,
|
||||
item_type_id: None,
|
||||
visual_key: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
|
||||
slots
|
||||
.iter()
|
||||
.find(|slot| slot.item_instance_id.is_none())
|
||||
.map(|slot| slot.slot_index)
|
||||
}
|
||||
|
||||
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
|
||||
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
|
||||
slot.item_instance_id = Some(item.item_instance_id.clone());
|
||||
slot.item_type_id = Some(item.item_type_id.clone());
|
||||
slot.visual_key = Some(item.visual_key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
|
||||
let matched_slot_item_ids = run
|
||||
.tray_slots
|
||||
.iter()
|
||||
.filter(|slot| slot.item_type_id.as_deref() == Some(item_type_id))
|
||||
.filter_map(|slot| slot.item_instance_id.clone())
|
||||
.take(MATCH3D_ITEMS_PER_CLEAR as usize)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if matched_slot_item_ids.len() < MATCH3D_ITEMS_PER_CLEAR as usize {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
for item in &mut run.items {
|
||||
if matched_slot_item_ids.contains(&item.item_instance_id) {
|
||||
item.state = Match3DItemState::Cleared;
|
||||
item.clickable = false;
|
||||
item.tray_slot_index = None;
|
||||
}
|
||||
}
|
||||
for slot in &mut run.tray_slots {
|
||||
if slot
|
||||
.item_instance_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| matched_slot_item_ids.contains(id))
|
||||
{
|
||||
slot.item_instance_id = None;
|
||||
slot.item_type_id = None;
|
||||
slot.visual_key = None;
|
||||
}
|
||||
}
|
||||
|
||||
matched_slot_item_ids
|
||||
}
|
||||
|
||||
fn compact_tray(run: &mut Match3DRunSnapshot) {
|
||||
let mut occupied = run
|
||||
.tray_slots
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
Some((
|
||||
slot.item_instance_id.clone()?,
|
||||
slot.item_type_id.clone()?,
|
||||
slot.visual_key.clone()?,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for slot in &mut run.tray_slots {
|
||||
slot.item_instance_id = None;
|
||||
slot.item_type_id = None;
|
||||
slot.visual_key = None;
|
||||
}
|
||||
|
||||
for (slot_index, (item_instance_id, item_type_id, visual_key)) in occupied.drain(..).enumerate()
|
||||
{
|
||||
let slot_index = slot_index as u32;
|
||||
if let Some(slot) = run
|
||||
.tray_slots
|
||||
.iter_mut()
|
||||
.find(|slot| slot.slot_index == slot_index)
|
||||
{
|
||||
slot.item_instance_id = Some(item_instance_id.clone());
|
||||
slot.item_type_id = Some(item_type_id);
|
||||
slot.visual_key = Some(visual_key);
|
||||
}
|
||||
if let Some(item) = run
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.item_instance_id == item_instance_id)
|
||||
{
|
||||
item.tray_slot_index = Some(slot_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_run(
|
||||
mut run: Match3DRunSnapshot,
|
||||
reason: Match3DFailureReason,
|
||||
action_id: String,
|
||||
) -> Match3DRunSnapshot {
|
||||
run.status = Match3DRunStatus::Failed;
|
||||
run.failure_reason = Some(reason);
|
||||
run.board_version += 1;
|
||||
run.last_confirmed_action_id = Some(action_id);
|
||||
run
|
||||
}
|
||||
|
||||
fn rejected(
|
||||
run: Match3DRunSnapshot,
|
||||
reject_reason: Match3DClickRejectReason,
|
||||
) -> Match3DClickConfirmation {
|
||||
Match3DClickConfirmation {
|
||||
accepted: false,
|
||||
reject_reason: Some(reject_reason),
|
||||
entered_slot_index: None,
|
||||
cleared_item_instance_ids: Vec::new(),
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
struct DeterministicRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl DeterministicRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed.max(1) }
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
let mut value = self.state;
|
||||
value ^= value << 13;
|
||||
value ^= value >> 7;
|
||||
value ^= value << 17;
|
||||
self.state = value;
|
||||
(value >> 32) as u32
|
||||
}
|
||||
|
||||
fn next_unit_signed(&mut self) -> f32 {
|
||||
let value = self.next_u32() as f32 / u32::MAX as f32;
|
||||
value * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
use crate::commands::{build_creator_config, validate_publish_requirements};
|
||||
|
||||
fn test_config(clear_count: u32) -> Match3DCreatorConfig {
|
||||
build_creator_config("水果", None, clear_count, 4).expect("config should be valid")
|
||||
}
|
||||
|
||||
fn manual_item(id: &str, type_id: &str, slot: Option<u32>) -> Match3DItemSnapshot {
|
||||
Match3DItemSnapshot {
|
||||
item_instance_id: id.to_string(),
|
||||
item_type_id: type_id.to_string(),
|
||||
visual_key: type_id.to_string(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
radius: 0.08,
|
||||
layer: 0,
|
||||
state: if slot.is_some() {
|
||||
Match3DItemState::InTray
|
||||
} else {
|
||||
Match3DItemState::InBoard
|
||||
},
|
||||
clickable: slot.is_none(),
|
||||
tray_slot_index: slot,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creator_config_requires_positive_clear_count() {
|
||||
let error = build_creator_config("水果", None, 0, 3).expect_err("zero should fail");
|
||||
assert_eq!(error, Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_requires_cover_before_publish() {
|
||||
let mut draft = compile_result_draft(&test_config(2));
|
||||
|
||||
assert!(!draft.publish_ready);
|
||||
assert!(draft.blockers.contains(&"封面图不能为空".to_string()));
|
||||
|
||||
draft.cover_image_src = Some("https://example.com/cover.png".to_string());
|
||||
assert!(validate_publish_requirements(&draft).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_run_generates_triples() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(12),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
assert_eq!(run.total_item_count, 36);
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||
}
|
||||
assert!(counts.values().all(|count| count % 3 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_run_uses_slightly_different_item_sizes() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-size".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(6),
|
||||
21,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut radii = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| (item.radius * 1_000.0).round() as u32)
|
||||
.collect::<Vec<_>>();
|
||||
radii.sort();
|
||||
radii.dedup();
|
||||
|
||||
assert!(radii.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruit_theme_generates_fruit_visuals_inside_board() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-fruit".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(10),
|
||||
12,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let visual_keys = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.visual_key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(visual_keys.contains(&"watermelon-green"));
|
||||
assert!(visual_keys.contains(&"apple-red"));
|
||||
assert!(visual_keys.contains(&"banana-yellow"));
|
||||
assert!(!visual_keys.contains(&"red_circle"));
|
||||
|
||||
for item in &run.items {
|
||||
let dx = item.x - MATCH3D_BOARD_CENTER;
|
||||
let dy = item.y - MATCH3D_BOARD_CENTER;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
assert!(
|
||||
distance + item.radius <= MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN + 0.0001,
|
||||
"item {} should stay inside board: x={}, y={}, radius={}",
|
||||
item.item_instance_id,
|
||||
item.x,
|
||||
item.y,
|
||||
item.radius
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruit_theme_uses_common_sense_relative_sizes() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-fruit-size".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(10),
|
||||
27,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let max_radius_for_visual = |visual_key: &str| {
|
||||
run.items
|
||||
.iter()
|
||||
.filter(|item| item.visual_key == visual_key)
|
||||
.map(|item| item.radius)
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
|
||||
let watermelon = max_radius_for_visual("watermelon-green");
|
||||
let apple = max_radius_for_visual("apple-red");
|
||||
let grape = max_radius_for_visual("grape-purple");
|
||||
|
||||
assert!(watermelon > apple);
|
||||
assert!(apple > grape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_fruit_theme_generates_shape_visuals() {
|
||||
let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
|
||||
let run = start_run_with_seed_at(
|
||||
"run-shapes".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&config,
|
||||
13,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let visual_keys = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.visual_key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(visual_keys.contains(&"red_circle"));
|
||||
assert!(visual_keys.contains(&"yellow_triangle"));
|
||||
assert!(!visual_keys.contains(&"apple-red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_three_same_items_clears_and_wins() {
|
||||
let mut run = start_run_with_seed_at(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(1),
|
||||
7,
|
||||
10_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
for item in &mut run.items {
|
||||
item.clickable = true;
|
||||
}
|
||||
|
||||
let ids = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.item_instance_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, item_id) in ids.iter().enumerate() {
|
||||
let input = Match3DClickInput {
|
||||
run_id: run.run_id.clone(),
|
||||
owner_user_id: run.owner_user_id.clone(),
|
||||
item_instance_id: item_id.clone(),
|
||||
client_action_id: format!("action-{index}"),
|
||||
snapshot_version: run.board_version,
|
||||
clicked_at_ms: 11_000 + index as u64,
|
||||
};
|
||||
run = confirm_click_at(&run, &input)
|
||||
.expect("click should confirm")
|
||||
.run;
|
||||
}
|
||||
|
||||
assert_eq!(run.status, Match3DRunStatus::Won);
|
||||
assert_eq!(run.cleared_item_count, 3);
|
||||
assert!(
|
||||
run.tray_slots
|
||||
.iter()
|
||||
.all(|slot| slot.item_instance_id.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_full_fails_when_no_triple_can_clear() {
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id: "run-full".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms: 0,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: 3,
|
||||
total_item_count: 9,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: (0..8)
|
||||
.map(|index| manual_item(&format!("item-{index}"), &format!("type-{index}"), None))
|
||||
.collect(),
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
|
||||
for index in 0..7 {
|
||||
let input = Match3DClickInput {
|
||||
run_id: run.run_id.clone(),
|
||||
owner_user_id: run.owner_user_id.clone(),
|
||||
item_instance_id: format!("item-{index}"),
|
||||
client_action_id: format!("action-{index}"),
|
||||
snapshot_version: run.board_version,
|
||||
clicked_at_ms: 1_000 + index,
|
||||
};
|
||||
run = confirm_click_at(&run, &input)
|
||||
.expect("click should confirm")
|
||||
.run;
|
||||
}
|
||||
|
||||
assert_eq!(run.status, Match3DRunStatus::Failed);
|
||||
assert_eq!(run.failure_reason, Some(Match3DFailureReason::TrayFull));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_expiration_fails_running_run() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(2),
|
||||
9,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let expired = resolve_run_timer_at(&run, 1_000 + MATCH3D_DEFAULT_DURATION_LIMIT_MS);
|
||||
|
||||
assert_eq!(expired.status, Match3DRunStatus::Failed);
|
||||
assert_eq!(expired.failure_reason, Some(Match3DFailureReason::TimeUp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_covered_item_is_not_clickable() {
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id: "run-cover".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms: 0,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: 1,
|
||||
total_item_count: 2,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: vec![
|
||||
Match3DItemSnapshot {
|
||||
layer: 0,
|
||||
radius: 0.04,
|
||||
..manual_item("bottom", "type-a", None)
|
||||
},
|
||||
Match3DItemSnapshot {
|
||||
layer: 1,
|
||||
radius: 0.08,
|
||||
..manual_item("top", "type-b", None)
|
||||
},
|
||||
],
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
|
||||
refresh_clickable_flags(&mut run);
|
||||
|
||||
let bottom = run
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.item_instance_id == "bottom")
|
||||
.expect("bottom item should exist");
|
||||
assert!(!bottom.clickable);
|
||||
}
|
||||
}
|
||||
86
server-rs/crates/module-match3d/src/commands.rs
Normal file
86
server-rs/crates/module-match3d/src/commands.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
||||
|
||||
use crate::{
|
||||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, Match3DCreatorConfig, Match3DFieldError,
|
||||
Match3DResultDraft,
|
||||
};
|
||||
|
||||
pub fn build_creator_config(
|
||||
theme_text: &str,
|
||||
reference_image_src: Option<String>,
|
||||
clear_count: u32,
|
||||
difficulty: u32,
|
||||
) -> Result<Match3DCreatorConfig, Match3DFieldError> {
|
||||
let theme_text = normalize_required_string(theme_text).ok_or(Match3DFieldError::MissingText)?;
|
||||
if clear_count == 0 {
|
||||
return Err(Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&difficulty) {
|
||||
return Err(Match3DFieldError::InvalidDifficulty);
|
||||
}
|
||||
|
||||
Ok(Match3DCreatorConfig {
|
||||
theme_text,
|
||||
reference_image_src: normalize_optional_string(reference_image_src),
|
||||
clear_count,
|
||||
difficulty,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据已确认的题材、消除次数和难度编译首版结果草稿。
|
||||
|
||||
pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec<String> {
|
||||
let mut blockers = validate_result_publish_fields(draft);
|
||||
if draft.clear_count == 0 {
|
||||
blockers.push("需要消除次数必须为正整数".to_string());
|
||||
}
|
||||
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&draft.difficulty) {
|
||||
blockers.push("难度必须在 1 到 10 之间".to_string());
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
/// 将结果草稿转换为可保存的作品 profile,实际持久化由 SpacetimeDB 分支负责。
|
||||
|
||||
pub(crate) fn validate_basic_publish_fields(
|
||||
game_name: &str,
|
||||
summary: &str,
|
||||
tags: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut blockers = Vec::new();
|
||||
if normalize_required_string(game_name).is_none() {
|
||||
blockers.push("游戏名称不能为空".to_string());
|
||||
}
|
||||
if normalize_required_string(summary).is_none() {
|
||||
blockers.push("简介不能为空".to_string());
|
||||
}
|
||||
let normalized_tags = normalize_string_list(tags.to_vec());
|
||||
if normalized_tags.is_empty() {
|
||||
blockers.push("至少需要 1 个标签".to_string());
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
pub(crate) fn validate_result_publish_fields(draft: &Match3DResultDraft) -> Vec<String> {
|
||||
let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags);
|
||||
if draft
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
.is_none()
|
||||
{
|
||||
blockers.push("封面图不能为空".to_string());
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
pub(crate) fn default_tags_for_theme(theme_text: &str) -> Vec<String> {
|
||||
let mut tags = vec![
|
||||
"抓大鹅".to_string(),
|
||||
"经典消除".to_string(),
|
||||
theme_text.to_string(),
|
||||
];
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
270
server-rs/crates/module-match3d/src/domain.rs
Normal file
270
server-rs/crates/module-match3d/src/domain.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const MATCH3D_SESSION_ID_PREFIX: &str = "match3d-session-";
|
||||
pub const MATCH3D_MESSAGE_ID_PREFIX: &str = "match3d-message-";
|
||||
pub const MATCH3D_PROFILE_ID_PREFIX: &str = "match3d-profile-";
|
||||
pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-";
|
||||
pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-";
|
||||
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
|
||||
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
|
||||
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
|
||||
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
|
||||
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000;
|
||||
pub const MATCH3D_BOARD_CENTER: f32 = 0.5;
|
||||
pub const MATCH3D_BOARD_RADIUS: f32 = 0.5;
|
||||
pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035;
|
||||
|
||||
// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。
|
||||
pub(crate) const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [
|
||||
"watermelon-green",
|
||||
"apple-red",
|
||||
"banana-yellow",
|
||||
"grape-purple",
|
||||
"melon-green",
|
||||
"berry-blue",
|
||||
"peach-pink",
|
||||
"plum-indigo",
|
||||
"lime-lime",
|
||||
"orange-orange",
|
||||
];
|
||||
|
||||
// 中文注释:非水果题材使用颜色形状兜底 key;前端必须逐个渲染,不能统一兜成同一图案。
|
||||
pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [
|
||||
"red_circle",
|
||||
"yellow_triangle",
|
||||
"purple_diamond",
|
||||
"green_square",
|
||||
"blue_star",
|
||||
"orange_hexagon",
|
||||
"cyan_capsule",
|
||||
"pink_heart",
|
||||
"lime_leaf",
|
||||
"white_moon",
|
||||
];
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DCreationStage {
|
||||
CollectingConfig,
|
||||
DraftReady,
|
||||
ReadyToPublish,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DPublicationStatus {
|
||||
Draft,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DRunStatus {
|
||||
Running,
|
||||
Won,
|
||||
Failed,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DFailureReason {
|
||||
TimeUp,
|
||||
TrayFull,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DItemState {
|
||||
InBoard,
|
||||
InTray,
|
||||
Cleared,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DClickRejectReason {
|
||||
RunNotActive,
|
||||
SnapshotVersionMismatch,
|
||||
ItemNotFound,
|
||||
ItemNotInBoard,
|
||||
ItemNotClickable,
|
||||
TrayFull,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DCreatorConfig {
|
||||
pub theme_text: String,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DResultDraft {
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DWorkProfile {
|
||||
pub work_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_session_id: Option<String>,
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publication_status: Match3DPublicationStatus,
|
||||
pub play_count: u32,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Match3DItemSnapshot {
|
||||
pub item_instance_id: String,
|
||||
pub item_type_id: String,
|
||||
pub visual_key: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub radius: f32,
|
||||
pub layer: u32,
|
||||
pub state: Match3DItemState,
|
||||
pub clickable: bool,
|
||||
pub tray_slot_index: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DTraySlot {
|
||||
pub slot_index: u32,
|
||||
pub item_instance_id: Option<String>,
|
||||
pub item_type_id: Option<String>,
|
||||
pub visual_key: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Match3DRunSnapshot {
|
||||
pub run_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub status: Match3DRunStatus,
|
||||
pub started_at_ms: u64,
|
||||
pub duration_limit_ms: u64,
|
||||
pub remaining_ms: u64,
|
||||
pub clear_count: u32,
|
||||
pub total_item_count: u32,
|
||||
pub cleared_item_count: u32,
|
||||
/// 领域内部权威快照版本;HTTP DTO 对外映射为 snapshotVersion。
|
||||
pub board_version: u64,
|
||||
pub items: Vec<Match3DItemSnapshot>,
|
||||
pub tray_slots: Vec<Match3DTraySlot>,
|
||||
pub failure_reason: Option<Match3DFailureReason>,
|
||||
pub last_confirmed_action_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DClickInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub item_instance_id: String,
|
||||
pub client_action_id: String,
|
||||
pub snapshot_version: u64,
|
||||
pub clicked_at_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Match3DClickConfirmation {
|
||||
pub accepted: bool,
|
||||
pub reject_reason: Option<Match3DClickRejectReason>,
|
||||
pub entered_slot_index: Option<u32>,
|
||||
pub cleared_item_instance_ids: Vec<String>,
|
||||
pub run: Match3DRunSnapshot,
|
||||
}
|
||||
|
||||
impl Match3DCreationStage {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CollectingConfig => "collecting_config",
|
||||
Self::DraftReady => "draft_ready",
|
||||
Self::ReadyToPublish => "ready_to_publish",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DPublicationStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Draft => "draft",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DRunStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Won => "won",
|
||||
Self::Failed => "failed",
|
||||
Self::Stopped => "stopped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DFailureReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::TimeUp => "time_up",
|
||||
Self::TrayFull => "tray_full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DItemState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InBoard => "in_board",
|
||||
Self::InTray => "in_tray",
|
||||
Self::Cleared => "cleared",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DClickRejectReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RunNotActive => "run_not_active",
|
||||
Self::SnapshotVersionMismatch => "snapshot_version_mismatch",
|
||||
Self::ItemNotFound => "item_not_found",
|
||||
Self::ItemNotInBoard => "item_not_in_board",
|
||||
Self::ItemNotClickable => "item_not_clickable",
|
||||
Self::TrayFull => "tray_full",
|
||||
}
|
||||
}
|
||||
}
|
||||
28
server-rs/crates/module-match3d/src/errors.rs
Normal file
28
server-rs/crates/module-match3d/src/errors.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Match3DFieldError {
|
||||
MissingText,
|
||||
MissingOwnerUserId,
|
||||
MissingProfileId,
|
||||
MissingRunId,
|
||||
MissingItemId,
|
||||
InvalidClearCount,
|
||||
InvalidDifficulty,
|
||||
}
|
||||
|
||||
impl fmt::Display for Match3DFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingText => write!(f, "必填文本缺失"),
|
||||
Self::MissingOwnerUserId => write!(f, "owner_user_id 缺失"),
|
||||
Self::MissingProfileId => write!(f, "profile_id 缺失"),
|
||||
Self::MissingRunId => write!(f, "run_id 缺失"),
|
||||
Self::MissingItemId => write!(f, "item_instance_id 缺失"),
|
||||
Self::InvalidClearCount => write!(f, "需要消除次数必须为正整数"),
|
||||
Self::InvalidDifficulty => write!(f, "难度必须在 1 到 10 之间"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for Match3DFieldError {}
|
||||
25
server-rs/crates/module-match3d/src/events.rs
Normal file
25
server-rs/crates/module-match3d/src/events.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Match3D 领域事件。
|
||||
//!
|
||||
//! 事件只表达已经发生的领域事实,持久化、订阅投影和 HTTP 通知
|
||||
//! 均由 SpacetimeDB adapter 或 BFF 决定。
|
||||
|
||||
/// Match3D 领域事件。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Match3DDomainEvent {
|
||||
DraftCompiled {
|
||||
profile_id: String,
|
||||
owner_user_id: String,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
WorkPublished {
|
||||
profile_id: String,
|
||||
owner_user_id: String,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
RunSettled {
|
||||
run_id: String,
|
||||
owner_user_id: String,
|
||||
status: String,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
}
|
||||
11
server-rs/crates/module-match3d/src/lib.rs
Normal file
11
server-rs/crates/module-match3d/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
pub use domain::*;
|
||||
pub use errors::*;
|
||||
pub use events::*;
|
||||
Reference in New Issue
Block a user