Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -58,6 +58,9 @@ const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime";
|
||||
const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具";
|
||||
const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12;
|
||||
const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
|
||||
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
||||
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
||||
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -90,7 +93,7 @@ pub async fn create_match3d_agent_session(
|
||||
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
|
||||
let config = build_config_from_create_request(&payload);
|
||||
let seed_text = build_seed_text(&payload, &config);
|
||||
let welcome_message_text = build_match3d_assistant_reply(&config);
|
||||
let welcome_message_text = MATCH3D_QUESTION_THEME.to_string();
|
||||
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
@@ -810,9 +813,10 @@ async fn submit_and_finalize_match3d_message(
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let next_turn = submitted.current_turn.saturating_add(1);
|
||||
let next_config = build_config_from_message(&submitted, &payload);
|
||||
let assistant_reply = build_match3d_assistant_reply(&next_config);
|
||||
let progress_percent = resolve_progress_percent(&next_config);
|
||||
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
|
||||
let progress_percent = resolve_progress_percent_for_turn(next_turn);
|
||||
let stage = if progress_percent >= 100 {
|
||||
"ReadyToCompile"
|
||||
} else {
|
||||
@@ -865,6 +869,14 @@ async fn compile_match3d_draft_for_session(
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
if session.current_turn < 3 || session.progress_percent < 100 {
|
||||
return Err(match3d_bad_request(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
"match3d 创作配置尚未确认完成",
|
||||
));
|
||||
}
|
||||
|
||||
let config = resolve_config_or_default(session.config.as_ref());
|
||||
let tags_json = tags
|
||||
.as_ref()
|
||||
@@ -901,8 +913,12 @@ fn map_match3d_agent_session_response(
|
||||
session_id: session.session_id,
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: session.progress_percent,
|
||||
stage: session.stage,
|
||||
anchor_pack: map_match3d_anchor_pack_response(session.anchor_pack),
|
||||
stage: session.stage.clone(),
|
||||
anchor_pack: map_match3d_anchor_pack_response_for_turn(
|
||||
session.anchor_pack,
|
||||
session.current_turn,
|
||||
session.stage.as_str(),
|
||||
),
|
||||
config: session.config.map(map_match3d_config_response),
|
||||
draft: session.draft.map(map_match3d_draft_response),
|
||||
messages: session
|
||||
@@ -916,11 +932,35 @@ fn map_match3d_agent_session_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_anchor_pack_response(anchor: Match3DAnchorPackRecord) -> Match3DAnchorPackResponse {
|
||||
fn map_match3d_anchor_pack_response_for_turn(
|
||||
anchor: Match3DAnchorPackRecord,
|
||||
current_turn: u32,
|
||||
stage: &str,
|
||||
) -> Match3DAnchorPackResponse {
|
||||
let is_ready = matches!(
|
||||
stage,
|
||||
"ReadyToCompile"
|
||||
| "ready_to_compile"
|
||||
| "DraftCompiled"
|
||||
| "draft_compiled"
|
||||
| "draft_ready"
|
||||
| "ReadyToPublish"
|
||||
| "ready_to_publish"
|
||||
| "Published"
|
||||
| "published"
|
||||
);
|
||||
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
|
||||
|
||||
Match3DAnchorPackResponse {
|
||||
theme: map_match3d_anchor_item_response(anchor.theme),
|
||||
clear_count: map_match3d_anchor_item_response(anchor.clear_count),
|
||||
difficulty: map_match3d_anchor_item_response(anchor.difficulty),
|
||||
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
|
||||
clear_count: map_match3d_anchor_item_response_for_collected(
|
||||
anchor.clear_count,
|
||||
collected_count >= 2,
|
||||
),
|
||||
difficulty: map_match3d_anchor_item_response_for_collected(
|
||||
anchor.difficulty,
|
||||
collected_count >= 3,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -933,6 +973,22 @@ fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DA
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_anchor_item_response_for_collected(
|
||||
anchor: Match3DAnchorItemRecord,
|
||||
collected: bool,
|
||||
) -> Match3DAnchorItemResponse {
|
||||
if collected {
|
||||
return map_match3d_anchor_item_response(anchor);
|
||||
}
|
||||
|
||||
Match3DAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: String::new(),
|
||||
status: "missing".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
|
||||
Match3DCreatorConfigResponse {
|
||||
theme_text: config.theme_text,
|
||||
@@ -1096,31 +1152,51 @@ fn build_config_from_message(
|
||||
payload: &SendMatch3DAgentMessageRequest,
|
||||
) -> Match3DConfigJson {
|
||||
let current = resolve_config_or_default(session.config.as_ref());
|
||||
if payload.quick_fill_requested.unwrap_or(false) || payload.text.contains("自动配置") {
|
||||
return Match3DConfigJson {
|
||||
theme_text: if current.theme_text.trim().is_empty() {
|
||||
let text = payload.text.trim();
|
||||
let reference_image_src = payload
|
||||
.reference_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or(current.reference_image_src);
|
||||
let quick_fill_requested =
|
||||
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
|
||||
|
||||
let mut theme_text = current.theme_text;
|
||||
let mut clear_count = current.clear_count.max(1);
|
||||
let mut difficulty = current.difficulty.clamp(1, 10);
|
||||
|
||||
match session.current_turn {
|
||||
0 => {
|
||||
theme_text = if quick_fill_requested {
|
||||
MATCH3D_DEFAULT_THEME.to_string()
|
||||
} else {
|
||||
current.theme_text
|
||||
},
|
||||
reference_image_src: current.reference_image_src,
|
||||
clear_count: current.clear_count.max(1),
|
||||
difficulty: current.difficulty.clamp(1, 10),
|
||||
};
|
||||
parse_theme_answer(text).unwrap_or(theme_text)
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
clear_count = if quick_fill_requested {
|
||||
clear_count
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
|
||||
.unwrap_or(clear_count)
|
||||
}
|
||||
.max(1);
|
||||
}
|
||||
_ => {
|
||||
difficulty = if quick_fill_requested {
|
||||
difficulty
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
|
||||
}
|
||||
.clamp(1, 10);
|
||||
}
|
||||
}
|
||||
|
||||
let text = payload.text.trim();
|
||||
let theme_text = parse_theme_from_text(text).unwrap_or(current.theme_text);
|
||||
let clear_count = parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
|
||||
.unwrap_or(current.clear_count)
|
||||
.max(1);
|
||||
let difficulty = parse_number_after_keywords(text, &["难度", "difficulty"])
|
||||
.unwrap_or(current.difficulty)
|
||||
.clamp(1, 10);
|
||||
|
||||
Match3DConfigJson {
|
||||
theme_text,
|
||||
reference_image_src: current.reference_image_src,
|
||||
reference_image_src,
|
||||
clear_count,
|
||||
difficulty,
|
||||
}
|
||||
@@ -1174,19 +1250,25 @@ fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_progress_percent(config: &Match3DConfigJson) -> u32 {
|
||||
let completed = [
|
||||
!config.theme_text.trim().is_empty(),
|
||||
config.clear_count > 0,
|
||||
(1..=10).contains(&config.difficulty),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|done| *done)
|
||||
.count();
|
||||
((completed as u32) * 100) / 3
|
||||
fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
|
||||
match current_turn {
|
||||
0 => MATCH3D_QUESTION_THEME.to_string(),
|
||||
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
|
||||
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
|
||||
_ => build_match3d_assistant_reply(config),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_from_text(text: &str) -> Option<String> {
|
||||
fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
|
||||
match current_turn {
|
||||
0 => 0,
|
||||
1 => 33,
|
||||
2 => 66,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_answer(text: &str) -> Option<String> {
|
||||
for marker in ["题材", "主题"] {
|
||||
if let Some((_, value)) = text.split_once(marker) {
|
||||
let normalized = value
|
||||
@@ -1416,3 +1498,81 @@ fn current_utc_micros() -> i64 {
|
||||
fn current_utc_ms() -> i64 {
|
||||
current_utc_micros().saturating_div(1000)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson {
|
||||
Match3DConfigJson {
|
||||
theme_text: theme_text.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count,
|
||||
difficulty,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_agent_reply_asks_three_questions_before_confirmation() {
|
||||
let current = config("水果", 4, 6);
|
||||
|
||||
assert_eq!(
|
||||
build_match3d_assistant_reply_for_turn(¤t, 0),
|
||||
MATCH3D_QUESTION_THEME
|
||||
);
|
||||
assert_eq!(
|
||||
build_match3d_assistant_reply_for_turn(¤t, 1),
|
||||
MATCH3D_QUESTION_CLEAR_COUNT
|
||||
);
|
||||
assert_eq!(
|
||||
build_match3d_assistant_reply_for_turn(¤t, 2),
|
||||
MATCH3D_QUESTION_DIFFICULTY
|
||||
);
|
||||
assert_eq!(
|
||||
build_match3d_assistant_reply_for_turn(¤t, 3),
|
||||
"已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_agent_progress_follows_question_turns() {
|
||||
assert_eq!(resolve_progress_percent_for_turn(0), 0);
|
||||
assert_eq!(resolve_progress_percent_for_turn(1), 33);
|
||||
assert_eq!(resolve_progress_percent_for_turn(2), 66);
|
||||
assert_eq!(resolve_progress_percent_for_turn(3), 100);
|
||||
assert_eq!(resolve_progress_percent_for_turn(8), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_anchor_pack_masks_uncollected_default_values() {
|
||||
let pack = Match3DAnchorPackRecord {
|
||||
theme: Match3DAnchorItemRecord {
|
||||
key: "theme".to_string(),
|
||||
label: "题材主题".to_string(),
|
||||
value: "缤纷玩具".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
},
|
||||
clear_count: Match3DAnchorItemRecord {
|
||||
key: "clearCount".to_string(),
|
||||
label: "需要消除次数".to_string(),
|
||||
value: "12".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
},
|
||||
difficulty: Match3DAnchorItemRecord {
|
||||
key: "difficulty".to_string(),
|
||||
label: "难度".to_string(),
|
||||
value: "4".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting");
|
||||
|
||||
assert_eq!(response.theme.value, "");
|
||||
assert_eq!(response.theme.status, "missing");
|
||||
assert_eq!(response.clear_count.value, "");
|
||||
assert_eq!(response.clear_count.status, "missing");
|
||||
assert_eq!(response.difficulty.value, "");
|
||||
assert_eq!(response.difficulty.status, "missing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,13 +224,29 @@ impl AppState {
|
||||
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
|
||||
)
|
||||
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
|
||||
// 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 快照用于跨进程恢复。
|
||||
// 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
|
||||
#[cfg(not(test))]
|
||||
self.spacetime_client
|
||||
if let Err(error) = self
|
||||
.spacetime_client
|
||||
.upsert_auth_store_snapshot(snapshot_json, updated_at_micros)
|
||||
.await?;
|
||||
// ?????????????????????????????????
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
error = %error,
|
||||
"认证快照写入 SpacetimeDB 失败,当前认证流程继续"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// 写入快照后尝试拆入正式认证表;失败只影响远端表恢复,不阻断当前认证响应。
|
||||
#[cfg(not(test))]
|
||||
self.spacetime_client.import_auth_store_snapshot().await?;
|
||||
if let Err(error) = self.spacetime_client.import_auth_store_snapshot().await {
|
||||
warn!(
|
||||
error = %error,
|
||||
"认证快照导入 SpacetimeDB 正式表失败,当前认证流程继续"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,10 +15,26 @@ 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_RADIUS: f32 = 1.0;
|
||||
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 使用固定 10 组颜色形状 key;后续真实题材素材接入时仍保持 item_type_id 三个一组。
|
||||
const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [
|
||||
// 中文注释:首版 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",
|
||||
@@ -428,7 +444,12 @@ pub fn start_run_with_seed_at(
|
||||
total_item_count,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: build_initial_items(config.clear_count, config.difficulty, seed),
|
||||
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,
|
||||
@@ -561,18 +582,26 @@ pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec<Match3DItemSnapshot> {
|
||||
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 radius = resolve_item_radius(difficulty);
|
||||
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) % MATCH3D_DEMO_VISUAL_KEYS.len();
|
||||
let visual_index = (clear_index as usize) % visual_keys.len();
|
||||
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||||
let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string();
|
||||
let visual_key = visual_keys[visual_index].to_string();
|
||||
|
||||
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||||
let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius);
|
||||
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}"),
|
||||
@@ -601,21 +630,87 @@ fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec<Matc
|
||||
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 (x, y);
|
||||
return (MATCH3D_BOARD_CENTER + x, MATCH3D_BOARD_CENTER + y);
|
||||
}
|
||||
}
|
||||
(0.0, 0.0)
|
||||
(MATCH3D_BOARD_CENTER, MATCH3D_BOARD_CENTER)
|
||||
}
|
||||
|
||||
fn fully_covers(
|
||||
@@ -888,6 +983,117 @@ mod tests {
|
||||
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(
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct SendMatch3DAgentMessageRequest {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub quick_fill_requested: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
|
||||
Reference in New Issue
Block a user