Files
Genarrative/server-rs/crates/spacetime-module/src/match3d/mod.rs
2026-05-14 14:21:17 +08:00

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(&current.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, &current, 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(&current)?;
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(&current)
};
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, &current, 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(&current.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(&current.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)
);
}
}