fix: 完善拼消消模板运行规则
This commit is contained in:
@@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env, fs,
|
||||
path::{Path as FsPath, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -38,8 +40,8 @@ use crate::{
|
||||
},
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
@@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet {
|
||||
image: DownloadedOpenAiImage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PuzzleClearImageDebugRun {
|
||||
root: PathBuf,
|
||||
run_id: String,
|
||||
}
|
||||
|
||||
impl PuzzleClearImageDebugRun {
|
||||
fn record_spec(
|
||||
&self,
|
||||
sheet_specs: &[PuzzleClearAtlasSheetSpec],
|
||||
groups: &[PuzzleClearPatternGroup],
|
||||
) {
|
||||
let sheets = sheet_specs
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
json!({
|
||||
"sheetId": spec.sheet_id,
|
||||
"layout": spec.layout.iter().map(|row| row.to_vec()).collect::<Vec<_>>(),
|
||||
"layoutPrompt": spec.layout_prompt,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let groups = groups
|
||||
.iter()
|
||||
.map(|group| {
|
||||
json!({
|
||||
"groupId": group.group_id.as_str(),
|
||||
"shape": group.shape.as_str(),
|
||||
"width": group.width,
|
||||
"height": group.height,
|
||||
"atlasX": group.atlas_x,
|
||||
"atlasY": group.atlas_y,
|
||||
"atlasWidth": group.atlas_width,
|
||||
"atlasHeight": group.atlas_height,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.write_json(
|
||||
"specs/puzzle-clear-debug-spec.json",
|
||||
&json!({
|
||||
"cellSize": PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||||
"sheetColumns": PUZZLE_CLEAR_SHEET_COLUMNS,
|
||||
"sheetRows": PUZZLE_CLEAR_SHEET_ROWS,
|
||||
"finalAtlasColumns": PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
|
||||
"finalAtlasRows": PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
|
||||
"sheets": sheets,
|
||||
"groups": groups,
|
||||
}),
|
||||
"记录拼消消调试规格失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_board_background_request(&self, prompt: &str) {
|
||||
self.write_text(
|
||||
"prompts/board-background.txt",
|
||||
prompt,
|
||||
"记录拼消消底图 prompt 失败",
|
||||
);
|
||||
self.write_json(
|
||||
"requests/board-background.json",
|
||||
&json!({
|
||||
"endpoint": "/v1/images/generations",
|
||||
"body": build_openai_image_request_body(
|
||||
prompt,
|
||||
Some("文字、水印、按钮、教程浮层、明显网格"),
|
||||
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
|
||||
1,
|
||||
&[],
|
||||
),
|
||||
}),
|
||||
"记录拼消消底图 request 失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_board_background_image(
|
||||
&self,
|
||||
task_id: &str,
|
||||
actual_prompt: Option<&str>,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let extension = puzzle_clear_debug_image_extension(image);
|
||||
self.write_bytes(
|
||||
format!("background/board-background.{extension}"),
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消底图图片失败",
|
||||
);
|
||||
self.write_json(
|
||||
"responses/board-background.json",
|
||||
&json!({
|
||||
"taskId": task_id,
|
||||
"actualPrompt": actual_prompt,
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消底图 response 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_board_background_error(&self, error: &AppError) {
|
||||
self.write_json(
|
||||
"responses/board-background.error.json",
|
||||
&puzzle_clear_debug_error_json(error),
|
||||
"记录拼消消底图错误失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_request(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
prompt: &str,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
self.write_text(
|
||||
format!("prompts/{}.txt", sheet_spec.sheet_id),
|
||||
prompt,
|
||||
"记录拼消消 sheet prompt 失败",
|
||||
);
|
||||
self.write_json(
|
||||
format!("requests/{}-{attempt_id}.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"endpoint": "/v1/images/generations",
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"body": build_openai_image_request_body(
|
||||
prompt,
|
||||
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
|
||||
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
|
||||
1,
|
||||
&[],
|
||||
),
|
||||
}),
|
||||
"记录拼消消 sheet request 失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_generation_error(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
error: &AppError,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
self.write_json(
|
||||
format!("responses/{}-{attempt_id}.error.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"stage": "generation",
|
||||
"error": puzzle_clear_debug_error_json(error),
|
||||
}),
|
||||
"记录拼消消 sheet 生成错误失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_attempt_image(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
task_id: &str,
|
||||
actual_prompt: Option<&str>,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
let extension = puzzle_clear_debug_image_extension(image);
|
||||
self.write_bytes(
|
||||
format!("sheets/{}-{attempt_id}.{extension}", sheet_spec.sheet_id),
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消 sheet 原图失败",
|
||||
);
|
||||
self.write_json(
|
||||
format!("responses/{}-{attempt_id}.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"taskId": task_id,
|
||||
"actualPrompt": actual_prompt,
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消 sheet response 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_sheet_quality(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
quality_error: Option<&AppError>,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
match build_puzzle_clear_sheet_quality_debug_report(
|
||||
sheet_spec,
|
||||
attempt_index,
|
||||
task_id,
|
||||
image,
|
||||
quality_error,
|
||||
) {
|
||||
Ok(report) => {
|
||||
self.write_json(
|
||||
format!("reports/{}-{attempt_id}.quality.json", sheet_spec.sheet_id),
|
||||
&report,
|
||||
"记录拼消消 sheet 质量报告失败",
|
||||
);
|
||||
}
|
||||
Err(error) => self.write_json(
|
||||
format!("reports/{}-{attempt_id}.quality-error.json", sheet_spec.sheet_id),
|
||||
&puzzle_clear_debug_error_json(&error),
|
||||
"记录拼消消 sheet 质量报告错误失败",
|
||||
),
|
||||
}
|
||||
self.write_sheet_cells(sheet_spec, attempt_index, image);
|
||||
}
|
||||
|
||||
fn record_sheet_accepted(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let extension = puzzle_clear_debug_image_extension(image);
|
||||
self.write_bytes(
|
||||
format!("accepted/{}.{extension}", sheet_spec.sheet_id),
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消 accepted sheet 失败",
|
||||
);
|
||||
self.write_json(
|
||||
format!("accepted/{}.json", sheet_spec.sheet_id),
|
||||
&json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"taskId": task_id,
|
||||
"accepted": true,
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消 accepted sheet 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_atlas_image(
|
||||
&self,
|
||||
image: &DownloadedOpenAiImage,
|
||||
generated_sheets: &[PuzzleClearGeneratedSheet],
|
||||
) {
|
||||
self.write_bytes(
|
||||
"atlas/puzzle-clear-atlas.png",
|
||||
image.bytes.as_slice(),
|
||||
"记录拼消消最终 atlas 失败",
|
||||
);
|
||||
self.write_json(
|
||||
"atlas/puzzle-clear-atlas.json",
|
||||
&json!({
|
||||
"image": {
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
"acceptedSheets": generated_sheets
|
||||
.iter()
|
||||
.map(|sheet| json!({
|
||||
"sheetId": sheet.spec.sheet_id,
|
||||
"taskId": sheet.task_id.as_str(),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
"记录拼消消最终 atlas 摘要失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn record_summary_success(
|
||||
&self,
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
sheet_count: usize,
|
||||
card_count: usize,
|
||||
) {
|
||||
self.write_text(
|
||||
"summary.md",
|
||||
format!(
|
||||
concat!(
|
||||
"# 拼消消 runtime 生图调试\n\n",
|
||||
"- runId: `{}`\n",
|
||||
"- sessionId: `{}`\n",
|
||||
"- profileId: `{}`\n",
|
||||
"- status: `ready`\n",
|
||||
"- acceptedSheets: `{}`\n",
|
||||
"- cardCount: `{}`\n\n",
|
||||
"关键查看顺序:\n\n",
|
||||
"1. `prompts/` 和 `requests/`:真实请求内容。\n",
|
||||
"2. `sheets/`:每次 attempt 的原始 sheet。\n",
|
||||
"3. `reports/*.quality.json`:每格质量指标和 hard/advisory findings。\n",
|
||||
"4. `cells/<sheet-attempt>/contact-sheet.png`:4x6 裁切总览。\n",
|
||||
"5. `accepted/`:最终通过门禁的 sheet。\n",
|
||||
"6. `atlas/puzzle-clear-atlas.png`:最终 atlas 预览。\n"
|
||||
),
|
||||
self.run_id, session_id, profile_id, sheet_count, card_count
|
||||
),
|
||||
"记录拼消消调试 summary 失败",
|
||||
);
|
||||
}
|
||||
|
||||
fn write_sheet_cells(
|
||||
&self,
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
image: &DownloadedOpenAiImage,
|
||||
) {
|
||||
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
|
||||
let result = (|| -> Result<(), AppError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
"message": format!("拼消消素材 {} 调试裁切解码失败:{error}", sheet_spec.sheet_id),
|
||||
}))
|
||||
})?;
|
||||
let source_width = source.width();
|
||||
let source_height = source.height();
|
||||
let mut contact = image::RgbaImage::from_pixel(
|
||||
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_COLUMNS,
|
||||
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_ROWS,
|
||||
image::Rgba([255, 255, 255, 0]),
|
||||
);
|
||||
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
|
||||
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
|
||||
let group_id = sheet_spec.layout[row as usize][col as usize];
|
||||
let bounds =
|
||||
puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
|
||||
let cropped = source
|
||||
.crop_imm(bounds.x0, bounds.y0, bounds.width(), bounds.height())
|
||||
.resize_exact(
|
||||
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||||
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
.to_rgba8();
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(cropped.clone())
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
"message": format!("拼消消素材 {} 调试裁切写入失败:{error}", sheet_spec.sheet_id),
|
||||
}))
|
||||
})?;
|
||||
self.write_bytes(
|
||||
format!(
|
||||
"cells/{}-{attempt_id}/r{:02}-c{:02}-{}.png",
|
||||
sheet_spec.sheet_id,
|
||||
row + 1,
|
||||
col + 1,
|
||||
sanitize_puzzle_clear_debug_segment(group_id, "cell"),
|
||||
),
|
||||
cursor.into_inner().as_slice(),
|
||||
"记录拼消消 sheet 裁切格失败",
|
||||
);
|
||||
image::imageops::overlay(
|
||||
&mut contact,
|
||||
&cropped,
|
||||
i64::from(col * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
|
||||
i64::from(row * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(contact)
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
"message": format!("拼消消素材 {} 调试 contact sheet 写入失败:{error}", sheet_spec.sheet_id),
|
||||
}))
|
||||
})?;
|
||||
self.write_bytes(
|
||||
format!("cells/{}-{attempt_id}/contact-sheet.png", sheet_spec.sheet_id),
|
||||
cursor.into_inner().as_slice(),
|
||||
"记录拼消消 sheet contact sheet 失败",
|
||||
);
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
self.write_json(
|
||||
format!("cells/{}-{attempt_id}/error.json", sheet_spec.sheet_id),
|
||||
&puzzle_clear_debug_error_json(&error),
|
||||
"记录拼消消 sheet 裁切错误失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_text(
|
||||
&self,
|
||||
relative_path: impl AsRef<FsPath>,
|
||||
content: impl AsRef<str>,
|
||||
action: &'static str,
|
||||
) {
|
||||
self.write_result(action, || {
|
||||
let target = self.root.join(relative_path.as_ref());
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(target, content.as_ref().as_bytes())
|
||||
});
|
||||
}
|
||||
|
||||
fn write_json(&self, relative_path: impl AsRef<FsPath>, value: &Value, action: &'static str) {
|
||||
self.write_result(action, || {
|
||||
let target = self.root.join(relative_path.as_ref());
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut bytes = serde_json::to_vec_pretty(value)
|
||||
.map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?;
|
||||
bytes.push(b'\n');
|
||||
fs::write(target, bytes)
|
||||
});
|
||||
}
|
||||
|
||||
fn write_bytes(
|
||||
&self,
|
||||
relative_path: impl AsRef<FsPath>,
|
||||
bytes: &[u8],
|
||||
action: &'static str,
|
||||
) {
|
||||
self.write_result(action, || {
|
||||
let target = self.root.join(relative_path.as_ref());
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(target, bytes)
|
||||
});
|
||||
}
|
||||
|
||||
fn write_result(
|
||||
&self,
|
||||
action: &'static str,
|
||||
operation: impl FnOnce() -> std::io::Result<()>,
|
||||
) {
|
||||
if let Err(error) = operation() {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
run_id = self.run_id,
|
||||
debug_path = %self.root.display(),
|
||||
error = %error,
|
||||
action,
|
||||
"拼消消本地生图调试包写入失败,已忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_create_puzzle_clear_image_debug_run(
|
||||
session_id: &str,
|
||||
profile_id: &str,
|
||||
theme_prompt: &str,
|
||||
) -> Option<PuzzleClearImageDebugRun> {
|
||||
if !puzzle_clear_image_debug_enabled() {
|
||||
return None;
|
||||
}
|
||||
let base_dir = puzzle_clear_image_debug_runs_dir();
|
||||
let run_id = env::var("PUZZLE_CLEAR_IMAGE_DEBUG_RUN_ID")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"runtime-{}-{}-{}",
|
||||
sanitize_puzzle_clear_debug_segment(session_id, "session"),
|
||||
sanitize_puzzle_clear_debug_segment(profile_id, "profile"),
|
||||
current_utc_micros()
|
||||
)
|
||||
});
|
||||
let run_id = sanitize_puzzle_clear_debug_segment(run_id.as_str(), "runtime");
|
||||
let root = base_dir.join(run_id.as_str());
|
||||
if let Err(error) = fs::create_dir_all(&root) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
debug_path = %root.display(),
|
||||
error = %error,
|
||||
"拼消消本地生图调试包初始化失败,已关闭本次记录"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let debug_run = PuzzleClearImageDebugRun { root, run_id };
|
||||
debug_run.write_json(
|
||||
"manifest.json",
|
||||
&json!({
|
||||
"runId": debug_run.run_id.as_str(),
|
||||
"source": "api-server-runtime",
|
||||
"playType": "puzzle-clear",
|
||||
"sessionId": session_id,
|
||||
"profileId": profile_id,
|
||||
"themePrompt": theme_prompt,
|
||||
"createdAt": format_timestamp_micros(current_utc_micros()),
|
||||
"env": {
|
||||
"PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED": "1",
|
||||
"PUZZLE_CLEAR_IMAGE_DEBUG_DIR": puzzle_clear_image_debug_runs_dir().display().to_string(),
|
||||
},
|
||||
}),
|
||||
"记录拼消消调试 manifest 失败",
|
||||
);
|
||||
if let Err(error) = fs::write(
|
||||
puzzle_clear_image_debug_runs_dir().join("latest-runtime.txt"),
|
||||
debug_run.root.to_string_lossy().as_bytes(),
|
||||
) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
error = %error,
|
||||
"拼消消本地生图调试包 latest-runtime.txt 写入失败,已忽略"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
run_id = debug_run.run_id.as_str(),
|
||||
debug_path = %debug_run.root.display(),
|
||||
"拼消消本地生图调试包已开启"
|
||||
);
|
||||
Some(debug_run)
|
||||
}
|
||||
|
||||
fn puzzle_clear_image_debug_enabled() -> bool {
|
||||
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED")
|
||||
.ok()
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn puzzle_clear_image_debug_runs_dir() -> PathBuf {
|
||||
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
puzzle_clear_repo_root()
|
||||
.join(".app")
|
||||
.join("puzzle-clear-image-debug")
|
||||
.join("runs")
|
||||
})
|
||||
}
|
||||
|
||||
fn puzzle_clear_repo_root() -> PathBuf {
|
||||
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
for candidate in current_dir.ancestors() {
|
||||
if candidate.join("server-rs").is_dir() && candidate.join("package.json").is_file() {
|
||||
return candidate.to_path_buf();
|
||||
}
|
||||
}
|
||||
current_dir
|
||||
}
|
||||
|
||||
fn puzzle_clear_debug_attempt_id(attempt_index: usize) -> String {
|
||||
format!("attempt-{:02}", attempt_index + 1)
|
||||
}
|
||||
|
||||
fn puzzle_clear_debug_image_extension(image: &DownloadedOpenAiImage) -> String {
|
||||
sanitize_puzzle_clear_debug_segment(
|
||||
image
|
||||
.extension
|
||||
.trim()
|
||||
.trim_start_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"png",
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_puzzle_clear_debug_segment(raw: &str, fallback: &str) -> String {
|
||||
let mut value = String::new();
|
||||
for character in raw.chars() {
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
|
||||
value.push(character);
|
||||
} else {
|
||||
value.push('-');
|
||||
}
|
||||
}
|
||||
let value = value.trim_matches('-').trim_matches('.').to_string();
|
||||
if value.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn puzzle_clear_debug_error_json(error: &AppError) -> Value {
|
||||
json!({
|
||||
"statusCode": error.status_code().as_u16(),
|
||||
"code": error.code(),
|
||||
"message": error.body_text(),
|
||||
"details": error.details(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_clear_sheet_quality_debug_report(
|
||||
sheet_spec: &PuzzleClearAtlasSheetSpec,
|
||||
attempt_index: usize,
|
||||
task_id: &str,
|
||||
image: &DownloadedOpenAiImage,
|
||||
quality_error: Option<&AppError>,
|
||||
) -> Result<Value, AppError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
"message": format!("拼消消素材 {} 调试质量报告解码失败:{error}", sheet_spec.sheet_id),
|
||||
}))
|
||||
})?;
|
||||
let source_width = source.width();
|
||||
let source_height = source.height();
|
||||
let mut hard_findings = Vec::new();
|
||||
let mut advisory_findings = Vec::new();
|
||||
let mut cells = Vec::new();
|
||||
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
|
||||
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
|
||||
let group_id = sheet_spec.layout[row as usize][col as usize];
|
||||
let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
|
||||
let quality =
|
||||
analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds);
|
||||
let cell_label = format!("第{}行第{}列", row + 1, col + 1);
|
||||
let mut cell_findings = Vec::new();
|
||||
let mut cell_advisory_findings = Vec::new();
|
||||
|
||||
if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL {
|
||||
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
|
||||
let finding = format!("{cell_label} 空白格有主体");
|
||||
hard_findings.push(finding.clone());
|
||||
cell_findings.push(finding);
|
||||
}
|
||||
} else if group_id != PUZZLE_CLEAR_SHEET_FILLER_CELL {
|
||||
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
|
||||
let finding = format!("{cell_label} 主体过少");
|
||||
hard_findings.push(finding.clone());
|
||||
cell_findings.push(finding);
|
||||
}
|
||||
if quality.strongest_internal_seam_ratio
|
||||
> PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
|
||||
{
|
||||
let finding = format!("{cell_label} 单格内部疑似拼接线");
|
||||
hard_findings.push(finding.clone());
|
||||
cell_findings.push(finding);
|
||||
}
|
||||
if quality.exposed_edge_count >= 2
|
||||
&& quality.strongest_edge_ratio
|
||||
> PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
|
||||
{
|
||||
let finding = format!("{cell_label} 主体贴到不同图案边界");
|
||||
advisory_findings.push(finding.clone());
|
||||
cell_advisory_findings.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
cells.push(json!({
|
||||
"row": row + 1,
|
||||
"col": col + 1,
|
||||
"groupId": group_id,
|
||||
"discarded": is_puzzle_clear_sheet_discarded_cell(group_id),
|
||||
"bounds": {
|
||||
"x0": bounds.x0,
|
||||
"y0": bounds.y0,
|
||||
"x1": bounds.x1,
|
||||
"y1": bounds.y1,
|
||||
},
|
||||
"foregroundRatio": quality.foreground_ratio,
|
||||
"exposedEdgeCount": quality.exposed_edge_count,
|
||||
"strongestEdgeRatio": quality.strongest_edge_ratio,
|
||||
"strongestInternalSeamRatio": quality.strongest_internal_seam_ratio,
|
||||
"hardFindings": cell_findings,
|
||||
"advisoryFindings": cell_advisory_findings,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"sheetId": sheet_spec.sheet_id,
|
||||
"attempt": attempt_index + 1,
|
||||
"taskId": task_id,
|
||||
"accepted": quality_error.is_none(),
|
||||
"image": {
|
||||
"width": source_width,
|
||||
"height": source_height,
|
||||
"mimeType": image.mime_type.as_str(),
|
||||
"extension": image.extension.as_str(),
|
||||
"byteLength": image.bytes.len(),
|
||||
},
|
||||
"thresholds": {
|
||||
"foregroundDiff": PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD,
|
||||
"minForegroundRatio": PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO,
|
||||
"blankMaxForegroundRatio": PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO,
|
||||
"edgeRatio": PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD,
|
||||
"strongEdgeRatio": PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD,
|
||||
"internalSeamDiff": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD,
|
||||
"internalSeamRatio": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD,
|
||||
"internalSeamSideContrast": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD,
|
||||
"internalSeamSideTextureMax": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX,
|
||||
},
|
||||
"hardFindings": hard_findings,
|
||||
"advisoryFindings": advisory_findings,
|
||||
"qualityError": quality_error.map(puzzle_clear_debug_error_json),
|
||||
"cells": cells,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
@@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX)
|
||||
});
|
||||
});
|
||||
payload.profile_id = Some(profile_id.clone());
|
||||
let image_debug_run = maybe_create_puzzle_clear_image_debug_run(
|
||||
session_id,
|
||||
profile_id.as_str(),
|
||||
payload.theme_prompt.as_deref().unwrap_or_default(),
|
||||
);
|
||||
|
||||
if payload.generate_board_background.unwrap_or(false)
|
||||
&& payload
|
||||
@@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
owner_user_id,
|
||||
profile_id.as_str(),
|
||||
board_background_prompt.unwrap_or(theme_prompt),
|
||||
image_debug_run.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
payload.board_background_asset = Some(background_asset);
|
||||
@@ -683,6 +1406,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
.map(|group| (group.group_id.clone(), group))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let sheet_specs = puzzle_clear_atlas_sheet_specs();
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_spec(&sheet_specs, &groups);
|
||||
}
|
||||
let settings = require_openai_image_settings(state)
|
||||
.map(|settings| {
|
||||
settings.with_external_api_audit_context(
|
||||
@@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
sheet_spec.sheet_id,
|
||||
attempt_index + 1
|
||||
);
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_request(&sheet_spec, attempt_index, sheet_prompt.as_str());
|
||||
}
|
||||
let generated = match create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
@@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
|
||||
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
|
||||
{
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
|
||||
}
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
sheet_id = sheet_spec.sheet_id,
|
||||
@@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
|
||||
}
|
||||
return Err(puzzle_clear_error_response(
|
||||
request_context,
|
||||
PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
@@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
}
|
||||
};
|
||||
let task_id = generated.task_id;
|
||||
let actual_prompt = generated.actual_prompt;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
puzzle_clear_error_response(
|
||||
request_context,
|
||||
@@ -755,8 +1491,30 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_attempt_image(
|
||||
&sheet_spec,
|
||||
attempt_index,
|
||||
task_id.as_str(),
|
||||
actual_prompt.as_deref(),
|
||||
&image,
|
||||
);
|
||||
}
|
||||
let quality_result = validate_puzzle_clear_sheet_quality(&image, &sheet_spec);
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_quality(
|
||||
&sheet_spec,
|
||||
attempt_index,
|
||||
task_id.as_str(),
|
||||
&image,
|
||||
quality_result.as_ref().err(),
|
||||
);
|
||||
}
|
||||
match quality_result {
|
||||
Ok(()) => {
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image);
|
||||
}
|
||||
accepted_sheet = Some(PuzzleClearGeneratedSheet {
|
||||
spec: sheet_spec,
|
||||
prompt: sheet_prompt.clone(),
|
||||
@@ -821,6 +1579,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
|
||||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_atlas_image(&atlas_image, &generated_sheets);
|
||||
}
|
||||
let atlas_prompt = generated_sheets
|
||||
.iter()
|
||||
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
|
||||
@@ -864,6 +1625,14 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
|
||||
payload.atlas_asset = Some(atlas_asset);
|
||||
payload.pattern_groups = Some(groups);
|
||||
payload.card_assets = Some(card_assets);
|
||||
if let Some(debug_run) = image_debug_run.as_ref() {
|
||||
debug_run.record_summary_success(
|
||||
session_id,
|
||||
profile_id.as_str(),
|
||||
generated_sheets.len(),
|
||||
payload.card_assets.as_ref().map_or(0, Vec::len),
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
|
||||
session_id,
|
||||
@@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background(
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
theme_prompt: &str,
|
||||
image_debug_run: Option<&PuzzleClearImageDebugRun>,
|
||||
) -> Result<PuzzleClearImageAsset, Response> {
|
||||
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
|
||||
if let Some(debug_run) = image_debug_run {
|
||||
debug_run.record_board_background_request(prompt.as_str());
|
||||
}
|
||||
let settings = require_openai_image_settings(state)
|
||||
.map(|settings| {
|
||||
settings.with_external_api_audit_context(
|
||||
@@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let Some(debug_run) = image_debug_run {
|
||||
debug_run.record_board_background_error(&error);
|
||||
}
|
||||
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let task_id = generated.task_id;
|
||||
let actual_prompt = generated.actual_prompt;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
puzzle_clear_error_response(
|
||||
request_context,
|
||||
@@ -1095,6 +1872,9 @@ async fn generate_and_persist_puzzle_clear_board_background(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if let Some(debug_run) = image_debug_run {
|
||||
debug_run.record_board_background_image(task_id.as_str(), actual_prompt.as_deref(), &image);
|
||||
}
|
||||
persist_puzzle_clear_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
|
||||
if value.contains("发布需要")
|
||||
|| value.contains("不能为空")
|
||||
|| value.contains("必须")
|
||||
|| value.contains("无权") =>
|
||||
|| value.contains("无权")
|
||||
|| value.contains("puzzle-clear 坐标无效")
|
||||
|| value.contains("puzzle-clear 目标格子没有卡牌")
|
||||
|| value.contains("puzzle-clear 当前 run 不在 playing 状态")
|
||||
|| value.contains("puzzle-clear 当前关卡已经超时") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
|
||||
@@ -3,25 +3,55 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
use shared_kernel::normalize_required_string;
|
||||
|
||||
use crate::{
|
||||
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell,
|
||||
PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig,
|
||||
PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot,
|
||||
PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota,
|
||||
PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination,
|
||||
PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation,
|
||||
PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind,
|
||||
PuzzleClearShapeQuota,
|
||||
};
|
||||
|
||||
pub fn puzzle_clear_level_configs() -> Vec<PuzzleClearLevelConfig> {
|
||||
vec![PuzzleClearLevelConfig {
|
||||
level_index: 1,
|
||||
board_size: 6,
|
||||
target_clears: 35,
|
||||
duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS,
|
||||
unlocked_shapes: vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
PuzzleClearShapeKind::TwoByTwo,
|
||||
PuzzleClearShapeKind::TwoByThree,
|
||||
],
|
||||
}]
|
||||
vec![
|
||||
PuzzleClearLevelConfig {
|
||||
level_index: 1,
|
||||
board_size: 6,
|
||||
target_clears: 15,
|
||||
duration_seconds: 300,
|
||||
unlocked_shapes: vec![PuzzleClearShapeKind::OneByTwo],
|
||||
},
|
||||
PuzzleClearLevelConfig {
|
||||
level_index: 2,
|
||||
board_size: 6,
|
||||
target_clears: 20,
|
||||
duration_seconds: 300,
|
||||
unlocked_shapes: vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
],
|
||||
},
|
||||
PuzzleClearLevelConfig {
|
||||
level_index: 3,
|
||||
board_size: 6,
|
||||
target_clears: 30,
|
||||
duration_seconds: 420,
|
||||
unlocked_shapes: vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
PuzzleClearShapeKind::TwoByTwo,
|
||||
],
|
||||
},
|
||||
PuzzleClearLevelConfig {
|
||||
level_index: 4,
|
||||
board_size: 6,
|
||||
target_clears: 35,
|
||||
duration_seconds: 600,
|
||||
unlocked_shapes: vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
PuzzleClearShapeKind::TwoByTwo,
|
||||
PuzzleClearShapeKind::TwoByThree,
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn puzzle_clear_shape_quotas() -> Vec<PuzzleClearShapeQuota> {
|
||||
@@ -114,7 +144,7 @@ pub fn create_puzzle_clear_board(
|
||||
return Err(PuzzleClearError::InvalidLevel);
|
||||
}
|
||||
let total = (level.board_size * level.board_size) as usize;
|
||||
if cards.len() < total {
|
||||
if cards.is_empty() {
|
||||
return Err(PuzzleClearError::EmptyDeck);
|
||||
}
|
||||
let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index));
|
||||
@@ -125,10 +155,12 @@ pub fn create_puzzle_clear_board(
|
||||
for row in 0..level.board_size {
|
||||
for col in 0..level.board_size {
|
||||
let index = (row * level.board_size + col) as usize;
|
||||
let empty_slots = total.saturating_sub(selected.len());
|
||||
let card_index = index.checked_sub(empty_slots);
|
||||
cells.push(PuzzleClearCell {
|
||||
row,
|
||||
col,
|
||||
card: selected.get(index).cloned(),
|
||||
card: card_index.and_then(|card_index| selected.get(card_index).cloned()),
|
||||
locked_group_id: None,
|
||||
});
|
||||
}
|
||||
@@ -202,7 +234,7 @@ pub fn apply_puzzle_clear_swap(
|
||||
.into_iter()
|
||||
.find(|config| config.level_index == next.level_index)
|
||||
.ok_or(PuzzleClearError::InvalidLevel)?;
|
||||
if next.clears_done >= level.target_clears && !has_remaining_cards(&next.board) {
|
||||
if next.clears_done >= level.target_clears && !has_remaining_cards_in_run(&next) {
|
||||
next.status = if next.level_index >= max_puzzle_clear_level_index() {
|
||||
PuzzleClearRunStatus::Finished
|
||||
} else {
|
||||
@@ -401,8 +433,13 @@ fn ensure_not_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> Result<(), P
|
||||
}
|
||||
|
||||
fn is_level_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> bool {
|
||||
let duration_seconds = puzzle_clear_level_configs()
|
||||
.into_iter()
|
||||
.find(|config| config.level_index == run.level_index)
|
||||
.map(|config| config.duration_seconds)
|
||||
.unwrap_or(600);
|
||||
now_ms.saturating_sub(run.level_started_at_ms)
|
||||
> u64::from(PUZZLE_CLEAR_LEVEL_DURATION_SECONDS) * 1000
|
||||
> u64::from(duration_seconds) * 1000
|
||||
}
|
||||
|
||||
fn validate_board(board: &PuzzleClearBoard) -> Result<(), PuzzleClearError> {
|
||||
@@ -421,6 +458,15 @@ fn has_remaining_cards(board: &PuzzleClearBoard) -> bool {
|
||||
board.cells.iter().any(|cell| cell.card.is_some())
|
||||
}
|
||||
|
||||
fn has_remaining_cards_in_run(run: &PuzzleClearRunSnapshot) -> bool {
|
||||
has_remaining_cards(&run.board)
|
||||
|| run
|
||||
.deck
|
||||
.ready_columns
|
||||
.iter()
|
||||
.any(|column| !column.is_empty())
|
||||
}
|
||||
|
||||
fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> {
|
||||
if find_eliminations(board).is_empty() && has_playable_move(board) {
|
||||
return Ok(());
|
||||
@@ -490,15 +536,7 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
|
||||
if entries.len() < 2 || first.shape == PuzzleClearShapeKind::OneByTwo {
|
||||
return None;
|
||||
}
|
||||
let mut ordered = entries.clone();
|
||||
ordered.sort_by_key(|(_, _, card)| (card.part_y, card.part_x));
|
||||
let adjacent = ordered.windows(2).all(|pair| {
|
||||
let a = &pair[0].2;
|
||||
let b = &pair[1].2;
|
||||
manhattan_part_distance(a, b) == 1
|
||||
&& are_neighbors(pair[0].0, pair[0].1, pair[1].0, pair[1].1)
|
||||
});
|
||||
adjacent.then(|| PuzzleClearElimination {
|
||||
is_connected_partial_group(&entries).then(|| PuzzleClearElimination {
|
||||
group_id,
|
||||
positions: entries
|
||||
.into_iter()
|
||||
@@ -509,6 +547,32 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_connected_partial_group(entries: &[(u32, u32, PuzzleClearCard)]) -> bool {
|
||||
if entries.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let mut visited = vec![false; entries.len()];
|
||||
let mut stack = vec![0usize];
|
||||
visited[0] = true;
|
||||
|
||||
while let Some(index) = stack.pop() {
|
||||
let current = &entries[index];
|
||||
for (candidate_index, candidate) in entries.iter().enumerate() {
|
||||
if visited[candidate_index] {
|
||||
continue;
|
||||
}
|
||||
if manhattan_part_distance(¤t.2, &candidate.2) == 1
|
||||
&& are_neighbors(current.0, current.1, candidate.0, candidate.1)
|
||||
{
|
||||
visited[candidate_index] = true;
|
||||
stack.push(candidate_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visited.into_iter().all(|is_visited| is_visited)
|
||||
}
|
||||
|
||||
fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) {
|
||||
for cell in &mut board.cells {
|
||||
if cell.locked_group_id.as_deref() == Some(group_id) {
|
||||
@@ -1177,14 +1241,40 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_level_config_uses_single_six_by_six_level() {
|
||||
fn fixed_level_config_uses_four_six_by_six_levels() {
|
||||
let configs = puzzle_clear_level_configs();
|
||||
|
||||
assert_eq!(configs.len(), 1);
|
||||
assert_eq!(configs[0].board_size, 6);
|
||||
assert_eq!(configs[0].target_clears, 35);
|
||||
assert_eq!(configs.len(), 4);
|
||||
assert!(configs.iter().all(|config| config.board_size == 6));
|
||||
assert_eq!(
|
||||
configs[0].unlocked_shapes,
|
||||
configs
|
||||
.iter()
|
||||
.map(|config| (
|
||||
config.level_index,
|
||||
config.target_clears,
|
||||
config.duration_seconds
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(1, 15, 300), (2, 20, 300), (3, 30, 420), (4, 35, 600)]
|
||||
);
|
||||
assert_eq!(configs[0].unlocked_shapes, vec![PuzzleClearShapeKind::OneByTwo]);
|
||||
assert_eq!(
|
||||
configs[1].unlocked_shapes,
|
||||
vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
configs[2].unlocked_shapes,
|
||||
vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
PuzzleClearShapeKind::TwoByTwo,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
configs[3].unlocked_shapes,
|
||||
vec![
|
||||
PuzzleClearShapeKind::OneByTwo,
|
||||
PuzzleClearShapeKind::OneByThree,
|
||||
@@ -1192,7 +1282,6 @@ mod tests {
|
||||
PuzzleClearShapeKind::TwoByThree,
|
||||
]
|
||||
);
|
||||
assert!(configs.iter().all(|config| config.duration_seconds == 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1250,6 +1339,23 @@ mod tests {
|
||||
assert!(has_playable_move(&board));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_level_board_uses_exact_target_cards_and_leaves_empty_cells() {
|
||||
let groups = plan_puzzle_clear_pattern_groups(64).expect("atlas should plan");
|
||||
let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear")
|
||||
.into_iter()
|
||||
.filter(|card| card.shape == PuzzleClearShapeKind::OneByTwo)
|
||||
.take(30)
|
||||
.collect::<Vec<_>>();
|
||||
let board = create_puzzle_clear_board(&puzzle_clear_level_configs()[0], "seed-a", cards)
|
||||
.expect("board should create with empty cells");
|
||||
|
||||
assert_eq!(board.cells.iter().filter(|cell| cell.card.is_some()).count(), 30);
|
||||
assert_eq!(board.cells.iter().filter(|cell| cell.card.is_none()).count(), 6);
|
||||
assert!(find_eliminations(&board).is_empty());
|
||||
assert!(has_playable_move(&board));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_by_two_neighbors_are_not_half_locked() {
|
||||
let board = board_from_cards(
|
||||
@@ -1350,7 +1456,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaching_target_clears_without_empty_board_keeps_playing() {
|
||||
fn reaching_target_clears_does_not_complete_level_with_remaining_cards() {
|
||||
let board = board_from_cards(
|
||||
3,
|
||||
vec![
|
||||
@@ -1376,7 +1482,7 @@ mod tests {
|
||||
100,
|
||||
)
|
||||
.expect("run should start");
|
||||
run.clears_done = 4;
|
||||
run.clears_done = 14;
|
||||
let next = apply_puzzle_clear_swap(
|
||||
&run,
|
||||
PuzzleClearMove {
|
||||
@@ -1389,11 +1495,57 @@ mod tests {
|
||||
)
|
||||
.expect("swap should resolve");
|
||||
|
||||
assert_eq!(next.clears_done, 5);
|
||||
assert_eq!(next.clears_done, 15);
|
||||
assert_eq!(next.status, PuzzleClearRunStatus::Playing);
|
||||
assert!(next.finished_at_ms.is_none());
|
||||
assert!(next.board.cells.iter().any(|cell| cell.card.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaching_target_clears_completes_level_after_all_cards_are_removed() {
|
||||
let board = board_from_cards(
|
||||
3,
|
||||
vec![
|
||||
Some(card("play", 0, 0)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(card("play", 1, 0)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
],
|
||||
);
|
||||
let mut run = start_puzzle_clear_run(
|
||||
"run-target-empty".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
board,
|
||||
PuzzleClearDeck {
|
||||
ready_columns: vec![vec![], vec![], vec![]],
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect("run should start");
|
||||
run.clears_done = 14;
|
||||
let next = apply_puzzle_clear_swap(
|
||||
&run,
|
||||
PuzzleClearMove {
|
||||
from_row: 1,
|
||||
from_col: 1,
|
||||
to_row: 0,
|
||||
to_col: 1,
|
||||
},
|
||||
200,
|
||||
)
|
||||
.expect("swap should resolve");
|
||||
|
||||
assert_eq!(next.clears_done, 15);
|
||||
assert_eq!(next.status, PuzzleClearRunStatus::LevelCleared);
|
||||
assert!(next.board.cells.iter().all(|cell| cell.card.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refill_keeps_locked_partial_group_in_place() {
|
||||
let mut board = board_from_cards(
|
||||
@@ -1737,6 +1889,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_by_two_l_shaped_partial_group_locks_as_one_group() {
|
||||
let board = board_from_cards(
|
||||
3,
|
||||
vec![
|
||||
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 0)),
|
||||
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 1, 0)),
|
||||
Some(card("noise-a", 0, 0)),
|
||||
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 1)),
|
||||
Some(card("noise-b", 0, 0)),
|
||||
Some(card("play", 1, 0)),
|
||||
Some(card("noise-d", 0, 0)),
|
||||
Some(card("play", 0, 0)),
|
||||
Some(card("noise-c", 0, 0)),
|
||||
],
|
||||
);
|
||||
let run = start_puzzle_clear_run(
|
||||
"run-2x2-partial".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
board,
|
||||
PuzzleClearDeck {
|
||||
ready_columns: vec![vec![], vec![], vec![]],
|
||||
},
|
||||
100,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let locked = apply_puzzle_clear_swap(
|
||||
&run,
|
||||
PuzzleClearMove {
|
||||
from_row: 1,
|
||||
from_col: 1,
|
||||
to_row: 2,
|
||||
to_col: 0,
|
||||
},
|
||||
200,
|
||||
)
|
||||
.expect("non-clear swap should lock partial group");
|
||||
|
||||
let block_locks = locked
|
||||
.board
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|cell| cell.card.as_ref().is_some_and(|card| card.group_id == "block"))
|
||||
.map(|cell| cell.locked_group_id.as_deref())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(block_locks, vec![Some("block"), Some("block"), Some("block")]);
|
||||
assert_eq!(locked.clears_done, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_fails_only_current_level_and_retry_restarts_it() {
|
||||
let board = board_from_cards(
|
||||
|
||||
@@ -6,8 +6,8 @@ pub use types::*;
|
||||
|
||||
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json};
|
||||
use module_puzzle_clear::{
|
||||
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck,
|
||||
PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level,
|
||||
PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearMove,
|
||||
PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level,
|
||||
apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout,
|
||||
parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs,
|
||||
retry_puzzle_clear_level, start_puzzle_clear_run,
|
||||
@@ -976,6 +976,7 @@ fn build_level_board_and_deck(
|
||||
.collect::<Vec<_>>(),
|
||||
seed,
|
||||
level.target_clears as usize,
|
||||
&level.unlocked_shapes,
|
||||
);
|
||||
let board = create_puzzle_clear_board(&level, seed, allowed.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -991,6 +992,7 @@ fn ordered_level_cards(
|
||||
cards: Vec<PuzzleClearCard>,
|
||||
seed: &str,
|
||||
target_groups: usize,
|
||||
unlocked_shapes: &[module_puzzle_clear::PuzzleClearShapeKind],
|
||||
) -> Vec<PuzzleClearCard> {
|
||||
let mut groups: BTreeMap<String, Vec<PuzzleClearCard>> = BTreeMap::new();
|
||||
for card in cards {
|
||||
@@ -1008,9 +1010,40 @@ fn ordered_level_cards(
|
||||
.unwrap_or_default();
|
||||
left_key.cmp(&right_key)
|
||||
});
|
||||
grouped
|
||||
let mut selected = Vec::new();
|
||||
let mut selected_group_ids = std::collections::BTreeSet::new();
|
||||
for shape in unlocked_shapes {
|
||||
if selected.len() >= target_groups {
|
||||
break;
|
||||
}
|
||||
let Some(group) = grouped.iter().find(|group| {
|
||||
group
|
||||
.first()
|
||||
.is_some_and(|card| card.shape == *shape && !selected_group_ids.contains(&card.group_id))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(first) = group.first() {
|
||||
selected_group_ids.insert(first.group_id.clone());
|
||||
selected.push(group.clone());
|
||||
}
|
||||
}
|
||||
for group in grouped {
|
||||
if selected.len() >= target_groups {
|
||||
break;
|
||||
}
|
||||
let Some(first) = group.first() else {
|
||||
continue;
|
||||
};
|
||||
if selected_group_ids.contains(&first.group_id) {
|
||||
continue;
|
||||
}
|
||||
selected_group_ids.insert(first.group_id.clone());
|
||||
selected.push(group);
|
||||
}
|
||||
|
||||
selected
|
||||
.into_iter()
|
||||
.take(target_groups)
|
||||
.flat_map(|mut group| {
|
||||
group.sort_by_key(|card| (card.part_y, card.part_x));
|
||||
group
|
||||
@@ -1061,7 +1094,7 @@ fn build_runtime_snapshot(
|
||||
level_index: snapshot.level_index,
|
||||
clears_done: snapshot.clears_done,
|
||||
target_clears: level.target_clears,
|
||||
level_duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS,
|
||||
level_duration_seconds: level.duration_seconds,
|
||||
level_started_at_ms: snapshot.level_started_at_ms,
|
||||
board: PuzzleClearBoardSnapshot {
|
||||
rows: snapshot.board.rows,
|
||||
|
||||
Reference in New Issue
Block a user