完善抓大鹅创作入口与运行态表现

This commit is contained in:
2026-05-01 22:07:55 +08:00
parent 8c03ec95c6
commit 9a3db67e13
25 changed files with 1320 additions and 183 deletions

View File

@@ -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(&current, 0),
MATCH3D_QUESTION_THEME
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 1),
MATCH3D_QUESTION_CLEAR_COUNT
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 2),
MATCH3D_QUESTION_DIFFICULTY
);
assert_eq!(
build_match3d_assistant_reply_for_turn(&current, 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");
}
}

View File

@@ -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(())
}

View File

@@ -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(

View File

@@ -3465,10 +3465,7 @@ pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_p
.saturating_sub(claimed_points)
}
pub fn puzzle_point_incentive_total_after_spend(
total_half_points: u64,
spent_points: u64,
) -> u64 {
pub fn puzzle_point_incentive_total_after_spend(total_half_points: u64, spent_points: u64) -> u64 {
total_half_points.saturating_add(spent_points)
}

View File

@@ -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)]

View File

@@ -2745,10 +2745,11 @@ fn accrue_puzzle_point_incentive(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: module_puzzle::puzzle_point_incentive_total_after_spend(
row.point_incentive_total_half_points,
spent_points,
),
point_incentive_total_half_points:
module_puzzle::puzzle_point_incentive_total_after_spend(
row.point_incentive_total_half_points,
spent_points,
),
point_incentive_claimed_points: row.point_incentive_claimed_points,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,