Files
Genarrative/server-rs/crates/module-match3d/src/lib.rs

1240 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
#[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 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。
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前端必须逐个渲染不能统一兜成同一图案。
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,
}
#[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 {}
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",
}
}
}
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 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 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 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,
}
}
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
}
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
}
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
}
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::*;
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);
}
}