Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
1348 lines
44 KiB
Rust
1348 lines
44 KiB
Rust
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_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS,
|
||
MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR,
|
||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY,
|
||
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason,
|
||
Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot,
|
||
Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot,
|
||
Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
|
||
};
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct Match3DSizeTierRule {
|
||
ratio: f32,
|
||
radius_scale: f32,
|
||
relative_volume: f32,
|
||
tier: &'static str,
|
||
}
|
||
|
||
const MATCH3D_SIZE_TIER_RULES: [Match3DSizeTierRule; 5] = [
|
||
Match3DSizeTierRule {
|
||
tier: "XL",
|
||
ratio: 0.20,
|
||
relative_volume: 1.86,
|
||
radius_scale: 1.23,
|
||
},
|
||
Match3DSizeTierRule {
|
||
tier: "L",
|
||
ratio: 0.30,
|
||
relative_volume: 1.40,
|
||
radius_scale: 1.12,
|
||
},
|
||
Match3DSizeTierRule {
|
||
tier: "M",
|
||
ratio: 0.30,
|
||
relative_volume: 1.00,
|
||
radius_scale: 1.00,
|
||
},
|
||
Match3DSizeTierRule {
|
||
tier: "XS",
|
||
ratio: 0.15,
|
||
relative_volume: 0.73,
|
||
radius_scale: 0.90,
|
||
},
|
||
Match3DSizeTierRule {
|
||
tier: "S",
|
||
ratio: 0.05,
|
||
relative_volume: 0.44,
|
||
radius_scale: 0.76,
|
||
},
|
||
];
|
||
const MATCH3D_CONTAINER_MOUTH_SPAWN_RATIO: f32 = 0.78;
|
||
const MATCH3D_GOLDEN_ANGLE: f32 = 2.399_963_1;
|
||
|
||
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> {
|
||
start_run_with_seed_at_and_item_type_count(
|
||
run_id,
|
||
owner_user_id,
|
||
profile_id,
|
||
config,
|
||
seed,
|
||
started_at_ms,
|
||
None,
|
||
)
|
||
}
|
||
|
||
/// 用确定性 seed 生成单局初始快照,可为试玩传入降档后的物品种类数量。
|
||
pub fn start_run_with_seed_at_and_item_type_count(
|
||
run_id: String,
|
||
owner_user_id: String,
|
||
profile_id: String,
|
||
config: &Match3DCreatorConfig,
|
||
seed: u64,
|
||
started_at_ms: u64,
|
||
item_type_count_override: Option<u32>,
|
||
) -> 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 clear_count = normalize_match3d_runtime_clear_count(config.clear_count, config.difficulty);
|
||
let total_item_count = 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,
|
||
total_item_count,
|
||
cleared_item_count: 0,
|
||
board_version: 1,
|
||
items: build_initial_items(
|
||
clear_count,
|
||
config.difficulty,
|
||
seed,
|
||
&config.theme_text,
|
||
item_type_count_override,
|
||
),
|
||
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) =
|
||
insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index)
|
||
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);
|
||
|
||
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,
|
||
item_type_count_override: Option<u32>,
|
||
) -> Vec<Match3DItemSnapshot> {
|
||
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||
let base_radius = resolve_item_radius(difficulty);
|
||
let item_type_count =
|
||
resolve_item_type_count(clear_count, difficulty, item_type_count_override);
|
||
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, item_type_count);
|
||
let size_tier_plan = resolve_size_tier_plan(item_type_count);
|
||
let total_item_count = clear_count * MATCH3D_ITEMS_PER_CLEAR;
|
||
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) % item_type_count;
|
||
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||
let visual_key = selected_visual_keys[visual_index].to_string();
|
||
let radius = resolve_item_radius_variant(base_radius, size_tier_plan[visual_index]);
|
||
|
||
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
|
||
let (x, y) = spawn_point_in_container_mouth(
|
||
instance_index,
|
||
total_item_count,
|
||
radius,
|
||
rng.next_unit_signed(),
|
||
rng.next_unit_signed(),
|
||
);
|
||
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 resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
|
||
let mut plans = MATCH3D_SIZE_TIER_RULES
|
||
.iter()
|
||
.map(|rule| {
|
||
let exact_count = item_type_count as f32 * rule.ratio;
|
||
(exact_count.floor() as usize, exact_count.fract(), *rule)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let mut assigned_count = plans.iter().map(|(count, _, _)| *count).sum::<usize>();
|
||
let mut remainder_order = (0..plans.len()).collect::<Vec<_>>();
|
||
remainder_order.sort_by(|left, right| {
|
||
plans[*right]
|
||
.1
|
||
.partial_cmp(&plans[*left].1)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
let mut cursor = 0;
|
||
while assigned_count < item_type_count {
|
||
let plan_index = remainder_order[cursor % remainder_order.len()];
|
||
plans[plan_index].0 += 1;
|
||
assigned_count += 1;
|
||
cursor += 1;
|
||
}
|
||
|
||
plans
|
||
.into_iter()
|
||
.flat_map(|(count, _, rule)| std::iter::repeat(rule).take(count))
|
||
.collect()
|
||
}
|
||
|
||
pub fn resolve_match3d_item_type_count_for_difficulty(clear_count: u32, difficulty: u32) -> u32 {
|
||
let target = match clear_count {
|
||
8 => 3,
|
||
12 => 9,
|
||
16 => 15,
|
||
20 | 21 => 21,
|
||
_ => match difficulty {
|
||
0..=2 => 3,
|
||
3..=4 => 9,
|
||
5..=6 => 15,
|
||
_ => 21,
|
||
},
|
||
};
|
||
|
||
target.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT)
|
||
}
|
||
|
||
pub fn normalize_match3d_runtime_clear_count(clear_count: u32, difficulty: u32) -> u32 {
|
||
// 中文注释:旧硬核草稿曾保存 clear_count=20;新硬核固定 21 种物品,
|
||
// 运行态也升到 21 组三消,避免出现 20 组却要求 21 种素材的不可达状态。
|
||
if clear_count == 20 && difficulty >= 7 {
|
||
21
|
||
} else {
|
||
clear_count
|
||
}
|
||
}
|
||
|
||
fn resolve_item_type_count(
|
||
clear_count: u32,
|
||
difficulty: u32,
|
||
item_type_count_override: Option<u32>,
|
||
) -> usize {
|
||
item_type_count_override
|
||
.filter(|count| *count > 0)
|
||
.unwrap_or_else(|| resolve_match3d_item_type_count_for_difficulty(clear_count, difficulty))
|
||
.min(clear_count.max(1))
|
||
.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
|
||
}
|
||
|
||
fn select_visual_keys(
|
||
rng: &mut DeterministicRng,
|
||
_theme_text: &str,
|
||
item_type_count: usize,
|
||
) -> Vec<&'static str> {
|
||
let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec();
|
||
// 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。
|
||
for index in (1..visual_keys.len()).rev() {
|
||
let swap_index = (rng.next_u32() as usize) % (index + 1);
|
||
visual_keys.swap(index, swap_index);
|
||
}
|
||
visual_keys.truncate(item_type_count);
|
||
visual_keys
|
||
}
|
||
|
||
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, size_tier: Match3DSizeTierRule) -> f32 {
|
||
debug_assert!(!size_tier.tier.is_empty());
|
||
debug_assert!(size_tier.relative_volume > 0.0);
|
||
(base_radius * size_tier.radius_scale).clamp(0.045, 0.13)
|
||
}
|
||
|
||
fn max_spawn_offset(radius: f32) -> f32 {
|
||
(MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN - radius).max(0.0)
|
||
}
|
||
|
||
fn spawn_point_in_container_mouth(
|
||
item_index: u32,
|
||
total_item_count: u32,
|
||
radius: f32,
|
||
jitter_x: f32,
|
||
jitter_y: f32,
|
||
) -> (f32, f32) {
|
||
let safe_radius = max_spawn_offset(radius);
|
||
let mouth_radius = safe_radius * MATCH3D_CONTAINER_MOUTH_SPAWN_RATIO;
|
||
let total = total_item_count.max(1) as f32;
|
||
let index = item_index as f32;
|
||
let distance = ((index + 0.5) / total).sqrt() * mouth_radius;
|
||
let angle = index * MATCH3D_GOLDEN_ANGLE;
|
||
let jitter_radius = (mouth_radius * 0.035).min(0.012);
|
||
let mut dx = angle.cos() * distance + jitter_x * jitter_radius;
|
||
let mut dy = angle.sin() * distance + jitter_y * jitter_radius;
|
||
let current_distance = (dx * dx + dy * dy).sqrt();
|
||
if current_distance > safe_radius && current_distance > 0.0 {
|
||
let ratio = safe_radius / current_distance;
|
||
dx *= ratio;
|
||
dy *= ratio;
|
||
}
|
||
(MATCH3D_BOARD_CENTER + dx, MATCH3D_BOARD_CENTER + dy)
|
||
}
|
||
|
||
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 insert_item_into_tray_after_same_type(
|
||
slots: &mut [Match3DTraySlot],
|
||
items: &mut [Match3DItemSnapshot],
|
||
item_index: usize,
|
||
) -> Option<u32> {
|
||
let occupied = slots
|
||
.iter()
|
||
.filter_map(|slot| {
|
||
Some((
|
||
slot.item_instance_id.clone()?,
|
||
slot.item_type_id.clone()?,
|
||
slot.visual_key.clone()?,
|
||
))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if occupied.len() >= slots.len() {
|
||
return None;
|
||
}
|
||
|
||
let item = items.get(item_index)?.clone();
|
||
let insertion_index = occupied
|
||
.iter()
|
||
.rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id)
|
||
.map(|index| index + 1)
|
||
.unwrap_or(occupied.len());
|
||
let mut next_occupied = occupied;
|
||
next_occupied.insert(
|
||
insertion_index,
|
||
(
|
||
item.item_instance_id.clone(),
|
||
item.item_type_id.clone(),
|
||
item.visual_key.clone(),
|
||
),
|
||
);
|
||
|
||
for slot in slots.iter_mut() {
|
||
slot.item_instance_id = None;
|
||
slot.item_type_id = None;
|
||
slot.visual_key = None;
|
||
}
|
||
for (index, (item_instance_id, item_type_id, visual_key)) in
|
||
next_occupied.into_iter().enumerate()
|
||
{
|
||
let slot_index = index as u32;
|
||
if let Some(slot) = 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(entry) = items
|
||
.iter_mut()
|
||
.find(|entry| entry.item_instance_id == item_instance_id)
|
||
{
|
||
entry.tray_slot_index = Some(slot_index);
|
||
}
|
||
}
|
||
|
||
Some(insertion_index as u32)
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
compact_tray(run);
|
||
|
||
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 item_type_count_follows_difficulty_config() {
|
||
let run = start_run_with_seed_at(
|
||
"run-types-small".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&test_config(12),
|
||
42,
|
||
1_000,
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut counts = BTreeMap::<String, u32>::new();
|
||
for item in &run.items {
|
||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||
}
|
||
|
||
assert_eq!(counts.len(), 9);
|
||
assert!(counts.values().all(|count| *count % 3 == 0));
|
||
}
|
||
|
||
#[test]
|
||
fn visual_key_count_follows_override_for_test_run() {
|
||
let run = start_run_with_seed_at(
|
||
"run-types-default".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&test_config(16),
|
||
42,
|
||
1_000,
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut counts = BTreeMap::<String, u32>::new();
|
||
for item in &run.items {
|
||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||
}
|
||
assert_eq!(counts.len(), 15);
|
||
|
||
let run = start_run_with_seed_at_and_item_type_count(
|
||
"run-types-fifteen".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&test_config(15),
|
||
42,
|
||
1_000,
|
||
Some(3),
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut counts = BTreeMap::<String, u32>::new();
|
||
let mut item_types_by_visual_key = BTreeMap::<String, Vec<String>>::new();
|
||
for item in &run.items {
|
||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||
item_types_by_visual_key
|
||
.entry(item.visual_key.clone())
|
||
.or_default()
|
||
.push(item.item_type_id.clone());
|
||
}
|
||
|
||
assert_eq!(counts.len(), 3);
|
||
assert!(counts.values().all(|count| *count == 15));
|
||
assert!(item_types_by_visual_key.values().all(|item_type_ids| {
|
||
item_type_ids
|
||
.iter()
|
||
.all(|item_type_id| item_type_id == &item_type_ids[0])
|
||
}));
|
||
}
|
||
|
||
#[test]
|
||
fn item_type_count_is_capped_at_runtime_limit() {
|
||
let run = start_run_with_seed_at(
|
||
"run-types-large".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&test_config(100),
|
||
42,
|
||
1_000,
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut counts = BTreeMap::<String, u32>::new();
|
||
for item in &run.items {
|
||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||
}
|
||
|
||
assert_eq!(counts.len(), 9);
|
||
assert!(counts.values().all(|count| count % 3 == 0));
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_hardcore_clear_count_runs_as_twenty_one_groups() {
|
||
let run = start_run_with_seed_at(
|
||
"run-types-legacy-hardcore".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&build_creator_config("水果", None, 20, 8).expect("config should be valid"),
|
||
42,
|
||
1_000,
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut counts = BTreeMap::<String, u32>::new();
|
||
for item in &run.items {
|
||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||
}
|
||
|
||
assert_eq!(run.clear_count, 21);
|
||
assert_eq!(run.total_item_count, 63);
|
||
assert_eq!(counts.len(), 21);
|
||
assert!(counts.values().all(|count| *count == 3));
|
||
}
|
||
|
||
#[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 size_tier_plan_follows_ratio_for_twenty_five_types() {
|
||
let plan = resolve_size_tier_plan(25);
|
||
let mut counts = BTreeMap::<&str, usize>::new();
|
||
for rule in plan {
|
||
*counts.entry(rule.tier).or_default() += 1;
|
||
match rule.tier {
|
||
"XL" => assert!((1.60..=2.30).contains(&rule.relative_volume)),
|
||
"L" => assert!((1.25..=1.60).contains(&rule.relative_volume)),
|
||
"M" => assert_eq!(rule.relative_volume, 1.00),
|
||
"XS" => assert!((0.65..=0.85).contains(&rule.relative_volume)),
|
||
"S" => assert!((0.35..=0.50).contains(&rule.relative_volume)),
|
||
_ => panic!("unknown size tier"),
|
||
}
|
||
}
|
||
|
||
assert_eq!(counts.get("XL"), Some(&5));
|
||
assert_eq!(counts.get("L"), Some(&8));
|
||
assert_eq!(counts.get("M"), Some(&7));
|
||
assert_eq!(counts.get("XS"), Some(&4));
|
||
assert_eq!(counts.get("S"), Some(&1));
|
||
}
|
||
|
||
#[test]
|
||
fn same_visual_key_keeps_one_size_in_run() {
|
||
let run = start_run_with_seed_at_and_item_type_count(
|
||
"run-size-unique".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&test_config(30),
|
||
42,
|
||
1_000,
|
||
Some(25),
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut radii_by_visual_key = BTreeMap::<String, Vec<u32>>::new();
|
||
for item in &run.items {
|
||
radii_by_visual_key
|
||
.entry(item.visual_key.clone())
|
||
.or_default()
|
||
.push((item.radius * 10_000.0).round() as u32);
|
||
}
|
||
|
||
assert_eq!(radii_by_visual_key.len(), 25);
|
||
assert!(
|
||
radii_by_visual_key
|
||
.values()
|
||
.all(|radii| { radii.iter().all(|radius| radius == &radii[0]) })
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn block_visuals_stay_inside_board() {
|
||
let run = start_run_with_seed_at(
|
||
"run-blocks".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
|
||
.iter()
|
||
.all(|visual_key| visual_key.starts_with("block-"))
|
||
);
|
||
|
||
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 initial_positions_are_centered_in_container_mouth() {
|
||
let run = start_run_with_seed_at(
|
||
"run-centered-spawn".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&build_creator_config("玩具", None, 21, 8).expect("config should be valid"),
|
||
12,
|
||
1_000,
|
||
)
|
||
.expect("run should start");
|
||
let board_items = run
|
||
.items
|
||
.iter()
|
||
.filter(|item| item.state == Match3DItemState::InBoard)
|
||
.collect::<Vec<_>>();
|
||
let item_count = board_items.len() as f32;
|
||
let mean_x = board_items.iter().map(|item| item.x).sum::<f32>() / item_count;
|
||
let mean_y = board_items.iter().map(|item| item.y).sum::<f32>() / item_count;
|
||
let max_distance = board_items
|
||
.iter()
|
||
.map(|item| {
|
||
let dx = item.x - MATCH3D_BOARD_CENTER;
|
||
let dy = item.y - MATCH3D_BOARD_CENTER;
|
||
(dx * dx + dy * dy).sqrt()
|
||
})
|
||
.fold(0.0_f32, f32::max);
|
||
let far_item_count = board_items
|
||
.iter()
|
||
.filter(|item| {
|
||
let dx = item.x - MATCH3D_BOARD_CENTER;
|
||
let dy = item.y - MATCH3D_BOARD_CENTER;
|
||
(dx * dx + dy * dy).sqrt() > 0.32
|
||
})
|
||
.count();
|
||
let mut quadrants = BTreeMap::<String, u32>::new();
|
||
for item in board_items {
|
||
let quadrant = format!(
|
||
"{}-{}",
|
||
if item.x >= MATCH3D_BOARD_CENTER {
|
||
"r"
|
||
} else {
|
||
"l"
|
||
},
|
||
if item.y >= MATCH3D_BOARD_CENTER {
|
||
"b"
|
||
} else {
|
||
"t"
|
||
},
|
||
);
|
||
*quadrants.entry(quadrant).or_default() += 1;
|
||
}
|
||
|
||
assert!((mean_x - MATCH3D_BOARD_CENTER).abs() < 0.035);
|
||
assert!((mean_y - MATCH3D_BOARD_CENTER).abs() < 0.035);
|
||
assert!(max_distance < 0.4);
|
||
assert!(far_item_count > 0);
|
||
assert_eq!(quadrants.len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn twenty_five_or_less_does_not_repeat_visual_keys() {
|
||
let run = start_run_with_seed_at_and_item_type_count(
|
||
"run-block-unique".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&test_config(25),
|
||
27,
|
||
1_000,
|
||
Some(25),
|
||
)
|
||
.expect("run should start");
|
||
|
||
let mut counts = BTreeMap::<String, u32>::new();
|
||
for item in &run.items {
|
||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||
}
|
||
|
||
assert_eq!(counts.len(), 25);
|
||
assert!(counts.values().all(|count| *count == 3));
|
||
}
|
||
|
||
#[test]
|
||
fn block_visuals_have_different_relative_sizes() {
|
||
let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
|
||
let run = start_run_with_seed_at(
|
||
"run-block-size".to_string(),
|
||
"user-1".to_string(),
|
||
"profile-1".to_string(),
|
||
&config,
|
||
13,
|
||
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 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 clicking_item_inserts_after_same_type_and_shifts_following_slots() {
|
||
let mut run = Match3DRunSnapshot {
|
||
run_id: "run-insert".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: 4,
|
||
cleared_item_count: 0,
|
||
board_version: 1,
|
||
items: vec![
|
||
manual_item("apple-3", "apple", None),
|
||
manual_item("apple-1", "apple", Some(0)),
|
||
manual_item("apple-2", "apple", Some(1)),
|
||
manual_item("pear-1", "pear", Some(2)),
|
||
],
|
||
tray_slots: empty_tray_slots(),
|
||
failure_reason: None,
|
||
last_confirmed_action_id: None,
|
||
};
|
||
run.tray_slots[0].item_instance_id = Some("apple-1".to_string());
|
||
run.tray_slots[0].item_type_id = Some("apple".to_string());
|
||
run.tray_slots[0].visual_key = Some("apple".to_string());
|
||
run.tray_slots[1].item_instance_id = Some("apple-2".to_string());
|
||
run.tray_slots[1].item_type_id = Some("apple".to_string());
|
||
run.tray_slots[1].visual_key = Some("apple".to_string());
|
||
run.tray_slots[2].item_instance_id = Some("pear-1".to_string());
|
||
run.tray_slots[2].item_type_id = Some("pear".to_string());
|
||
run.tray_slots[2].visual_key = Some("pear".to_string());
|
||
|
||
let confirmed = confirm_click_at(
|
||
&run,
|
||
&Match3DClickInput {
|
||
run_id: run.run_id.clone(),
|
||
owner_user_id: run.owner_user_id.clone(),
|
||
item_instance_id: "apple-3".to_string(),
|
||
client_action_id: "action-insert".to_string(),
|
||
snapshot_version: run.board_version,
|
||
clicked_at_ms: 1_000,
|
||
},
|
||
)
|
||
.expect("click should confirm");
|
||
|
||
assert_eq!(confirmed.entered_slot_index, Some(2));
|
||
assert_eq!(
|
||
confirmed
|
||
.run
|
||
.tray_slots
|
||
.iter()
|
||
.map(|slot| slot.item_instance_id.as_deref())
|
||
.collect::<Vec<_>>(),
|
||
vec![Some("pear-1"), None, None, None, None, None, None]
|
||
);
|
||
assert_eq!(
|
||
confirmed
|
||
.run
|
||
.items
|
||
.iter()
|
||
.find(|item| item.item_instance_id == "pear-1")
|
||
.and_then(|item| item.tray_slot_index),
|
||
Some(0)
|
||
);
|
||
assert_eq!(
|
||
confirmed.cleared_item_instance_ids,
|
||
vec![
|
||
"apple-1".to_string(),
|
||
"apple-2".to_string(),
|
||
"apple-3".to_string()
|
||
]
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|