2087 lines
72 KiB
Rust
2087 lines
72 KiB
Rust
pub(crate) mod tables;
|
|
mod types;
|
|
|
|
pub use tables::*;
|
|
pub use types::*;
|
|
|
|
use crate::*;
|
|
use module_match3d::{
|
|
Match3DClickInput as DomainMatch3DClickInput,
|
|
Match3DClickRejectReason as DomainMatch3DClickRejectReason,
|
|
Match3DCreatorConfig as DomainMatch3DCreatorConfig,
|
|
Match3DFailureReason as DomainMatch3DFailureReason,
|
|
Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState,
|
|
Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus,
|
|
Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at,
|
|
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at_and_item_type_count,
|
|
stop_run_at as stop_domain_run_at,
|
|
};
|
|
use serde::Serialize;
|
|
use serde::de::DeserializeOwned;
|
|
use serde_json::Value;
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn create_match3d_agent_session(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DAgentSessionCreateInput,
|
|
) -> Match3DAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| create_match3d_agent_session_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_match3d_agent_session(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DAgentSessionGetInput,
|
|
) -> Match3DAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| get_match3d_agent_session_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn submit_match3d_agent_message(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DAgentMessageSubmitInput,
|
|
) -> Match3DAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| submit_match3d_agent_message_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn finalize_match3d_agent_message_turn(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DAgentMessageFinalizeInput,
|
|
) -> Match3DAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| finalize_match3d_agent_message_turn_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn compile_match3d_draft(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DDraftCompileInput,
|
|
) -> Match3DAgentSessionProcedureResult {
|
|
match ctx.try_with_tx(|tx| compile_match3d_draft_tx(tx, input.clone())) {
|
|
Ok(session) => session_result(session),
|
|
Err(message) => session_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn update_match3d_work(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DWorkUpdateInput,
|
|
) -> Match3DWorkProcedureResult {
|
|
match ctx.try_with_tx(|tx| update_match3d_work_tx(tx, input.clone())) {
|
|
Ok(work) => work_result(work),
|
|
Err(message) => work_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn publish_match3d_work(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DWorkPublishInput,
|
|
) -> Match3DWorkProcedureResult {
|
|
match ctx.try_with_tx(|tx| publish_match3d_work_tx(tx, input.clone())) {
|
|
Ok(work) => work_result(work),
|
|
Err(message) => work_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn list_match3d_works(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DWorksListInput,
|
|
) -> Match3DWorksProcedureResult {
|
|
match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) {
|
|
Ok(items) => Match3DWorksProcedureResult {
|
|
ok: true,
|
|
items_json: Some(to_json_string(&items)),
|
|
error_message: None,
|
|
},
|
|
Err(message) => Match3DWorksProcedureResult {
|
|
ok: false,
|
|
items_json: None,
|
|
error_message: Some(message),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_match3d_work_detail(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DWorkGetInput,
|
|
) -> Match3DWorkProcedureResult {
|
|
match ctx.try_with_tx(|tx| get_match3d_work_detail_tx(tx, input.clone())) {
|
|
Ok(work) => work_result(work),
|
|
Err(message) => work_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn delete_match3d_work(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DWorkDeleteInput,
|
|
) -> Match3DWorksProcedureResult {
|
|
match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) {
|
|
Ok(items) => Match3DWorksProcedureResult {
|
|
ok: true,
|
|
items_json: Some(to_json_string(&items)),
|
|
error_message: None,
|
|
},
|
|
Err(message) => Match3DWorksProcedureResult {
|
|
ok: false,
|
|
items_json: None,
|
|
error_message: Some(message),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn start_match3d_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DRunStartInput,
|
|
) -> Match3DRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| start_match3d_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn get_match3d_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DRunGetInput,
|
|
) -> Match3DRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| get_match3d_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn click_match3d_item(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DRunClickInput,
|
|
) -> Match3DClickItemProcedureResult {
|
|
match ctx.try_with_tx(|tx| click_match3d_item_tx(tx, input.clone())) {
|
|
Ok(result) => result,
|
|
Err(message) => Match3DClickItemProcedureResult {
|
|
ok: false,
|
|
status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(),
|
|
run_json: None,
|
|
accepted_item_instance_id: None,
|
|
cleared_item_instance_ids: Vec::new(),
|
|
failure_reason: None,
|
|
error_message: Some(message),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn stop_match3d_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DRunStopInput,
|
|
) -> Match3DRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| stop_match3d_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn restart_match3d_run(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DRunRestartInput,
|
|
) -> Match3DRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| restart_match3d_run_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
#[spacetimedb::procedure]
|
|
pub fn finish_match3d_time_up(
|
|
ctx: &mut ProcedureContext,
|
|
input: Match3DRunTimeUpInput,
|
|
) -> Match3DRunProcedureResult {
|
|
match ctx.try_with_tx(|tx| finish_match3d_time_up_tx(tx, input.clone())) {
|
|
Ok(run) => run_result(run),
|
|
Err(message) => run_error(message),
|
|
}
|
|
}
|
|
|
|
fn create_match3d_agent_session_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DAgentSessionCreateInput,
|
|
) -> Result<Match3DAgentSessionSnapshot, String> {
|
|
require_non_empty(&input.session_id, "match3d session_id")?;
|
|
require_non_empty(&input.owner_user_id, "match3d owner_user_id")?;
|
|
require_non_empty(&input.welcome_message_id, "match3d welcome_message_id")?;
|
|
if ctx
|
|
.db
|
|
.match3d_agent_session()
|
|
.session_id()
|
|
.find(&input.session_id)
|
|
.is_some()
|
|
{
|
|
return Err("match3d_agent_session.session_id 已存在".to_string());
|
|
}
|
|
if ctx
|
|
.db
|
|
.match3d_agent_message()
|
|
.message_id()
|
|
.find(&input.welcome_message_id)
|
|
.is_some()
|
|
{
|
|
return Err("match3d_agent_message.message_id 已存在".to_string());
|
|
}
|
|
|
|
let config = input
|
|
.config_json
|
|
.as_deref()
|
|
.map(parse_config)
|
|
.transpose()?
|
|
.unwrap_or_else(|| default_config_from_seed(&input.seed_text));
|
|
validate_config(&config)?;
|
|
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
|
|
let welcome = input.welcome_message_text.trim();
|
|
|
|
ctx.db
|
|
.match3d_agent_session()
|
|
.insert(Match3DAgentSessionRow {
|
|
session_id: input.session_id.clone(),
|
|
owner_user_id: input.owner_user_id.clone(),
|
|
seed_text: input.seed_text.trim().to_string(),
|
|
current_turn: 0,
|
|
progress_percent: 0,
|
|
stage: MATCH3D_STAGE_COLLECTING.to_string(),
|
|
config_json: to_json_string(&config),
|
|
draft_json: String::new(),
|
|
last_assistant_reply: welcome.to_string(),
|
|
published_profile_id: String::new(),
|
|
created_at,
|
|
updated_at: created_at,
|
|
});
|
|
ctx.db
|
|
.match3d_agent_message()
|
|
.insert(Match3DAgentMessageRow {
|
|
message_id: input.welcome_message_id,
|
|
session_id: input.session_id.clone(),
|
|
role: MATCH3D_ROLE_ASSISTANT.to_string(),
|
|
kind: MATCH3D_KIND_TEXT.to_string(),
|
|
text: welcome.to_string(),
|
|
created_at,
|
|
});
|
|
|
|
get_match3d_agent_session_tx(
|
|
ctx,
|
|
Match3DAgentSessionGetInput {
|
|
session_id: input.session_id,
|
|
owner_user_id: input.owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn get_match3d_agent_session_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DAgentSessionGetInput,
|
|
) -> Result<Match3DAgentSessionSnapshot, String> {
|
|
let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
|
build_session_snapshot(ctx, &row)
|
|
}
|
|
|
|
fn submit_match3d_agent_message_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DAgentMessageSubmitInput,
|
|
) -> Result<Match3DAgentSessionSnapshot, String> {
|
|
require_non_empty(&input.user_message_id, "match3d user_message_id")?;
|
|
require_non_empty(&input.user_message_text, "match3d user_message_text")?;
|
|
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
|
if ctx
|
|
.db
|
|
.match3d_agent_message()
|
|
.message_id()
|
|
.find(&input.user_message_id)
|
|
.is_some()
|
|
{
|
|
return Err("match3d_agent_message.user_message_id 已存在".to_string());
|
|
}
|
|
|
|
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
|
|
ctx.db
|
|
.match3d_agent_message()
|
|
.insert(Match3DAgentMessageRow {
|
|
message_id: input.user_message_id,
|
|
session_id: input.session_id.clone(),
|
|
role: MATCH3D_ROLE_USER.to_string(),
|
|
kind: MATCH3D_KIND_TEXT.to_string(),
|
|
text: input.user_message_text.trim().to_string(),
|
|
created_at: submitted_at,
|
|
});
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
Match3DAgentSessionRow {
|
|
updated_at: submitted_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
|
|
get_match3d_agent_session_tx(
|
|
ctx,
|
|
Match3DAgentSessionGetInput {
|
|
session_id: input.session_id,
|
|
owner_user_id: input.owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn finalize_match3d_agent_message_turn_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DAgentMessageFinalizeInput,
|
|
) -> Result<Match3DAgentSessionSnapshot, String> {
|
|
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
|
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
|
if let Some(message) = input
|
|
.error_message
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
Match3DAgentSessionRow {
|
|
updated_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
return Err(message.to_string());
|
|
}
|
|
|
|
let next_config = input
|
|
.config_json
|
|
.as_deref()
|
|
.map(parse_config)
|
|
.transpose()?
|
|
.unwrap_or_else(|| parse_config_or_default(&session.config_json));
|
|
validate_config(&next_config)?;
|
|
let assistant_text = input
|
|
.assistant_reply_text
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(&session.last_assistant_reply)
|
|
.to_string();
|
|
if let Some(message_id) = input
|
|
.assistant_message_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
if ctx
|
|
.db
|
|
.match3d_agent_message()
|
|
.message_id()
|
|
.find(&message_id.to_string())
|
|
.is_some()
|
|
{
|
|
return Err("match3d_agent_message.assistant_message_id 已存在".to_string());
|
|
}
|
|
ctx.db
|
|
.match3d_agent_message()
|
|
.insert(Match3DAgentMessageRow {
|
|
message_id: message_id.to_string(),
|
|
session_id: input.session_id.clone(),
|
|
role: MATCH3D_ROLE_ASSISTANT.to_string(),
|
|
kind: MATCH3D_KIND_TEXT.to_string(),
|
|
text: assistant_text.clone(),
|
|
created_at: updated_at,
|
|
});
|
|
}
|
|
|
|
let next_stage = normalize_stage(&input.stage);
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
Match3DAgentSessionRow {
|
|
current_turn: session.current_turn.saturating_add(1),
|
|
progress_percent: input.progress_percent.min(100),
|
|
stage: next_stage,
|
|
config_json: to_json_string(&next_config),
|
|
last_assistant_reply: assistant_text,
|
|
updated_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
|
|
get_match3d_agent_session_tx(
|
|
ctx,
|
|
Match3DAgentSessionGetInput {
|
|
session_id: input.session_id,
|
|
owner_user_id: input.owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn compile_match3d_draft_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DDraftCompileInput,
|
|
) -> Result<Match3DAgentSessionSnapshot, String> {
|
|
require_non_empty(&input.profile_id, "match3d profile_id")?;
|
|
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
|
|
let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?);
|
|
validate_config(&config)?;
|
|
let existing_work = ctx
|
|
.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.find(&input.profile_id)
|
|
.filter(|row| row.owner_user_id == input.owner_user_id);
|
|
let tags = resolve_compile_tags(
|
|
input.tags_json.as_deref(),
|
|
existing_work.as_ref(),
|
|
config.theme_text.as_str(),
|
|
)?;
|
|
let game_name = resolve_compile_game_name(
|
|
&input.game_name,
|
|
existing_work.as_ref(),
|
|
config.theme_text.as_str(),
|
|
);
|
|
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
|
|
let draft = Match3DDraftSnapshot {
|
|
profile_id: input.profile_id.clone(),
|
|
game_name: game_name.clone(),
|
|
theme_text: config.theme_text.clone(),
|
|
summary_text: summary_text.clone(),
|
|
tags: tags.clone(),
|
|
clear_count: config.clear_count,
|
|
difficulty: config.difficulty,
|
|
};
|
|
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
|
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
|
|
input.generated_item_assets_json.as_deref(),
|
|
existing_work.as_ref(),
|
|
)?;
|
|
let previous_publication_status = existing_work
|
|
.as_ref()
|
|
.map(|work| work.publication_status.clone())
|
|
.unwrap_or_else(|| MATCH3D_PUBLICATION_DRAFT.to_string());
|
|
let previous_play_count = existing_work
|
|
.as_ref()
|
|
.map(|work| work.play_count)
|
|
.unwrap_or(0);
|
|
let previous_published_at = existing_work.as_ref().and_then(|work| work.published_at);
|
|
let cover_image_src = resolve_compile_optional_text(
|
|
&input.cover_image_src,
|
|
existing_work
|
|
.as_ref()
|
|
.map(|work| work.cover_image_src.as_str()),
|
|
);
|
|
let cover_asset_id = resolve_compile_optional_text(
|
|
&input.cover_asset_id,
|
|
existing_work
|
|
.as_ref()
|
|
.map(|work| work.cover_asset_id.as_str()),
|
|
);
|
|
let work = Match3DWorkProfileRow {
|
|
profile_id: input.profile_id.clone(),
|
|
owner_user_id: input.owner_user_id.clone(),
|
|
source_session_id: input.session_id.clone(),
|
|
author_display_name: clean_string(&input.author_display_name, "陶泥儿主"),
|
|
game_name,
|
|
theme_text: config.theme_text.clone(),
|
|
summary_text,
|
|
tags_json: to_json_string(&tags),
|
|
cover_image_src,
|
|
cover_asset_id,
|
|
clear_count: config.clear_count,
|
|
difficulty: config.difficulty,
|
|
config_json: to_json_string(&config),
|
|
publication_status: previous_publication_status,
|
|
play_count: previous_play_count,
|
|
updated_at: compiled_at,
|
|
published_at: previous_published_at,
|
|
generated_item_assets_json,
|
|
};
|
|
upsert_work(ctx, work);
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
Match3DAgentSessionRow {
|
|
progress_percent: 80,
|
|
stage: MATCH3D_STAGE_DRAFT_COMPILED.to_string(),
|
|
draft_json: to_json_string(&draft),
|
|
published_profile_id: input.profile_id,
|
|
last_assistant_reply: "抓大鹅玩法草稿已生成,可以进入结果页编辑基础信息并试玩。"
|
|
.to_string(),
|
|
updated_at: compiled_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
|
|
get_match3d_agent_session_tx(
|
|
ctx,
|
|
Match3DAgentSessionGetInput {
|
|
session_id: input.session_id,
|
|
owner_user_id: input.owner_user_id,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn update_match3d_work_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DWorkUpdateInput,
|
|
) -> Result<Match3DWorkSnapshot, String> {
|
|
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
|
let tags = parse_tags(&input.tags_json)?;
|
|
let config = Match3DCreatorConfigSnapshot {
|
|
theme_text: clean_string(&input.theme_text, "经典消除"),
|
|
..parse_config_or_default(¤t.config_json)
|
|
};
|
|
let config = Match3DCreatorConfigSnapshot {
|
|
clear_count: input.clear_count,
|
|
difficulty: input.difficulty,
|
|
..config
|
|
};
|
|
validate_config(&config)?;
|
|
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
|
let next = Match3DWorkProfileRow {
|
|
profile_id: current.profile_id.clone(),
|
|
owner_user_id: current.owner_user_id.clone(),
|
|
source_session_id: current.source_session_id.clone(),
|
|
author_display_name: current.author_display_name.clone(),
|
|
game_name: clean_string(&input.game_name, "未命名抓大鹅"),
|
|
theme_text: config.theme_text.clone(),
|
|
summary_text: clean_string(&input.summary_text, "经典消除玩法"),
|
|
tags_json: to_json_string(&tags),
|
|
cover_image_src: input.cover_image_src.trim().to_string(),
|
|
cover_asset_id: input.cover_asset_id.trim().to_string(),
|
|
clear_count: config.clear_count,
|
|
difficulty: config.difficulty,
|
|
config_json: to_json_string(&config),
|
|
publication_status: current.publication_status.clone(),
|
|
play_count: current.play_count,
|
|
updated_at,
|
|
published_at: current.published_at,
|
|
generated_item_assets_json: current.generated_item_assets_json.clone(),
|
|
};
|
|
let snapshot = build_work_snapshot(&next)?;
|
|
replace_work(ctx, ¤t, next);
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn publish_match3d_work_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DWorkPublishInput,
|
|
) -> Result<Match3DWorkSnapshot, String> {
|
|
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
|
validate_publishable_work(¤t)?;
|
|
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
|
|
let next = Match3DWorkProfileRow {
|
|
publication_status: MATCH3D_PUBLICATION_PUBLISHED.to_string(),
|
|
updated_at: published_at,
|
|
published_at: Some(published_at),
|
|
..clone_work(¤t)
|
|
};
|
|
let snapshot = build_work_snapshot(&next)?;
|
|
if !next.source_session_id.is_empty() {
|
|
if let Some(session) = ctx
|
|
.db
|
|
.match3d_agent_session()
|
|
.session_id()
|
|
.find(&next.source_session_id)
|
|
.filter(|row| row.owner_user_id == input.owner_user_id)
|
|
{
|
|
replace_session(
|
|
ctx,
|
|
&session,
|
|
Match3DAgentSessionRow {
|
|
progress_percent: 100,
|
|
stage: MATCH3D_STAGE_PUBLISHED.to_string(),
|
|
published_profile_id: next.profile_id.clone(),
|
|
updated_at: published_at,
|
|
..clone_session(&session)
|
|
},
|
|
);
|
|
}
|
|
}
|
|
replace_work(ctx, ¤t, next);
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn list_match3d_works_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DWorksListInput,
|
|
) -> Result<Vec<Match3DWorkSnapshot>, String> {
|
|
let mut items = ctx
|
|
.db
|
|
.match3d_work_profile()
|
|
.iter()
|
|
.filter(|row| {
|
|
if input.published_only {
|
|
row.publication_status == MATCH3D_PUBLICATION_PUBLISHED
|
|
} else {
|
|
row.owner_user_id == input.owner_user_id
|
|
}
|
|
})
|
|
.map(|row| build_work_snapshot(&row))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
items.sort_by(|left, right| {
|
|
right
|
|
.updated_at_micros
|
|
.cmp(&left.updated_at_micros)
|
|
.then_with(|| left.profile_id.cmp(&right.profile_id))
|
|
});
|
|
Ok(items)
|
|
}
|
|
|
|
fn get_match3d_work_detail_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DWorkGetInput,
|
|
) -> Result<Match3DWorkSnapshot, String> {
|
|
let row = ctx
|
|
.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.find(&input.profile_id)
|
|
.filter(|row| {
|
|
row.owner_user_id == input.owner_user_id
|
|
|| row.publication_status == MATCH3D_PUBLICATION_PUBLISHED
|
|
})
|
|
.ok_or_else(|| "match3d_work_profile 不存在".to_string())?;
|
|
build_work_snapshot(&row)
|
|
}
|
|
|
|
fn delete_match3d_work_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DWorkDeleteInput,
|
|
) -> Result<Vec<Match3DWorkSnapshot>, String> {
|
|
let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
|
|
ctx.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.delete(&work.profile_id);
|
|
for run in ctx
|
|
.db
|
|
.match3d_runtime_run()
|
|
.iter()
|
|
.filter(|row| {
|
|
row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id
|
|
})
|
|
.collect::<Vec<_>>()
|
|
{
|
|
ctx.db.match3d_runtime_run().run_id().delete(&run.run_id);
|
|
}
|
|
list_match3d_works_tx(
|
|
ctx,
|
|
Match3DWorksListInput {
|
|
owner_user_id: input.owner_user_id,
|
|
published_only: false,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn start_match3d_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DRunStartInput,
|
|
) -> Result<Match3DRunSnapshot, String> {
|
|
require_non_empty(&input.run_id, "match3d run_id")?;
|
|
if ctx
|
|
.db
|
|
.match3d_runtime_run()
|
|
.run_id()
|
|
.find(&input.run_id)
|
|
.is_some()
|
|
{
|
|
return Err("match3d_runtime_run.run_id 已存在".to_string());
|
|
}
|
|
let work = ctx
|
|
.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.find(&input.profile_id)
|
|
.filter(|row| {
|
|
row.owner_user_id == input.owner_user_id
|
|
|| row.publication_status == MATCH3D_PUBLICATION_PUBLISHED
|
|
})
|
|
.ok_or_else(|| "match3d_work_profile 不存在".to_string())?;
|
|
let started_at_ms = if input.started_at_ms > 0 {
|
|
input.started_at_ms
|
|
} else {
|
|
current_server_ms(ctx)
|
|
};
|
|
let mut snapshot = build_initial_run_snapshot(
|
|
&input.run_id,
|
|
&work,
|
|
started_at_ms,
|
|
normalize_match3d_item_type_count_override(input.item_type_count_override),
|
|
);
|
|
snapshot.server_now_ms = current_server_ms(ctx);
|
|
snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms);
|
|
let now = ctx.timestamp;
|
|
ctx.db.match3d_runtime_run().insert(row_from_snapshot(
|
|
&input.owner_user_id,
|
|
&snapshot,
|
|
now,
|
|
now,
|
|
));
|
|
|
|
Ok(snapshot)
|
|
}
|
|
|
|
fn get_match3d_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DRunGetInput,
|
|
) -> Result<Match3DRunSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
|
|
let next = confirm_time_up_if_needed(ctx, &row, snapshot, current_server_ms(ctx))?;
|
|
Ok(next)
|
|
}
|
|
|
|
fn click_match3d_item_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DRunClickInput,
|
|
) -> Result<Match3DClickItemProcedureResult, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
|
|
let server_now_ms = current_server_ms(ctx);
|
|
let snapshot = confirm_time_up_if_needed(ctx, &row, snapshot, server_now_ms)?;
|
|
if snapshot.status != MATCH3D_RUN_RUNNING {
|
|
return Ok(click_result(
|
|
MATCH3D_CLICK_RUN_FINISHED,
|
|
snapshot,
|
|
None,
|
|
Vec::new(),
|
|
));
|
|
}
|
|
if snapshot.snapshot_version != input.client_snapshot_version {
|
|
return Ok(click_result(
|
|
MATCH3D_CLICK_VERSION_CONFLICT,
|
|
snapshot,
|
|
None,
|
|
Vec::new(),
|
|
));
|
|
}
|
|
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
|
|
let confirmation = confirm_domain_click_at(
|
|
&domain_run,
|
|
&DomainMatch3DClickInput {
|
|
run_id: input.run_id.clone(),
|
|
owner_user_id: input.owner_user_id.clone(),
|
|
item_instance_id: input.item_instance_id.clone(),
|
|
client_action_id: clean_string(&input.client_event_id, "match3d-action"),
|
|
snapshot_version: input.client_snapshot_version as u64,
|
|
clicked_at_ms: to_u64_ms(server_now_ms),
|
|
},
|
|
)
|
|
.map_err(|error| error.to_string())?;
|
|
let next = snapshot_from_domain(&confirmation.run, server_now_ms);
|
|
let status = if confirmation.accepted {
|
|
MATCH3D_CLICK_ACCEPTED
|
|
} else {
|
|
match confirmation.reject_reason {
|
|
Some(DomainMatch3DClickRejectReason::RunNotActive) => MATCH3D_CLICK_RUN_FINISHED,
|
|
Some(DomainMatch3DClickRejectReason::SnapshotVersionMismatch) => {
|
|
MATCH3D_CLICK_VERSION_CONFLICT
|
|
}
|
|
Some(DomainMatch3DClickRejectReason::ItemNotFound)
|
|
| Some(DomainMatch3DClickRejectReason::ItemNotInBoard) => {
|
|
MATCH3D_CLICK_REJECTED_ALREADY_MOVED
|
|
}
|
|
Some(DomainMatch3DClickRejectReason::ItemNotClickable) => {
|
|
MATCH3D_CLICK_REJECTED_NOT_CLICKABLE
|
|
}
|
|
Some(DomainMatch3DClickRejectReason::TrayFull) => MATCH3D_CLICK_REJECTED_TRAY_FULL,
|
|
None => MATCH3D_CLICK_REJECTED_NOT_CLICKABLE,
|
|
}
|
|
};
|
|
if confirmation.accepted
|
|
|| status == MATCH3D_CLICK_REJECTED_TRAY_FULL
|
|
|| next.status != snapshot.status
|
|
|| next.snapshot_version != snapshot.snapshot_version
|
|
{
|
|
persist_snapshot(ctx, &row, &next, server_now_ms);
|
|
}
|
|
Ok(click_result(
|
|
status,
|
|
next,
|
|
confirmation.accepted.then_some(input.item_instance_id),
|
|
confirmation.cleared_item_instance_ids,
|
|
))
|
|
}
|
|
|
|
fn stop_match3d_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DRunStopInput,
|
|
) -> Result<Match3DRunSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let stopped_at_ms = input.stopped_at_ms.max(current_server_ms(ctx));
|
|
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
|
|
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
|
|
let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(stopped_at_ms));
|
|
let domain_run = stop_domain_run_at(&domain_run, "match3d-stop".to_string());
|
|
let next = snapshot_from_domain(&domain_run, stopped_at_ms);
|
|
persist_snapshot(ctx, &row, &next, stopped_at_ms);
|
|
Ok(next)
|
|
}
|
|
|
|
fn restart_match3d_run_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DRunRestartInput,
|
|
) -> Result<Match3DRunSnapshot, String> {
|
|
let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?;
|
|
let item_type_count_override = resolve_item_type_count_override_from_run(&source);
|
|
start_match3d_run_tx(
|
|
ctx,
|
|
Match3DRunStartInput {
|
|
run_id: input.next_run_id,
|
|
owner_user_id: input.owner_user_id,
|
|
profile_id: source.profile_id,
|
|
started_at_ms: input.restarted_at_ms,
|
|
item_type_count_override,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn finish_match3d_time_up_tx(
|
|
ctx: &ReducerContext,
|
|
input: Match3DRunTimeUpInput,
|
|
) -> Result<Match3DRunSnapshot, String> {
|
|
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
|
|
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
|
|
let finished_at_ms = input.finished_at_ms.max(current_server_ms(ctx));
|
|
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
|
|
let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(finished_at_ms));
|
|
let next = snapshot_from_domain(&domain_run, finished_at_ms);
|
|
persist_snapshot(ctx, &row, &next, finished_at_ms);
|
|
Ok(next)
|
|
}
|
|
|
|
fn find_owned_session(
|
|
ctx: &ReducerContext,
|
|
session_id: &str,
|
|
owner_user_id: &str,
|
|
) -> Result<Match3DAgentSessionRow, String> {
|
|
require_non_empty(session_id, "match3d session_id")?;
|
|
require_non_empty(owner_user_id, "match3d owner_user_id")?;
|
|
ctx.db
|
|
.match3d_agent_session()
|
|
.session_id()
|
|
.find(&session_id.to_string())
|
|
.filter(|row| row.owner_user_id == owner_user_id)
|
|
.ok_or_else(|| "match3d_agent_session 不存在".to_string())
|
|
}
|
|
|
|
fn find_owned_work(
|
|
ctx: &ReducerContext,
|
|
profile_id: &str,
|
|
owner_user_id: &str,
|
|
) -> Result<Match3DWorkProfileRow, String> {
|
|
require_non_empty(profile_id, "match3d profile_id")?;
|
|
require_non_empty(owner_user_id, "match3d owner_user_id")?;
|
|
ctx.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.find(&profile_id.to_string())
|
|
.filter(|row| row.owner_user_id == owner_user_id)
|
|
.ok_or_else(|| "match3d_work_profile 不存在".to_string())
|
|
}
|
|
|
|
fn find_owned_run(
|
|
ctx: &ReducerContext,
|
|
run_id: &str,
|
|
owner_user_id: &str,
|
|
) -> Result<Match3DRuntimeRunRow, String> {
|
|
require_non_empty(run_id, "match3d run_id")?;
|
|
require_non_empty(owner_user_id, "match3d owner_user_id")?;
|
|
ctx.db
|
|
.match3d_runtime_run()
|
|
.run_id()
|
|
.find(&run_id.to_string())
|
|
.filter(|row| row.owner_user_id == owner_user_id)
|
|
.ok_or_else(|| "match3d_runtime_run 不存在".to_string())
|
|
}
|
|
|
|
fn build_session_snapshot(
|
|
ctx: &ReducerContext,
|
|
row: &Match3DAgentSessionRow,
|
|
) -> Result<Match3DAgentSessionSnapshot, String> {
|
|
let mut messages = ctx
|
|
.db
|
|
.match3d_agent_message()
|
|
.iter()
|
|
.filter(|message| message.session_id == row.session_id)
|
|
.map(|message| Match3DAgentMessageSnapshot {
|
|
message_id: message.message_id,
|
|
session_id: message.session_id,
|
|
role: message.role,
|
|
kind: message.kind,
|
|
text: message.text,
|
|
created_at_micros: message.created_at.to_micros_since_unix_epoch(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
messages.sort_by(|left, right| {
|
|
left.created_at_micros
|
|
.cmp(&right.created_at_micros)
|
|
.then_with(|| left.message_id.cmp(&right.message_id))
|
|
});
|
|
let config = parse_config(&row.config_json)?;
|
|
let draft = if row.draft_json.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(parse_json::<Match3DDraftSnapshot>(
|
|
&row.draft_json,
|
|
"match3d draft_json",
|
|
)?)
|
|
};
|
|
|
|
Ok(Match3DAgentSessionSnapshot {
|
|
session_id: row.session_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
seed_text: row.seed_text.clone(),
|
|
current_turn: row.current_turn,
|
|
progress_percent: row.progress_percent,
|
|
stage: row.stage.clone(),
|
|
config,
|
|
draft,
|
|
messages,
|
|
last_assistant_reply: row.last_assistant_reply.clone(),
|
|
published_profile_id: empty_to_none(&row.published_profile_id),
|
|
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
|
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
|
})
|
|
}
|
|
|
|
fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result<Match3DWorkSnapshot, String> {
|
|
let config = parse_config(&row.config_json)?;
|
|
let tags = parse_tags(&row.tags_json)?;
|
|
Ok(Match3DWorkSnapshot {
|
|
profile_id: row.profile_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
source_session_id: row.source_session_id.clone(),
|
|
author_display_name: row.author_display_name.clone(),
|
|
game_name: row.game_name.clone(),
|
|
theme_text: row.theme_text.clone(),
|
|
summary_text: row.summary_text.clone(),
|
|
tags,
|
|
cover_image_src: row.cover_image_src.clone(),
|
|
cover_asset_id: row.cover_asset_id.clone(),
|
|
clear_count: row.clear_count,
|
|
difficulty: row.difficulty,
|
|
config,
|
|
publication_status: row.publication_status.clone(),
|
|
publish_ready: is_work_publish_ready(row),
|
|
play_count: row.play_count,
|
|
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
|
published_at_micros: row
|
|
.published_at
|
|
.map(|value| value.to_micros_since_unix_epoch()),
|
|
generated_item_assets_json: normalize_generated_item_assets_json(
|
|
row.generated_item_assets_json.as_deref(),
|
|
)?,
|
|
})
|
|
}
|
|
|
|
fn build_initial_run_snapshot(
|
|
run_id: &str,
|
|
work: &Match3DWorkProfileRow,
|
|
started_at_ms: i64,
|
|
item_type_count_override: Option<u32>,
|
|
) -> Match3DRunSnapshot {
|
|
let config = parse_config_or_default(&work.config_json);
|
|
let mut domain_config =
|
|
domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config());
|
|
domain_config.clear_count = module_match3d::normalize_match3d_runtime_clear_count(
|
|
domain_config.clear_count,
|
|
domain_config.difficulty,
|
|
);
|
|
let domain_started_at_ms = to_u64_ms(started_at_ms);
|
|
let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty);
|
|
let domain_run = start_run_with_seed_at_and_item_type_count(
|
|
run_id.to_string(),
|
|
work.owner_user_id.clone(),
|
|
work.profile_id.clone(),
|
|
&domain_config,
|
|
seed,
|
|
domain_started_at_ms,
|
|
item_type_count_override,
|
|
)
|
|
.unwrap_or_else(|_| DomainMatch3DRunSnapshot {
|
|
run_id: run_id.to_string(),
|
|
profile_id: work.profile_id.clone(),
|
|
owner_user_id: work.owner_user_id.clone(),
|
|
status: DomainMatch3DRunStatus::Running,
|
|
started_at_ms: domain_started_at_ms,
|
|
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64,
|
|
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64,
|
|
clear_count: work.clear_count.max(1),
|
|
total_item_count: work.clear_count.max(1).saturating_mul(3),
|
|
cleared_item_count: 0,
|
|
board_version: 1,
|
|
items: Vec::new(),
|
|
tray_slots: Vec::new(),
|
|
failure_reason: None,
|
|
last_confirmed_action_id: None,
|
|
});
|
|
snapshot_from_domain(&domain_run, started_at_ms)
|
|
}
|
|
|
|
fn normalize_match3d_item_type_count_override(value: u32) -> Option<u32> {
|
|
(value > 0).then_some(value)
|
|
}
|
|
|
|
fn resolve_item_type_count_override_from_run(row: &Match3DRuntimeRunRow) -> u32 {
|
|
deserialize_snapshot(&row.snapshot_json)
|
|
.ok()
|
|
.map(|snapshot| {
|
|
let mut item_type_ids = snapshot
|
|
.items
|
|
.iter()
|
|
.map(|item| item.item_type_id.clone())
|
|
.collect::<Vec<_>>();
|
|
item_type_ids.sort();
|
|
item_type_ids.dedup();
|
|
item_type_ids.len() as u32
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn fallback_domain_config() -> DomainMatch3DCreatorConfig {
|
|
DomainMatch3DCreatorConfig {
|
|
theme_text: "经典消除".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 1,
|
|
difficulty: 3,
|
|
}
|
|
}
|
|
|
|
fn confirm_time_up_if_needed(
|
|
ctx: &ReducerContext,
|
|
row: &Match3DRuntimeRunRow,
|
|
snapshot: Match3DRunSnapshot,
|
|
server_now_ms: i64,
|
|
) -> Result<Match3DRunSnapshot, String> {
|
|
if snapshot.status != MATCH3D_RUN_RUNNING || compute_remaining_ms(&snapshot, server_now_ms) > 0
|
|
{
|
|
let mut next = snapshot;
|
|
next.server_now_ms = server_now_ms;
|
|
next.remaining_ms = compute_remaining_ms(&next, server_now_ms);
|
|
return Ok(next);
|
|
}
|
|
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
|
|
let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(server_now_ms));
|
|
let next = snapshot_from_domain(&domain_run, server_now_ms);
|
|
persist_snapshot(ctx, row, &next, server_now_ms);
|
|
Ok(next)
|
|
}
|
|
|
|
fn persist_snapshot(
|
|
ctx: &ReducerContext,
|
|
row: &Match3DRuntimeRunRow,
|
|
snapshot: &Match3DRunSnapshot,
|
|
server_now_ms: i64,
|
|
) {
|
|
let updated_at = Timestamp::from_micros_since_unix_epoch(server_now_ms.saturating_mul(1000));
|
|
let next = row_from_snapshot(&row.owner_user_id, snapshot, row.created_at, updated_at);
|
|
ctx.db.match3d_runtime_run().run_id().delete(&row.run_id);
|
|
ctx.db.match3d_runtime_run().insert(next);
|
|
}
|
|
|
|
fn row_from_snapshot(
|
|
owner_user_id: &str,
|
|
snapshot: &Match3DRunSnapshot,
|
|
created_at: Timestamp,
|
|
updated_at: Timestamp,
|
|
) -> Match3DRuntimeRunRow {
|
|
let finished_at_ms = if snapshot.status == MATCH3D_RUN_RUNNING {
|
|
0
|
|
} else {
|
|
snapshot.server_now_ms
|
|
};
|
|
let elapsed_ms = if finished_at_ms > 0 {
|
|
finished_at_ms.saturating_sub(snapshot.started_at_ms)
|
|
} else {
|
|
snapshot
|
|
.server_now_ms
|
|
.saturating_sub(snapshot.started_at_ms)
|
|
};
|
|
Match3DRuntimeRunRow {
|
|
run_id: snapshot.run_id.clone(),
|
|
owner_user_id: owner_user_id.to_string(),
|
|
profile_id: snapshot.profile_id.clone(),
|
|
status: snapshot.status.clone(),
|
|
snapshot_version: snapshot.snapshot_version,
|
|
started_at_ms: snapshot.started_at_ms,
|
|
duration_limit_ms: snapshot.duration_limit_ms,
|
|
finished_at_ms,
|
|
elapsed_ms,
|
|
clear_count: snapshot.clear_count,
|
|
total_item_count: snapshot.total_item_count,
|
|
cleared_item_count: snapshot.cleared_item_count,
|
|
failure_reason: snapshot.failure_reason.clone().unwrap_or_default(),
|
|
snapshot_json: to_json_string(snapshot),
|
|
created_at,
|
|
updated_at,
|
|
}
|
|
}
|
|
|
|
fn click_result(
|
|
status: &str,
|
|
snapshot: Match3DRunSnapshot,
|
|
accepted_item_instance_id: Option<String>,
|
|
cleared_item_instance_ids: Vec<String>,
|
|
) -> Match3DClickItemProcedureResult {
|
|
Match3DClickItemProcedureResult {
|
|
ok: true,
|
|
status: status.to_string(),
|
|
run_json: Some(to_json_string(&snapshot)),
|
|
accepted_item_instance_id,
|
|
cleared_item_instance_ids,
|
|
failure_reason: snapshot.failure_reason,
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn upsert_work(ctx: &ReducerContext, work: Match3DWorkProfileRow) {
|
|
if ctx
|
|
.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.find(&work.profile_id)
|
|
.is_some()
|
|
{
|
|
ctx.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.delete(&work.profile_id);
|
|
}
|
|
ctx.db.match3d_work_profile().insert(work);
|
|
}
|
|
|
|
fn replace_session(
|
|
ctx: &ReducerContext,
|
|
current: &Match3DAgentSessionRow,
|
|
next: Match3DAgentSessionRow,
|
|
) {
|
|
ctx.db
|
|
.match3d_agent_session()
|
|
.session_id()
|
|
.delete(¤t.session_id);
|
|
ctx.db.match3d_agent_session().insert(next);
|
|
}
|
|
|
|
fn replace_work(
|
|
ctx: &ReducerContext,
|
|
current: &Match3DWorkProfileRow,
|
|
next: Match3DWorkProfileRow,
|
|
) {
|
|
ctx.db
|
|
.match3d_work_profile()
|
|
.profile_id()
|
|
.delete(¤t.profile_id);
|
|
ctx.db.match3d_work_profile().insert(next);
|
|
}
|
|
|
|
fn clone_session(row: &Match3DAgentSessionRow) -> Match3DAgentSessionRow {
|
|
Match3DAgentSessionRow {
|
|
session_id: row.session_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
seed_text: row.seed_text.clone(),
|
|
current_turn: row.current_turn,
|
|
progress_percent: row.progress_percent,
|
|
stage: row.stage.clone(),
|
|
config_json: row.config_json.clone(),
|
|
draft_json: row.draft_json.clone(),
|
|
last_assistant_reply: row.last_assistant_reply.clone(),
|
|
published_profile_id: row.published_profile_id.clone(),
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
}
|
|
}
|
|
|
|
fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow {
|
|
Match3DWorkProfileRow {
|
|
profile_id: row.profile_id.clone(),
|
|
owner_user_id: row.owner_user_id.clone(),
|
|
source_session_id: row.source_session_id.clone(),
|
|
author_display_name: row.author_display_name.clone(),
|
|
game_name: row.game_name.clone(),
|
|
theme_text: row.theme_text.clone(),
|
|
summary_text: row.summary_text.clone(),
|
|
tags_json: row.tags_json.clone(),
|
|
cover_image_src: row.cover_image_src.clone(),
|
|
cover_asset_id: row.cover_asset_id.clone(),
|
|
clear_count: row.clear_count,
|
|
difficulty: row.difficulty,
|
|
config_json: row.config_json.clone(),
|
|
publication_status: row.publication_status.clone(),
|
|
play_count: row.play_count,
|
|
updated_at: row.updated_at,
|
|
published_at: row.published_at,
|
|
generated_item_assets_json: row.generated_item_assets_json.clone(),
|
|
}
|
|
}
|
|
|
|
fn validate_config(config: &Match3DCreatorConfigSnapshot) -> Result<(), String> {
|
|
domain_config_from_snapshot(config)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String> {
|
|
if row.game_name.trim().is_empty() {
|
|
return Err("match3d 发布需要填写游戏名称".to_string());
|
|
}
|
|
if row.cover_image_src.trim().is_empty() {
|
|
return Err("match3d 发布需要封面图".to_string());
|
|
}
|
|
if parse_tags(&row.tags_json)?.is_empty() {
|
|
return Err("match3d 发布需要至少 1 个标签".to_string());
|
|
}
|
|
let config = parse_config(&row.config_json)?;
|
|
let required_item_types =
|
|
module_match3d::resolve_match3d_item_type_count_for_difficulty(
|
|
config.clear_count,
|
|
config.difficulty,
|
|
) as usize;
|
|
let ready_item_types = count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?;
|
|
if ready_item_types < required_item_types {
|
|
return Err(format!(
|
|
"match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types} 种"
|
|
));
|
|
}
|
|
validate_config(&config)
|
|
}
|
|
|
|
fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool {
|
|
validate_publishable_work(row).is_ok()
|
|
}
|
|
|
|
fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
|
|
Match3DCreatorConfigSnapshot {
|
|
theme_text: clean_string(seed_text, "经典消除"),
|
|
reference_image_src: None,
|
|
clear_count: 12,
|
|
difficulty: 3,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: false,
|
|
}
|
|
}
|
|
|
|
fn parse_config_or_default(value: &str) -> Match3DCreatorConfigSnapshot {
|
|
parse_config(value).unwrap_or_else(|_| default_config_from_seed("经典消除"))
|
|
}
|
|
|
|
fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
|
|
parse_json(value, "match3d config_json").map(|mut config: Match3DCreatorConfigSnapshot| {
|
|
config.theme_text = clean_string(&config.theme_text, "经典消除");
|
|
config.difficulty = config
|
|
.difficulty
|
|
.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
|
|
config.asset_style_id = normalize_optional_text(config.asset_style_id);
|
|
config.asset_style_label = normalize_optional_text(config.asset_style_label);
|
|
config.asset_style_prompt = normalize_optional_text(config.asset_style_prompt);
|
|
config
|
|
})
|
|
}
|
|
|
|
fn normalize_match3d_generated_item_config(
|
|
config: Match3DCreatorConfigSnapshot,
|
|
) -> Match3DCreatorConfigSnapshot {
|
|
config
|
|
}
|
|
|
|
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
|
value
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
|
|
let parsed = parse_json::<Vec<String>>(value, "match3d tags_json")?;
|
|
Ok(normalize_tags(parsed))
|
|
}
|
|
|
|
fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<String>, String> {
|
|
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
let parsed = parse_json::<serde_json::Value>(trimmed, "match3d generated_item_assets_json")?;
|
|
if !parsed.is_array() {
|
|
return Err("match3d generated_item_assets_json 必须是数组".to_string());
|
|
}
|
|
Ok(Some(to_json_string(&parsed)))
|
|
}
|
|
|
|
fn count_ready_generated_item_types(value: Option<&str>) -> Result<usize, String> {
|
|
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(0);
|
|
};
|
|
let parsed = parse_json::<Vec<Value>>(trimmed, "match3d generated_item_assets_json")?;
|
|
Ok(parsed
|
|
.iter()
|
|
.filter(|asset| {
|
|
let status_ready = asset
|
|
.get("status")
|
|
.and_then(Value::as_str)
|
|
.map(|status| status == "image_ready")
|
|
.unwrap_or(false);
|
|
let view_count = asset
|
|
.get("imageViews")
|
|
.or_else(|| asset.get("image_views"))
|
|
.and_then(Value::as_array)
|
|
.map(|views| {
|
|
views
|
|
.iter()
|
|
.filter(|view| {
|
|
view.get("imageSrc")
|
|
.or_else(|| view.get("image_src"))
|
|
.and_then(Value::as_str)
|
|
.map(|value| !value.trim().is_empty())
|
|
.unwrap_or(false)
|
|
|| view
|
|
.get("imageObjectKey")
|
|
.or_else(|| view.get("image_object_key"))
|
|
.and_then(Value::as_str)
|
|
.map(|value| !value.trim().is_empty())
|
|
.unwrap_or(false)
|
|
})
|
|
.count()
|
|
})
|
|
.unwrap_or(0);
|
|
status_ready && view_count >= 5
|
|
})
|
|
.count())
|
|
}
|
|
|
|
fn resolve_generated_item_assets_json_for_compile(
|
|
input: Option<&str>,
|
|
existing_work: Option<&Match3DWorkProfileRow>,
|
|
) -> Result<Option<String>, String> {
|
|
if input.is_some() {
|
|
return normalize_generated_item_assets_json(input);
|
|
}
|
|
Ok(existing_work.and_then(|work| work.generated_item_assets_json.clone()))
|
|
}
|
|
|
|
fn resolve_compile_tags(
|
|
input_tags_json: Option<&str>,
|
|
existing_work: Option<&Match3DWorkProfileRow>,
|
|
theme_text: &str,
|
|
) -> Result<Vec<String>, String> {
|
|
input_tags_json
|
|
.or_else(|| existing_work.map(|work| work.tags_json.as_str()))
|
|
.map(parse_tags)
|
|
.transpose()
|
|
.map(|tags| {
|
|
tags.filter(|items| !items.is_empty())
|
|
.unwrap_or_else(|| default_tags(theme_text))
|
|
})
|
|
}
|
|
|
|
fn resolve_compile_game_name(
|
|
input_game_name: &Option<String>,
|
|
existing_work: Option<&Match3DWorkProfileRow>,
|
|
theme_text: &str,
|
|
) -> String {
|
|
clean_optional(input_game_name)
|
|
.or_else(|| {
|
|
existing_work
|
|
.map(|work| clean_string(&work.game_name, ""))
|
|
.filter(|value| !value.is_empty())
|
|
})
|
|
.unwrap_or_else(|| format!("{theme_text}抓大鹅"))
|
|
}
|
|
|
|
fn resolve_compile_summary_text(
|
|
input_summary_text: &Option<String>,
|
|
existing_work: Option<&Match3DWorkProfileRow>,
|
|
) -> String {
|
|
input_summary_text
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.map(str::to_string)
|
|
.or_else(|| existing_work.map(|work| work.summary_text.clone()))
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
}
|
|
|
|
fn resolve_compile_optional_text(input: &Option<String>, existing: Option<&str>) -> String {
|
|
clean_optional(input)
|
|
.or_else(|| {
|
|
existing
|
|
.map(|value| clean_string(value, ""))
|
|
.filter(|value| !value.is_empty())
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn default_tags(theme_text: &str) -> Vec<String> {
|
|
normalize_tags(vec![
|
|
theme_text.to_string(),
|
|
"抓大鹅".to_string(),
|
|
"消除".to_string(),
|
|
])
|
|
}
|
|
|
|
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
|
let mut result = Vec::new();
|
|
for tag in tags {
|
|
let trimmed = tag.trim();
|
|
if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) {
|
|
result.push(trimmed.to_string());
|
|
}
|
|
if result.len() >= 6 {
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn normalize_stage(value: &str) -> String {
|
|
match value.trim() {
|
|
MATCH3D_STAGE_READY_TO_COMPILE => MATCH3D_STAGE_READY_TO_COMPILE.to_string(),
|
|
MATCH3D_STAGE_DRAFT_COMPILED => MATCH3D_STAGE_DRAFT_COMPILED.to_string(),
|
|
MATCH3D_STAGE_PUBLISHED => MATCH3D_STAGE_PUBLISHED.to_string(),
|
|
_ => MATCH3D_STAGE_COLLECTING.to_string(),
|
|
}
|
|
}
|
|
|
|
fn domain_config_from_snapshot(
|
|
config: &Match3DCreatorConfigSnapshot,
|
|
) -> Result<DomainMatch3DCreatorConfig, module_match3d::Match3DFieldError> {
|
|
module_match3d::build_creator_config(
|
|
&config.theme_text,
|
|
config.reference_image_src.clone(),
|
|
config.clear_count,
|
|
config.difficulty,
|
|
)
|
|
}
|
|
|
|
fn snapshot_from_domain(run: &DomainMatch3DRunSnapshot, server_now_ms: i64) -> Match3DRunSnapshot {
|
|
Match3DRunSnapshot {
|
|
run_id: run.run_id.clone(),
|
|
profile_id: run.profile_id.clone(),
|
|
status: domain_status_to_text(run.status).to_string(),
|
|
snapshot_version: run.board_version.min(u32::MAX as u64) as u32,
|
|
started_at_ms: run.started_at_ms.min(i64::MAX as u64) as i64,
|
|
duration_limit_ms: run.duration_limit_ms.min(i64::MAX as u64) as i64,
|
|
server_now_ms,
|
|
remaining_ms: run.remaining_ms.min(i64::MAX as u64) as i64,
|
|
clear_count: run.clear_count,
|
|
total_item_count: run.total_item_count,
|
|
cleared_item_count: run.cleared_item_count,
|
|
tray_slots: run
|
|
.tray_slots
|
|
.iter()
|
|
.map(snapshot_tray_slot_from_domain)
|
|
.collect(),
|
|
items: run.items.iter().map(snapshot_item_from_domain).collect(),
|
|
failure_reason: run
|
|
.failure_reason
|
|
.map(domain_failure_to_text)
|
|
.map(str::to_string),
|
|
}
|
|
}
|
|
|
|
fn domain_snapshot_from_snapshot(
|
|
snapshot: &Match3DRunSnapshot,
|
|
owner_user_id: &str,
|
|
) -> DomainMatch3DRunSnapshot {
|
|
DomainMatch3DRunSnapshot {
|
|
run_id: snapshot.run_id.clone(),
|
|
profile_id: snapshot.profile_id.clone(),
|
|
owner_user_id: owner_user_id.to_string(),
|
|
status: domain_status_from_text(&snapshot.status),
|
|
started_at_ms: to_u64_ms(snapshot.started_at_ms),
|
|
duration_limit_ms: to_u64_ms(snapshot.duration_limit_ms),
|
|
remaining_ms: to_u64_ms(snapshot.remaining_ms),
|
|
clear_count: snapshot.clear_count,
|
|
total_item_count: snapshot.total_item_count,
|
|
cleared_item_count: snapshot.cleared_item_count,
|
|
board_version: snapshot.snapshot_version as u64,
|
|
items: snapshot
|
|
.items
|
|
.iter()
|
|
.map(domain_item_from_snapshot)
|
|
.collect(),
|
|
tray_slots: snapshot
|
|
.tray_slots
|
|
.iter()
|
|
.map(domain_tray_slot_from_snapshot)
|
|
.collect(),
|
|
failure_reason: snapshot
|
|
.failure_reason
|
|
.as_deref()
|
|
.map(domain_failure_from_text),
|
|
last_confirmed_action_id: None,
|
|
}
|
|
}
|
|
|
|
fn snapshot_item_from_domain(item: &DomainMatch3DItemSnapshot) -> Match3DItemSnapshot {
|
|
Match3DItemSnapshot {
|
|
item_instance_id: item.item_instance_id.clone(),
|
|
item_type_id: item.item_type_id.clone(),
|
|
visual_key: item.visual_key.clone(),
|
|
x: item.x,
|
|
y: item.y,
|
|
radius: item.radius,
|
|
layer: item.layer,
|
|
state: domain_item_state_to_text(item.state).to_string(),
|
|
clickable: item.clickable,
|
|
}
|
|
}
|
|
|
|
fn domain_item_from_snapshot(item: &Match3DItemSnapshot) -> DomainMatch3DItemSnapshot {
|
|
DomainMatch3DItemSnapshot {
|
|
item_instance_id: item.item_instance_id.clone(),
|
|
item_type_id: item.item_type_id.clone(),
|
|
visual_key: item.visual_key.clone(),
|
|
x: item.x,
|
|
y: item.y,
|
|
radius: item.radius,
|
|
layer: item.layer,
|
|
state: domain_item_state_from_text(&item.state),
|
|
clickable: item.clickable,
|
|
tray_slot_index: None,
|
|
}
|
|
}
|
|
|
|
fn snapshot_tray_slot_from_domain(slot: &DomainMatch3DTraySlot) -> Match3DTraySlotSnapshot {
|
|
Match3DTraySlotSnapshot {
|
|
slot_index: slot.slot_index,
|
|
item_instance_id: slot.item_instance_id.clone(),
|
|
item_type_id: slot.item_type_id.clone(),
|
|
visual_key: slot.visual_key.clone(),
|
|
}
|
|
}
|
|
|
|
fn domain_tray_slot_from_snapshot(slot: &Match3DTraySlotSnapshot) -> DomainMatch3DTraySlot {
|
|
DomainMatch3DTraySlot {
|
|
slot_index: slot.slot_index,
|
|
item_instance_id: slot.item_instance_id.clone(),
|
|
item_type_id: slot.item_type_id.clone(),
|
|
visual_key: slot.visual_key.clone(),
|
|
}
|
|
}
|
|
|
|
fn domain_status_to_text(status: DomainMatch3DRunStatus) -> &'static str {
|
|
match status {
|
|
DomainMatch3DRunStatus::Running => MATCH3D_RUN_RUNNING,
|
|
DomainMatch3DRunStatus::Won => MATCH3D_RUN_WON,
|
|
DomainMatch3DRunStatus::Failed => MATCH3D_RUN_FAILED,
|
|
DomainMatch3DRunStatus::Stopped => MATCH3D_RUN_STOPPED,
|
|
}
|
|
}
|
|
|
|
fn domain_status_from_text(value: &str) -> DomainMatch3DRunStatus {
|
|
match value {
|
|
MATCH3D_RUN_WON | "won" => DomainMatch3DRunStatus::Won,
|
|
MATCH3D_RUN_FAILED | "failed" => DomainMatch3DRunStatus::Failed,
|
|
MATCH3D_RUN_STOPPED | "stopped" => DomainMatch3DRunStatus::Stopped,
|
|
_ => DomainMatch3DRunStatus::Running,
|
|
}
|
|
}
|
|
|
|
fn domain_failure_to_text(reason: DomainMatch3DFailureReason) -> &'static str {
|
|
match reason {
|
|
DomainMatch3DFailureReason::TimeUp => MATCH3D_FAILURE_TIME_UP,
|
|
DomainMatch3DFailureReason::TrayFull => MATCH3D_FAILURE_TRAY_FULL,
|
|
}
|
|
}
|
|
|
|
fn domain_failure_from_text(value: &str) -> DomainMatch3DFailureReason {
|
|
match value {
|
|
MATCH3D_FAILURE_TRAY_FULL | "tray_full" => DomainMatch3DFailureReason::TrayFull,
|
|
_ => DomainMatch3DFailureReason::TimeUp,
|
|
}
|
|
}
|
|
|
|
fn domain_item_state_to_text(state: DomainMatch3DItemState) -> &'static str {
|
|
match state {
|
|
DomainMatch3DItemState::InBoard => MATCH3D_ITEM_IN_BOARD,
|
|
DomainMatch3DItemState::InTray => MATCH3D_ITEM_IN_TRAY,
|
|
DomainMatch3DItemState::Cleared => MATCH3D_ITEM_CLEARED,
|
|
}
|
|
}
|
|
|
|
fn domain_item_state_from_text(value: &str) -> DomainMatch3DItemState {
|
|
match value {
|
|
MATCH3D_ITEM_IN_TRAY | "in_tray" => DomainMatch3DItemState::InTray,
|
|
MATCH3D_ITEM_CLEARED | "cleared" => DomainMatch3DItemState::Cleared,
|
|
_ => DomainMatch3DItemState::InBoard,
|
|
}
|
|
}
|
|
|
|
fn deterministic_run_seed(
|
|
run_id: &str,
|
|
profile_id: &str,
|
|
clear_count: u32,
|
|
difficulty: u32,
|
|
) -> u64 {
|
|
let mut seed = 0xcbf2_9ce4_8422_2325_u64;
|
|
for byte in run_id.bytes().chain(profile_id.bytes()) {
|
|
seed ^= byte as u64;
|
|
seed = seed.wrapping_mul(0x0000_0100_0000_01b3);
|
|
}
|
|
seed ^ ((clear_count as u64) << 32) ^ difficulty as u64
|
|
}
|
|
|
|
fn to_u64_ms(value: i64) -> u64 {
|
|
value.max(0) as u64
|
|
}
|
|
|
|
fn compute_remaining_ms(snapshot: &Match3DRunSnapshot, server_now_ms: i64) -> i64 {
|
|
snapshot
|
|
.duration_limit_ms
|
|
.saturating_sub(server_now_ms.saturating_sub(snapshot.started_at_ms))
|
|
.max(0)
|
|
}
|
|
|
|
fn current_server_ms(ctx: &ReducerContext) -> i64 {
|
|
ctx.timestamp
|
|
.to_micros_since_unix_epoch()
|
|
.saturating_div(1000)
|
|
}
|
|
|
|
fn clean_optional(value: &Option<String>) -> Option<String> {
|
|
value
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn clean_string(value: &str, fallback: &str) -> String {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
fallback.to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
fn empty_to_none(value: &str) -> Option<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|
|
|
|
fn require_non_empty(value: &str, label: &str) -> Result<(), String> {
|
|
if value.trim().is_empty() {
|
|
Err(format!("{label} 不能为空"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn parse_json<T: DeserializeOwned>(value: &str, label: &str) -> Result<T, String> {
|
|
serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}"))
|
|
}
|
|
|
|
fn deserialize_snapshot(value: &str) -> Result<Match3DRunSnapshot, String> {
|
|
parse_json(value, "match3d snapshot_json")
|
|
}
|
|
|
|
fn to_json_string<T: Serialize>(value: &T) -> String {
|
|
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
|
|
}
|
|
|
|
fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult {
|
|
Match3DAgentSessionProcedureResult {
|
|
ok: true,
|
|
session_json: Some(to_json_string(&session)),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn session_error(message: String) -> Match3DAgentSessionProcedureResult {
|
|
Match3DAgentSessionProcedureResult {
|
|
ok: false,
|
|
session_json: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult {
|
|
Match3DWorkProcedureResult {
|
|
ok: true,
|
|
work_json: Some(to_json_string(&work)),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn work_error(message: String) -> Match3DWorkProcedureResult {
|
|
Match3DWorkProcedureResult {
|
|
ok: false,
|
|
work_json: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult {
|
|
Match3DRunProcedureResult {
|
|
ok: true,
|
|
run_json: Some(to_json_string(&run)),
|
|
error_message: None,
|
|
}
|
|
}
|
|
|
|
fn run_error(message: String) -> Match3DRunProcedureResult {
|
|
Match3DRunProcedureResult {
|
|
ok: false,
|
|
run_json: None,
|
|
error_message: Some(message),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn match3d_total_items_follow_clear_count() {
|
|
let work = Match3DWorkProfileRow {
|
|
profile_id: "profile-1".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_session_id: "session-1".to_string(),
|
|
author_display_name: "作者".to_string(),
|
|
game_name: "水果抓大鹅".to_string(),
|
|
theme_text: "水果".to_string(),
|
|
summary_text: "水果主题".to_string(),
|
|
tags_json: "[\"水果\"]".to_string(),
|
|
cover_image_src: "/cover.png".to_string(),
|
|
cover_asset_id: String::new(),
|
|
clear_count: 4,
|
|
difficulty: 3,
|
|
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
|
theme_text: "水果".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 4,
|
|
difficulty: 3,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: false,
|
|
}),
|
|
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 0,
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
|
published_at: None,
|
|
generated_item_assets_json: None,
|
|
};
|
|
let snapshot = build_initial_run_snapshot("run-1", &work, 10, None);
|
|
assert_eq!(snapshot.total_item_count, 12);
|
|
assert_eq!(snapshot.items.len(), 12);
|
|
}
|
|
|
|
#[test]
|
|
fn match3d_work_snapshot_keeps_generated_item_assets_json() {
|
|
let work = Match3DWorkProfileRow {
|
|
profile_id: "profile-1".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_session_id: "session-1".to_string(),
|
|
author_display_name: "作者".to_string(),
|
|
game_name: "水果抓大鹅".to_string(),
|
|
theme_text: "水果".to_string(),
|
|
summary_text: "水果主题".to_string(),
|
|
tags_json: "[\"水果\"]".to_string(),
|
|
cover_image_src: "/cover.png".to_string(),
|
|
cover_asset_id: String::new(),
|
|
clear_count: 3,
|
|
difficulty: 3,
|
|
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
|
theme_text: "水果".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 3,
|
|
difficulty: 3,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: false,
|
|
}),
|
|
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 0,
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
|
published_at: None,
|
|
generated_item_assets_json: Some(
|
|
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
|
|
.to_string(),
|
|
),
|
|
};
|
|
|
|
let snapshot = build_work_snapshot(&work).expect("work snapshot should build");
|
|
|
|
assert_eq!(
|
|
snapshot.generated_item_assets_json.as_deref(),
|
|
Some(
|
|
r#"[{"imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match3d_compile_without_asset_payload_preserves_existing_generated_assets() {
|
|
let existing = Match3DWorkProfileRow {
|
|
profile_id: "profile-1".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_session_id: "session-1".to_string(),
|
|
author_display_name: "作者".to_string(),
|
|
game_name: "水果抓大鹅".to_string(),
|
|
theme_text: "水果".to_string(),
|
|
summary_text: String::new(),
|
|
tags_json: "[\"水果\"]".to_string(),
|
|
cover_image_src: String::new(),
|
|
cover_asset_id: String::new(),
|
|
clear_count: 3,
|
|
difficulty: 3,
|
|
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
|
theme_text: "水果".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 3,
|
|
difficulty: 3,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: false,
|
|
}),
|
|
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 2,
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
|
published_at: None,
|
|
generated_item_assets_json: Some(
|
|
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
|
|
.to_string(),
|
|
),
|
|
};
|
|
|
|
let preserved =
|
|
resolve_generated_item_assets_json_for_compile(None, Some(&existing)).unwrap();
|
|
|
|
assert_eq!(
|
|
preserved.as_deref(),
|
|
existing.generated_item_assets_json.as_deref()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn match3d_publish_ready_requires_five_image_views_per_item() {
|
|
let base_work = Match3DWorkProfileRow {
|
|
profile_id: "profile-1".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_session_id: "session-1".to_string(),
|
|
author_display_name: "作者".to_string(),
|
|
game_name: "水果抓大鹅".to_string(),
|
|
theme_text: "水果".to_string(),
|
|
summary_text: "水果主题".to_string(),
|
|
tags_json: "[\"水果\"]".to_string(),
|
|
cover_image_src: "/cover.png".to_string(),
|
|
cover_asset_id: String::new(),
|
|
clear_count: 8,
|
|
difficulty: 2,
|
|
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
|
theme_text: "水果".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 8,
|
|
difficulty: 2,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: false,
|
|
}),
|
|
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 0,
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
|
published_at: None,
|
|
generated_item_assets_json: Some(
|
|
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"#
|
|
.to_string(),
|
|
),
|
|
};
|
|
|
|
let error = validate_publishable_work(&base_work).unwrap_err();
|
|
assert!(error.contains("当前已有 0 种"));
|
|
|
|
let ready_assets = (1..=3)
|
|
.map(|index| {
|
|
let views = (1..=5)
|
|
.map(|view_index| {
|
|
format!(
|
|
r#"{{"imageSrc":"/generated-match3d-assets/session/profile/items/i{index}/views/view-{view_index:02}.png"}}"#
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
format!(
|
|
r#"{{"itemId":"match3d-item-{index}","itemName":"物品{index}","imageViews":[{views}],"status":"image_ready"}}"#
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let ready_work = Match3DWorkProfileRow {
|
|
generated_item_assets_json: Some(format!("[{ready_assets}]")),
|
|
..base_work
|
|
};
|
|
|
|
assert!(validate_publishable_work(&ready_work).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn match3d_compile_without_metadata_payload_preserves_existing_metadata() {
|
|
let existing = Match3DWorkProfileRow {
|
|
profile_id: "profile-1".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
source_session_id: "session-1".to_string(),
|
|
author_display_name: "作者".to_string(),
|
|
game_name: "果园大鹅宴".to_string(),
|
|
theme_text: "水果".to_string(),
|
|
summary_text: "保留描述".to_string(),
|
|
tags_json: "[\"水果\",\"轻量休闲\"]".to_string(),
|
|
cover_image_src: "/cover.png".to_string(),
|
|
cover_asset_id: "cover-asset-1".to_string(),
|
|
clear_count: 3,
|
|
difficulty: 3,
|
|
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
|
theme_text: "水果".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 3,
|
|
difficulty: 3,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: false,
|
|
}),
|
|
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
|
play_count: 2,
|
|
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
|
published_at: None,
|
|
generated_item_assets_json: None,
|
|
};
|
|
|
|
let input_game_name = None;
|
|
let input_summary_text = None;
|
|
let input_tags_json = None;
|
|
let input_cover_image_src = None;
|
|
let input_cover_asset_id = None;
|
|
let tags = resolve_compile_tags(input_tags_json, Some(&existing), "水果").unwrap();
|
|
let game_name = resolve_compile_game_name(&input_game_name, Some(&existing), "水果");
|
|
let summary_text = resolve_compile_summary_text(&input_summary_text, Some(&existing));
|
|
let cover_image_src =
|
|
resolve_compile_optional_text(&input_cover_image_src, Some(&existing.cover_image_src));
|
|
let cover_asset_id =
|
|
resolve_compile_optional_text(&input_cover_asset_id, Some(&existing.cover_asset_id));
|
|
|
|
assert_eq!(game_name, "果园大鹅宴");
|
|
assert_eq!(summary_text, "保留描述");
|
|
assert_eq!(tags, vec!["水果".to_string(), "轻量休闲".to_string()]);
|
|
assert_eq!(cover_image_src, "/cover.png");
|
|
assert_eq!(cover_asset_id, "cover-asset-1");
|
|
}
|
|
|
|
#[test]
|
|
fn match3d_compile_keeps_difficulty_clear_count() {
|
|
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
|
|
theme_text: "水果".to_string(),
|
|
reference_image_src: None,
|
|
clear_count: 20,
|
|
difficulty: 8,
|
|
asset_style_id: None,
|
|
asset_style_label: None,
|
|
asset_style_prompt: None,
|
|
generate_click_sound: true,
|
|
});
|
|
|
|
assert_eq!(config.clear_count, 20);
|
|
assert_eq!(config.difficulty, 8);
|
|
assert!(config.generate_click_sound);
|
|
}
|
|
|
|
#[test]
|
|
fn match3d_domain_click_bridge_clears_three_items() {
|
|
let snapshot = Match3DRunSnapshot {
|
|
run_id: "run-1".to_string(),
|
|
profile_id: "profile-1".to_string(),
|
|
status: MATCH3D_RUN_RUNNING.to_string(),
|
|
snapshot_version: 1,
|
|
started_at_ms: 0,
|
|
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
|
server_now_ms: 0,
|
|
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
|
clear_count: 1,
|
|
total_item_count: 3,
|
|
cleared_item_count: 0,
|
|
tray_slots: (0..MATCH3D_TRAY_SLOT_COUNT)
|
|
.map(|slot_index| Match3DTraySlotSnapshot {
|
|
slot_index,
|
|
item_instance_id: (slot_index < 2).then(|| format!("item-{slot_index}")),
|
|
item_type_id: (slot_index < 3).then(|| "type-1".to_string()),
|
|
visual_key: (slot_index < 3).then(|| "visual-1".to_string()),
|
|
})
|
|
.collect(),
|
|
items: (0..3)
|
|
.map(|index| Match3DItemSnapshot {
|
|
item_instance_id: format!("item-{index}"),
|
|
item_type_id: "type-1".to_string(),
|
|
visual_key: "visual-1".to_string(),
|
|
x: 0.0,
|
|
y: 0.0,
|
|
radius: 0.1,
|
|
layer: index,
|
|
state: if index < 2 {
|
|
MATCH3D_ITEM_IN_TRAY.to_string()
|
|
} else {
|
|
MATCH3D_ITEM_IN_BOARD.to_string()
|
|
},
|
|
clickable: index == 2,
|
|
})
|
|
.collect(),
|
|
failure_reason: None,
|
|
};
|
|
|
|
let domain_run = domain_snapshot_from_snapshot(&snapshot, "user-1");
|
|
let confirmation = confirm_domain_click_at(
|
|
&domain_run,
|
|
&DomainMatch3DClickInput {
|
|
run_id: "run-1".to_string(),
|
|
owner_user_id: "user-1".to_string(),
|
|
item_instance_id: "item-2".to_string(),
|
|
client_action_id: "action-1".to_string(),
|
|
snapshot_version: 1,
|
|
clicked_at_ms: 10,
|
|
},
|
|
)
|
|
.expect("domain click should be confirmed");
|
|
let next = snapshot_from_domain(&confirmation.run, 10);
|
|
|
|
assert!(confirmation.accepted);
|
|
assert_eq!(confirmation.cleared_item_instance_ids.len(), 3);
|
|
assert!(
|
|
next.tray_slots
|
|
.iter()
|
|
.all(|slot| slot.item_instance_id.is_none())
|
|
);
|
|
assert!(
|
|
next.items
|
|
.iter()
|
|
.all(|item| item.state == MATCH3D_ITEM_CLEARED)
|
|
);
|
|
}
|
|
}
|