Files
Genarrative/server-rs/crates/spacetime-module/src/square_hole.rs

1638 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
pub(crate) mod tables;
mod types;
pub use tables::*;
pub use types::*;
use crate::*;
use module_square_hole::{
SquareHoleCreatorConfig as DomainSquareHoleCreatorConfig,
SquareHoleDropFeedback as DomainSquareHoleDropFeedback,
SquareHoleDropInput as DomainSquareHoleDropInput,
SquareHoleDropRejectReason as DomainSquareHoleDropRejectReason,
SquareHoleHoleOption as DomainSquareHoleHoleOption,
SquareHoleHoleSnapshot as DomainSquareHoleHoleSnapshot,
SquareHoleRunSnapshot as DomainSquareHoleRunSnapshot,
SquareHoleRunStatus as DomainSquareHoleRunStatus,
SquareHoleShapeOption as DomainSquareHoleShapeOption,
SquareHoleShapeSnapshot as DomainSquareHoleShapeSnapshot,
build_creator_config as build_domain_creator_config,
compile_result_draft as compile_domain_result_draft, confirm_drop_at as confirm_domain_drop_at,
default_background_prompt as default_domain_background_prompt,
normalize_hole_options as normalize_domain_hole_options,
normalize_shape_options as normalize_domain_shape_options,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_at as start_domain_run_at,
stop_run_at as stop_domain_run_at,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
/// 方洞挑战公开广场列表投影。
///
/// HTTP gallery 通过 `spacetime-client` 订阅该 view 后读本地 cache
/// 不再在每个公开列表请求里调用 `list_square_hole_works` procedure。
#[spacetimedb::view(accessor = square_hole_gallery_view, public)]
pub fn square_hole_gallery_view(ctx: &AnonymousViewContext) -> Vec<SquareHoleGalleryViewRow> {
let mut items = ctx
.db
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"方洞挑战公开广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct SquareHoleGalleryViewRow {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary_text: String,
pub tags: Vec<String>,
pub cover_image_src: String,
pub background_prompt: String,
pub background_image_src: String,
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
pub hole_options: Vec<SquareHoleHoleOptionSnapshot>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[spacetimedb::procedure]
pub fn create_square_hole_agent_session(
ctx: &mut ProcedureContext,
input: SquareHoleAgentSessionCreateInput,
) -> SquareHoleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| create_square_hole_agent_session_tx(tx, input.clone())) {
Ok(session) => session_result(session),
Err(message) => session_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_square_hole_agent_session(
ctx: &mut ProcedureContext,
input: SquareHoleAgentSessionGetInput,
) -> SquareHoleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| get_square_hole_agent_session_tx(tx, input.clone())) {
Ok(session) => session_result(session),
Err(message) => session_error(message),
}
}
#[spacetimedb::procedure]
pub fn submit_square_hole_agent_message(
ctx: &mut ProcedureContext,
input: SquareHoleAgentMessageSubmitInput,
) -> SquareHoleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| submit_square_hole_agent_message_tx(tx, input.clone())) {
Ok(session) => session_result(session),
Err(message) => session_error(message),
}
}
#[spacetimedb::procedure]
pub fn finalize_square_hole_agent_message_turn(
ctx: &mut ProcedureContext,
input: SquareHoleAgentMessageFinalizeInput,
) -> SquareHoleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| finalize_square_hole_agent_message_turn_tx(tx, input.clone())) {
Ok(session) => session_result(session),
Err(message) => session_error(message),
}
}
#[spacetimedb::procedure]
pub fn compile_square_hole_draft(
ctx: &mut ProcedureContext,
input: SquareHoleDraftCompileInput,
) -> SquareHoleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| compile_square_hole_draft_tx(tx, input.clone())) {
Ok(session) => session_result(session),
Err(message) => session_error(message),
}
}
#[spacetimedb::procedure]
pub fn update_square_hole_work(
ctx: &mut ProcedureContext,
input: SquareHoleWorkUpdateInput,
) -> SquareHoleWorkProcedureResult {
match ctx.try_with_tx(|tx| update_square_hole_work_tx(tx, input.clone())) {
Ok(work) => work_result(work),
Err(message) => work_error(message),
}
}
#[spacetimedb::procedure]
pub fn publish_square_hole_work(
ctx: &mut ProcedureContext,
input: SquareHoleWorkPublishInput,
) -> SquareHoleWorkProcedureResult {
match ctx.try_with_tx(|tx| publish_square_hole_work_tx(tx, input.clone())) {
Ok(work) => work_result(work),
Err(message) => work_error(message),
}
}
#[spacetimedb::procedure]
pub fn list_square_hole_works(
ctx: &mut ProcedureContext,
input: SquareHoleWorksListInput,
) -> SquareHoleWorksProcedureResult {
match ctx.try_with_tx(|tx| list_square_hole_works_tx(tx, input.clone())) {
Ok(items) => SquareHoleWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => SquareHoleWorksProcedureResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_square_hole_work_detail(
ctx: &mut ProcedureContext,
input: SquareHoleWorkGetInput,
) -> SquareHoleWorkProcedureResult {
match ctx.try_with_tx(|tx| get_square_hole_work_detail_tx(tx, input.clone())) {
Ok(work) => work_result(work),
Err(message) => work_error(message),
}
}
#[spacetimedb::procedure]
pub fn delete_square_hole_work(
ctx: &mut ProcedureContext,
input: SquareHoleWorkDeleteInput,
) -> SquareHoleWorksProcedureResult {
match ctx.try_with_tx(|tx| delete_square_hole_work_tx(tx, input.clone())) {
Ok(items) => SquareHoleWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => SquareHoleWorksProcedureResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn start_square_hole_run(
ctx: &mut ProcedureContext,
input: SquareHoleRunStartInput,
) -> SquareHoleRunProcedureResult {
match ctx.try_with_tx(|tx| start_square_hole_run_tx(tx, input.clone())) {
Ok(run) => run_result(run),
Err(message) => run_error(message),
}
}
#[spacetimedb::procedure]
pub fn get_square_hole_run(
ctx: &mut ProcedureContext,
input: SquareHoleRunGetInput,
) -> SquareHoleRunProcedureResult {
match ctx.try_with_tx(|tx| get_square_hole_run_tx(tx, input.clone())) {
Ok(run) => run_result(run),
Err(message) => run_error(message),
}
}
#[spacetimedb::procedure]
pub fn drop_square_hole_shape(
ctx: &mut ProcedureContext,
input: SquareHoleRunDropInput,
) -> SquareHoleDropShapeProcedureResult {
match ctx.try_with_tx(|tx| drop_square_hole_shape_tx(tx, input.clone())) {
Ok(result) => result,
Err(message) => SquareHoleDropShapeProcedureResult {
ok: false,
status: SQUARE_HOLE_DROP_REJECTED.to_string(),
run: None,
feedback: None,
failure_reason: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn stop_square_hole_run(
ctx: &mut ProcedureContext,
input: SquareHoleRunStopInput,
) -> SquareHoleRunProcedureResult {
match ctx.try_with_tx(|tx| stop_square_hole_run_tx(tx, input.clone())) {
Ok(run) => run_result(run),
Err(message) => run_error(message),
}
}
#[spacetimedb::procedure]
pub fn restart_square_hole_run(
ctx: &mut ProcedureContext,
input: SquareHoleRunRestartInput,
) -> SquareHoleRunProcedureResult {
match ctx.try_with_tx(|tx| restart_square_hole_run_tx(tx, input.clone())) {
Ok(run) => run_result(run),
Err(message) => run_error(message),
}
}
#[spacetimedb::procedure]
pub fn finish_square_hole_time_up(
ctx: &mut ProcedureContext,
input: SquareHoleRunTimeUpInput,
) -> SquareHoleRunProcedureResult {
match ctx.try_with_tx(|tx| finish_square_hole_time_up_tx(tx, input.clone())) {
Ok(run) => run_result(run),
Err(message) => run_error(message),
}
}
fn create_square_hole_agent_session_tx(
ctx: &ReducerContext,
input: SquareHoleAgentSessionCreateInput,
) -> Result<SquareHoleAgentSessionSnapshot, String> {
require_non_empty(&input.session_id, "square_hole session_id")?;
require_non_empty(&input.owner_user_id, "square_hole owner_user_id")?;
require_non_empty(&input.welcome_message_id, "square_hole welcome_message_id")?;
if ctx
.db
.square_hole_agent_session()
.session_id()
.find(&input.session_id)
.is_some()
{
return Err("square_hole_agent_session.session_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
.square_hole_agent_session()
.insert(SquareHoleAgentSessionRow {
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: SQUARE_HOLE_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
.square_hole_agent_message()
.insert(SquareHoleAgentMessageRow {
message_id: input.welcome_message_id,
session_id: input.session_id.clone(),
role: SQUARE_HOLE_ROLE_ASSISTANT.to_string(),
kind: SQUARE_HOLE_KIND_TEXT.to_string(),
text: welcome.to_string(),
created_at,
});
get_square_hole_agent_session_tx(
ctx,
SquareHoleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn get_square_hole_agent_session_tx(
ctx: &ReducerContext,
input: SquareHoleAgentSessionGetInput,
) -> Result<SquareHoleAgentSessionSnapshot, String> {
let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
build_session_snapshot(ctx, &row)
}
fn submit_square_hole_agent_message_tx(
ctx: &ReducerContext,
input: SquareHoleAgentMessageSubmitInput,
) -> Result<SquareHoleAgentSessionSnapshot, String> {
require_non_empty(&input.user_message_id, "square_hole user_message_id")?;
require_non_empty(&input.user_message_text, "square_hole user_message_text")?;
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
if ctx
.db
.square_hole_agent_message()
.message_id()
.find(&input.user_message_id)
.is_some()
{
return Err("square_hole_agent_message.user_message_id 已存在".to_string());
}
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
ctx.db
.square_hole_agent_message()
.insert(SquareHoleAgentMessageRow {
message_id: input.user_message_id,
session_id: input.session_id.clone(),
role: SQUARE_HOLE_ROLE_USER.to_string(),
kind: SQUARE_HOLE_KIND_TEXT.to_string(),
text: input.user_message_text.trim().to_string(),
created_at: submitted_at,
});
replace_session(
ctx,
&session,
SquareHoleAgentSessionRow {
updated_at: submitted_at,
..clone_session(&session)
},
);
get_square_hole_agent_session_tx(
ctx,
SquareHoleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn finalize_square_hole_agent_message_turn_tx(
ctx: &ReducerContext,
input: SquareHoleAgentMessageFinalizeInput,
) -> Result<SquareHoleAgentSessionSnapshot, 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())
{
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())
{
ctx.db
.square_hole_agent_message()
.insert(SquareHoleAgentMessageRow {
message_id: message_id.to_string(),
session_id: input.session_id.clone(),
role: SQUARE_HOLE_ROLE_ASSISTANT.to_string(),
kind: SQUARE_HOLE_KIND_TEXT.to_string(),
text: assistant_text.clone(),
created_at: updated_at,
});
}
replace_session(
ctx,
&session,
SquareHoleAgentSessionRow {
current_turn: session.current_turn.saturating_add(1),
progress_percent: input.progress_percent.min(100),
stage: normalize_stage(&input.stage),
config_json: to_json_string(&next_config),
last_assistant_reply: assistant_text,
updated_at,
..clone_session(&session)
},
);
get_square_hole_agent_session_tx(
ctx,
SquareHoleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn compile_square_hole_draft_tx(
ctx: &ReducerContext,
input: SquareHoleDraftCompileInput,
) -> Result<SquareHoleAgentSessionSnapshot, String> {
require_non_empty(&input.profile_id, "square_hole profile_id")?;
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
let config = parse_config(&session.config_json)?;
validate_config(&config)?;
let domain_config = domain_config_from_snapshot(&config).map_err(|error| error.to_string())?;
let mut domain_draft = compile_domain_result_draft(input.profile_id.clone(), &domain_config);
if let Some(game_name) = clean_optional(&input.game_name) {
domain_draft.game_name = game_name;
}
if let Some(summary_text) = clean_optional(&input.summary_text) {
domain_draft.summary = summary_text;
}
if let Some(tags) = input.tags_json.as_deref().map(parse_tags).transpose()? {
if !tags.is_empty() {
domain_draft.tags = tags;
}
}
domain_draft.blockers = module_square_hole::validate_publish_requirements(&domain_draft);
domain_draft.publish_ready = domain_draft.blockers.is_empty();
let cover_image_src = clean_optional(&input.cover_image_src)
.or_else(|| clean_optional(&Some(config.cover_image_src.clone())))
.unwrap_or_default();
let draft = SquareHoleDraftSnapshot {
profile_id: input.profile_id.clone(),
game_name: domain_draft.game_name.clone(),
theme_text: domain_draft.theme_text.clone(),
twist_rule: domain_draft.twist_rule.clone(),
summary_text: domain_draft.summary.clone(),
tags: domain_draft.tags.clone(),
cover_image_src: cover_image_src.clone(),
background_prompt: domain_draft.background_prompt.clone(),
background_image_src: domain_draft
.background_image_src
.clone()
.unwrap_or_default(),
shape_options: shape_options_to_snapshot(&domain_draft.shape_options),
hole_options: hole_options_to_snapshot(&domain_draft.hole_options),
shape_count: domain_draft.shape_count,
difficulty: domain_draft.difficulty,
};
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let work = SquareHoleWorkProfileRow {
profile_id: input.profile_id.clone(),
work_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: draft.game_name.clone(),
theme_text: config.theme_text.clone(),
twist_rule: config.twist_rule.clone(),
summary_text: draft.summary_text.clone(),
tags_json: to_json_string(&draft.tags),
cover_image_src,
shape_count: config.shape_count,
difficulty: config.difficulty,
config_json: to_json_string(&config),
publication_status: SQUARE_HOLE_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: compiled_at,
published_at: None,
};
upsert_work(ctx, work);
replace_session(
ctx,
&session,
SquareHoleAgentSessionRow {
progress_percent: 80,
stage: SQUARE_HOLE_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_square_hole_agent_session_tx(
ctx,
SquareHoleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn update_square_hole_work_tx(
ctx: &ReducerContext,
input: SquareHoleWorkUpdateInput,
) -> Result<SquareHoleWorkSnapshot, String> {
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
let tags = parse_tags(&input.tags_json)?;
let current_config = parse_config(&current.config_json)?;
let shape_options = parse_optional_json::<Vec<SquareHoleShapeOptionSnapshot>>(
input.shape_options_json.as_str(),
"square_hole shape_options_json",
)?
.unwrap_or_else(|| current_config.shape_options.clone());
let hole_options = parse_optional_json::<Vec<SquareHoleHoleOptionSnapshot>>(
input.hole_options_json.as_str(),
"square_hole hole_options_json",
)?
.unwrap_or_else(|| current_config.hole_options.clone());
let config = normalize_config(SquareHoleCreatorConfigSnapshot {
theme_text: clean_string(&input.theme_text, "玩具"),
twist_rule: clean_string(&input.twist_rule, "方洞万能"),
shape_count: input.shape_count,
difficulty: input.difficulty,
shape_options,
hole_options,
background_prompt: clean_string(
&input.background_prompt,
&default_domain_background_prompt(&input.theme_text),
),
cover_image_src: input.cover_image_src.trim().to_string(),
background_image_src: input.background_image_src.trim().to_string(),
});
validate_config(&config)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let next = SquareHoleWorkProfileRow {
profile_id: current.profile_id.clone(),
work_id: current.work_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(),
twist_rule: config.twist_rule.clone(),
summary_text: clean_string(&input.summary_text, "反直觉形状分拣玩法"),
tags_json: to_json_string(&tags),
cover_image_src: input.cover_image_src.trim().to_string(),
shape_count: config.shape_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,
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
Ok(snapshot)
}
fn publish_square_hole_work_tx(
ctx: &ReducerContext,
input: SquareHoleWorkPublishInput,
) -> Result<SquareHoleWorkSnapshot, 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 = SquareHoleWorkProfileRow {
publication_status: SQUARE_HOLE_PUBLICATION_PUBLISHED.to_string(),
updated_at: published_at,
published_at: Some(published_at),
..clone_work(&current)
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
if !current.source_session_id.is_empty() {
if let Some(session) = ctx
.db
.square_hole_agent_session()
.session_id()
.find(&current.source_session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
{
replace_session(
ctx,
&session,
SquareHoleAgentSessionRow {
stage: SQUARE_HOLE_STAGE_PUBLISHED.to_string(),
progress_percent: 100,
updated_at: published_at,
..clone_session(&session)
},
);
}
}
Ok(snapshot)
}
fn list_square_hole_works_tx(
ctx: &ReducerContext,
input: SquareHoleWorksListInput,
) -> Result<Vec<SquareHoleWorkSnapshot>, String> {
let rows = if input.published_only {
ctx.db
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.collect::<Vec<_>>()
} else {
require_non_empty(&input.owner_user_id, "square_hole owner_user_id")?;
ctx.db
.square_hole_work_profile()
.by_square_hole_work_owner_user_id()
.filter(&input.owner_user_id)
.collect::<Vec<_>>()
};
let mut items = rows
.iter()
.map(build_work_snapshot)
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(items)
}
fn get_square_hole_work_detail_tx(
ctx: &ReducerContext,
input: SquareHoleWorkGetInput,
) -> Result<SquareHoleWorkSnapshot, String> {
let row = ctx
.db
.square_hole_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "方洞挑战作品不存在".to_string())?;
if row.owner_user_id != input.owner_user_id
&& row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED
{
return Err("无权访问该方洞挑战作品".to_string());
}
build_work_snapshot(&row)
}
fn delete_square_hole_work_tx(
ctx: &ReducerContext,
input: SquareHoleWorkDeleteInput,
) -> Result<Vec<SquareHoleWorkSnapshot>, String> {
let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
ctx.db
.square_hole_work_profile()
.profile_id()
.delete(&current.profile_id);
list_square_hole_works_tx(
ctx,
SquareHoleWorksListInput {
owner_user_id: input.owner_user_id,
published_only: false,
},
)
}
fn start_square_hole_run_tx(
ctx: &ReducerContext,
input: SquareHoleRunStartInput,
) -> Result<SquareHoleRunSnapshot, String> {
let work = find_playable_work(ctx, &input.profile_id, &input.owner_user_id)?;
let config = parse_config(&work.config_json)?;
let domain_config = domain_config_from_snapshot(&config).map_err(|error| error.to_string())?;
let started_at_ms = input.started_at_ms.max(0) as u64;
let domain_run = start_domain_run_at(
input.run_id.clone(),
input.owner_user_id.clone(),
input.profile_id.clone(),
&domain_config,
started_at_ms,
)
.map_err(|error| error.to_string())?;
let snapshot = snapshot_from_domain(&domain_run, input.started_at_ms.max(0));
let created_at = Timestamp::from_micros_since_unix_epoch(input.started_at_ms * 1000);
ctx.db
.square_hole_runtime_run()
.insert(run_row_from_snapshot(&snapshot, created_at, created_at));
replace_work(
ctx,
&work,
SquareHoleWorkProfileRow {
play_count: work.play_count.saturating_add(1),
updated_at: created_at,
..clone_work(&work)
},
);
Ok(snapshot)
}
fn get_square_hole_run_tx(
ctx: &ReducerContext,
input: SquareHoleRunGetInput,
) -> Result<SquareHoleRunSnapshot, String> {
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
refresh_run_row(ctx, row, current_server_ms(ctx))
}
fn drop_square_hole_shape_tx(
ctx: &ReducerContext,
input: SquareHoleRunDropInput,
) -> Result<SquareHoleDropShapeProcedureResult, String> {
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
let confirmation = confirm_domain_drop_at(
&domain_run,
&DomainSquareHoleDropInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
hole_id: input.hole_id,
client_snapshot_version: input.client_snapshot_version,
client_event_id: input.client_event_id,
dropped_at_ms: input.dropped_at_ms.max(0) as u64,
},
)
.map_err(|error| error.to_string())?;
let next = snapshot_from_domain(&confirmation.run, input.dropped_at_ms.max(0));
replace_run(
ctx,
&row,
run_row_from_snapshot(&next, row.created_at, ctx.timestamp),
);
let status = if confirmation.feedback.accepted {
SQUARE_HOLE_DROP_ACCEPTED
} else {
match confirmation.feedback.reject_reason {
Some(DomainSquareHoleDropRejectReason::SnapshotVersionMismatch) => {
SQUARE_HOLE_DROP_VERSION_CONFLICT
}
Some(DomainSquareHoleDropRejectReason::RunNotActive)
| Some(DomainSquareHoleDropRejectReason::TimeUp) => SQUARE_HOLE_DROP_RUN_FINISHED,
_ => SQUARE_HOLE_DROP_REJECTED,
}
};
Ok(SquareHoleDropShapeProcedureResult {
ok: true,
status: status.to_string(),
run: Some(next),
feedback: Some(feedback_from_domain(&confirmation.feedback)),
failure_reason: confirmation
.feedback
.reject_reason
.map(domain_reject_reason_to_text)
.map(str::to_string),
error_message: None,
})
}
fn stop_square_hole_run_tx(
ctx: &ReducerContext,
input: SquareHoleRunStopInput,
) -> Result<SquareHoleRunSnapshot, String> {
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
let stopped = stop_domain_run_at(&domain_run);
let next = snapshot_from_domain(&stopped, input.stopped_at_ms.max(0));
replace_run(
ctx,
&row,
run_row_from_snapshot(&next, row.created_at, ctx.timestamp),
);
Ok(next)
}
fn restart_square_hole_run_tx(
ctx: &ReducerContext,
input: SquareHoleRunRestartInput,
) -> Result<SquareHoleRunSnapshot, String> {
let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?;
start_square_hole_run_tx(
ctx,
SquareHoleRunStartInput {
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,
},
)
}
fn finish_square_hole_time_up_tx(
ctx: &ReducerContext,
input: SquareHoleRunTimeUpInput,
) -> Result<SquareHoleRunSnapshot, String> {
let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?;
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
let expired = resolve_domain_run_timer_at(&domain_run, input.finished_at_ms.max(0) as u64);
let next = snapshot_from_domain(&expired, input.finished_at_ms.max(0));
replace_run(
ctx,
&row,
run_row_from_snapshot(&next, row.created_at, ctx.timestamp),
);
Ok(next)
}
fn build_session_snapshot(
ctx: &ReducerContext,
row: &SquareHoleAgentSessionRow,
) -> Result<SquareHoleAgentSessionSnapshot, String> {
let config = parse_config(&row.config_json)?;
let draft = if row.draft_json.trim().is_empty() {
None
} else {
Some(parse_json(&row.draft_json, "square_hole draft_json")?)
};
let mut messages = ctx
.db
.square_hole_agent_message()
.by_square_hole_agent_message_session_id()
.filter(&row.session_id)
.map(|message| SquareHoleAgentMessageSnapshot {
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));
Ok(SquareHoleAgentSessionSnapshot {
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: &SquareHoleWorkProfileRow) -> Result<SquareHoleWorkSnapshot, String> {
let config = parse_config(&row.config_json)?;
Ok(SquareHoleWorkSnapshot {
work_id: row.work_id.clone(),
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(),
twist_rule: row.twist_rule.clone(),
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(),
background_prompt: config.background_prompt.clone(),
background_image_src: config.background_image_src.clone(),
shape_options: config.shape_options.clone(),
hole_options: config.hole_options.clone(),
shape_count: row.shape_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()),
})
}
fn build_gallery_view_row(
row: &SquareHoleWorkProfileRow,
) -> Result<SquareHoleGalleryViewRow, String> {
let config = parse_config(&row.config_json)?;
Ok(SquareHoleGalleryViewRow {
work_id: row.work_id.clone(),
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(),
twist_rule: row.twist_rule.clone(),
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(),
background_prompt: config.background_prompt,
background_image_src: config.background_image_src,
shape_options: config.shape_options,
hole_options: config.hole_options,
shape_count: row.shape_count,
difficulty: row.difficulty,
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()),
})
}
fn refresh_run_row(
ctx: &ReducerContext,
row: SquareHoleRuntimeRunRow,
server_now_ms: i64,
) -> Result<SquareHoleRunSnapshot, String> {
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
let refreshed = resolve_domain_run_timer_at(&domain_run, server_now_ms.max(0) as u64);
let next = snapshot_from_domain(&refreshed, server_now_ms);
if next.snapshot_version != snapshot.snapshot_version || next.status != snapshot.status {
replace_run(
ctx,
&row,
run_row_from_snapshot(&next, row.created_at, ctx.timestamp),
);
}
Ok(next)
}
fn find_owned_session(
ctx: &ReducerContext,
session_id: &str,
owner_user_id: &str,
) -> Result<SquareHoleAgentSessionRow, String> {
require_non_empty(session_id, "square_hole session_id")?;
require_non_empty(owner_user_id, "square_hole owner_user_id")?;
ctx.db
.square_hole_agent_session()
.session_id()
.find(&session_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.ok_or_else(|| "方洞挑战会话不存在".to_string())
}
fn find_owned_work(
ctx: &ReducerContext,
profile_id: &str,
owner_user_id: &str,
) -> Result<SquareHoleWorkProfileRow, String> {
require_non_empty(profile_id, "square_hole profile_id")?;
require_non_empty(owner_user_id, "square_hole owner_user_id")?;
ctx.db
.square_hole_work_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.ok_or_else(|| "方洞挑战作品不存在".to_string())
}
fn find_playable_work(
ctx: &ReducerContext,
profile_id: &str,
owner_user_id: &str,
) -> Result<SquareHoleWorkProfileRow, String> {
let row = ctx
.db
.square_hole_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "方洞挑战作品不存在".to_string())?;
if row.owner_user_id != owner_user_id
&& row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED
{
return Err("无权试玩该方洞挑战作品".to_string());
}
Ok(row)
}
fn find_owned_run(
ctx: &ReducerContext,
run_id: &str,
owner_user_id: &str,
) -> Result<SquareHoleRuntimeRunRow, String> {
require_non_empty(run_id, "square_hole run_id")?;
require_non_empty(owner_user_id, "square_hole owner_user_id")?;
ctx.db
.square_hole_runtime_run()
.run_id()
.find(&run_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.ok_or_else(|| "方洞挑战运行态不存在".to_string())
}
fn upsert_work(ctx: &ReducerContext, work: SquareHoleWorkProfileRow) {
if ctx
.db
.square_hole_work_profile()
.profile_id()
.find(&work.profile_id)
.is_some()
{
ctx.db
.square_hole_work_profile()
.profile_id()
.delete(&work.profile_id);
}
ctx.db.square_hole_work_profile().insert(work);
}
fn replace_session(
ctx: &ReducerContext,
current: &SquareHoleAgentSessionRow,
next: SquareHoleAgentSessionRow,
) {
ctx.db
.square_hole_agent_session()
.session_id()
.delete(&current.session_id);
ctx.db.square_hole_agent_session().insert(next);
}
fn replace_work(
ctx: &ReducerContext,
current: &SquareHoleWorkProfileRow,
next: SquareHoleWorkProfileRow,
) {
ctx.db
.square_hole_work_profile()
.profile_id()
.delete(&current.profile_id);
ctx.db.square_hole_work_profile().insert(next);
}
fn replace_run(
ctx: &ReducerContext,
current: &SquareHoleRuntimeRunRow,
next: SquareHoleRuntimeRunRow,
) {
ctx.db
.square_hole_runtime_run()
.run_id()
.delete(&current.run_id);
ctx.db.square_hole_runtime_run().insert(next);
}
fn clone_session(row: &SquareHoleAgentSessionRow) -> SquareHoleAgentSessionRow {
SquareHoleAgentSessionRow {
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: &SquareHoleWorkProfileRow) -> SquareHoleWorkProfileRow {
SquareHoleWorkProfileRow {
profile_id: row.profile_id.clone(),
work_id: row.work_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(),
twist_rule: row.twist_rule.clone(),
summary_text: row.summary_text.clone(),
tags_json: row.tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
shape_count: row.shape_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,
}
}
fn validate_config(config: &SquareHoleCreatorConfigSnapshot) -> Result<(), String> {
domain_config_from_snapshot(config)
.map(|_| ())
.map_err(|error| error.to_string())
}
fn validate_publishable_work(row: &SquareHoleWorkProfileRow) -> Result<(), String> {
if row.game_name.trim().is_empty() {
return Err("方洞挑战发布需要填写游戏名称".to_string());
}
if row.summary_text.trim().is_empty() {
return Err("方洞挑战发布需要简介".to_string());
}
if parse_tags(&row.tags_json)?.is_empty() {
return Err("方洞挑战发布需要至少 1 个标签".to_string());
}
validate_config(&parse_config(&row.config_json)?)
}
fn is_work_publish_ready(row: &SquareHoleWorkProfileRow) -> bool {
validate_publishable_work(row).is_ok()
}
fn default_config_from_seed(seed_text: &str) -> SquareHoleCreatorConfigSnapshot {
normalize_config(SquareHoleCreatorConfigSnapshot {
theme_text: clean_string(seed_text, "玩具"),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: String::new(),
cover_image_src: String::new(),
background_image_src: String::new(),
})
}
fn parse_config_or_default(value: &str) -> SquareHoleCreatorConfigSnapshot {
parse_config(value).unwrap_or_else(|_| default_config_from_seed("玩具"))
}
fn parse_config(value: &str) -> Result<SquareHoleCreatorConfigSnapshot, String> {
parse_json(value, "square_hole config_json").map(normalize_config)
}
fn normalize_config(
mut config: SquareHoleCreatorConfigSnapshot,
) -> SquareHoleCreatorConfigSnapshot {
config.theme_text = clean_string(&config.theme_text, "玩具");
config.twist_rule = clean_string(&config.twist_rule, "方洞万能");
config.difficulty = config.difficulty.clamp(
module_square_hole::SQUARE_HOLE_MIN_DIFFICULTY,
module_square_hole::SQUARE_HOLE_MAX_DIFFICULTY,
);
config.background_prompt = clean_string(
&config.background_prompt,
&default_domain_background_prompt(&config.theme_text),
);
config.cover_image_src = config.cover_image_src.trim().to_string();
config.background_image_src = config.background_image_src.trim().to_string();
let hole_options = normalize_domain_hole_options(
domain_hole_options_from_snapshot(&config.hole_options),
&config.theme_text,
);
let shape_options = normalize_domain_shape_options(
domain_shape_options_from_snapshot(&config.shape_options),
&config.theme_text,
hole_options.as_slice(),
);
config.shape_options = shape_options_to_snapshot(&shape_options);
config.hole_options = hole_options_to_snapshot(&hole_options);
config
}
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
let parsed = parse_json::<Vec<String>>(value, "square_hole tags_json")?;
Ok(normalize_tags(parsed))
}
fn parse_optional_json<T: DeserializeOwned>(value: &str, label: &str) -> Result<Option<T>, String> {
if value.trim().is_empty() {
return Ok(None);
}
parse_json(value, label).map(Some)
}
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() {
SQUARE_HOLE_STAGE_READY_TO_COMPILE => SQUARE_HOLE_STAGE_READY_TO_COMPILE.to_string(),
SQUARE_HOLE_STAGE_DRAFT_COMPILED => SQUARE_HOLE_STAGE_DRAFT_COMPILED.to_string(),
SQUARE_HOLE_STAGE_PUBLISHED => SQUARE_HOLE_STAGE_PUBLISHED.to_string(),
_ => SQUARE_HOLE_STAGE_COLLECTING.to_string(),
}
}
fn domain_config_from_snapshot(
config: &SquareHoleCreatorConfigSnapshot,
) -> Result<DomainSquareHoleCreatorConfig, module_square_hole::SquareHoleError> {
let mut domain = build_domain_creator_config(
&config.theme_text,
&config.twist_rule,
config.shape_count,
config.difficulty,
)?;
domain.shape_options = domain_shape_options_from_snapshot(&config.shape_options);
domain.hole_options = domain_hole_options_from_snapshot(&config.hole_options);
domain.background_prompt = config.background_prompt.clone();
domain.cover_image_src = empty_to_none(&config.cover_image_src);
domain.background_image_src = empty_to_none(&config.background_image_src);
Ok(domain)
}
fn snapshot_from_domain(
run: &DomainSquareHoleRunSnapshot,
server_now_ms: i64,
) -> SquareHoleRunSnapshot {
SquareHoleRunSnapshot {
run_id: run.run_id.clone(),
profile_id: run.profile_id.clone(),
owner_user_id: run.owner_user_id.clone(),
status: domain_status_to_text(run.status).to_string(),
snapshot_version: run.snapshot_version,
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,
total_shape_count: run.total_shape_count,
completed_shape_count: run.completed_shape_count,
combo: run.combo,
best_combo: run.best_combo,
score: run.score,
rule_label: run.rule_label.clone(),
background_image_src: run.background_image_src.clone().unwrap_or_default(),
shape_options: shape_options_to_snapshot(&run.shape_options),
current_shape: run.current_shape.as_ref().map(shape_from_domain),
holes: run.holes.iter().map(hole_from_domain).collect(),
last_feedback: run.last_feedback.as_ref().map(feedback_from_domain),
}
}
fn domain_snapshot_from_snapshot(
snapshot: &SquareHoleRunSnapshot,
owner_user_id: &str,
) -> DomainSquareHoleRunSnapshot {
DomainSquareHoleRunSnapshot {
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),
snapshot_version: snapshot.snapshot_version,
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),
total_shape_count: snapshot.total_shape_count,
completed_shape_count: snapshot.completed_shape_count,
combo: snapshot.combo,
best_combo: snapshot.best_combo,
score: snapshot.score,
rule_label: snapshot.rule_label.clone(),
background_image_src: empty_to_none(&snapshot.background_image_src),
shape_options: domain_shape_options_from_snapshot(&snapshot.shape_options),
current_shape: snapshot
.current_shape
.as_ref()
.map(domain_shape_from_snapshot),
holes: snapshot
.holes
.iter()
.map(domain_hole_from_snapshot)
.collect(),
last_feedback: snapshot
.last_feedback
.as_ref()
.map(domain_feedback_from_snapshot),
}
}
fn shape_from_domain(shape: &DomainSquareHoleShapeSnapshot) -> SquareHoleShapeSnapshot {
SquareHoleShapeSnapshot {
shape_id: shape.shape_id.clone(),
shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(),
target_hole_id: shape.target_hole_id.clone(),
color: shape.color.clone(),
image_src: shape.image_src.clone().unwrap_or_default(),
}
}
fn domain_shape_from_snapshot(shape: &SquareHoleShapeSnapshot) -> DomainSquareHoleShapeSnapshot {
DomainSquareHoleShapeSnapshot {
shape_id: shape.shape_id.clone(),
shape_kind: shape.shape_kind.clone(),
label: shape.label.clone(),
target_hole_id: shape.target_hole_id.clone(),
color: shape.color.clone(),
image_src: empty_to_none(&shape.image_src),
}
}
fn hole_from_domain(hole: &DomainSquareHoleHoleSnapshot) -> SquareHoleHoleSnapshot {
SquareHoleHoleSnapshot {
hole_id: hole.hole_id.clone(),
hole_kind: hole.hole_kind.clone(),
label: hole.label.clone(),
x: hole.x,
y: hole.y,
image_src: hole.image_src.clone().unwrap_or_default(),
}
}
fn domain_hole_from_snapshot(hole: &SquareHoleHoleSnapshot) -> DomainSquareHoleHoleSnapshot {
DomainSquareHoleHoleSnapshot {
hole_id: hole.hole_id.clone(),
hole_kind: hole.hole_kind.clone(),
label: hole.label.clone(),
x: hole.x,
y: hole.y,
image_src: empty_to_none(&hole.image_src),
}
}
fn shape_options_to_snapshot(
options: &[DomainSquareHoleShapeOption],
) -> Vec<SquareHoleShapeOptionSnapshot> {
options
.iter()
.map(|option| SquareHoleShapeOptionSnapshot {
option_id: option.option_id.clone(),
shape_kind: option.shape_kind.clone(),
label: option.label.clone(),
target_hole_id: option.target_hole_id.clone(),
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone().unwrap_or_default(),
})
.collect()
}
fn domain_shape_options_from_snapshot(
options: &[SquareHoleShapeOptionSnapshot],
) -> Vec<DomainSquareHoleShapeOption> {
options
.iter()
.map(|option| DomainSquareHoleShapeOption {
option_id: option.option_id.clone(),
shape_kind: option.shape_kind.clone(),
label: option.label.clone(),
target_hole_id: option.target_hole_id.clone(),
image_prompt: option.image_prompt.clone(),
image_src: empty_to_none(&option.image_src),
})
.collect()
}
fn hole_options_to_snapshot(
options: &[DomainSquareHoleHoleOption],
) -> Vec<SquareHoleHoleOptionSnapshot> {
options
.iter()
.map(|option| SquareHoleHoleOptionSnapshot {
hole_id: option.hole_id.clone(),
hole_kind: option.hole_kind.clone(),
label: option.label.clone(),
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone().unwrap_or_default(),
})
.collect()
}
fn domain_hole_options_from_snapshot(
options: &[SquareHoleHoleOptionSnapshot],
) -> Vec<DomainSquareHoleHoleOption> {
options
.iter()
.map(|option| DomainSquareHoleHoleOption {
hole_id: option.hole_id.clone(),
hole_kind: option.hole_kind.clone(),
label: option.label.clone(),
image_prompt: option.image_prompt.clone(),
image_src: empty_to_none(&option.image_src),
})
.collect()
}
fn feedback_from_domain(feedback: &DomainSquareHoleDropFeedback) -> SquareHoleDropFeedbackSnapshot {
SquareHoleDropFeedbackSnapshot {
accepted: feedback.accepted,
reject_reason: feedback
.reject_reason
.map(domain_reject_reason_to_text)
.map(str::to_string),
message: feedback.message.clone(),
}
}
fn domain_feedback_from_snapshot(
feedback: &SquareHoleDropFeedbackSnapshot,
) -> DomainSquareHoleDropFeedback {
DomainSquareHoleDropFeedback {
accepted: feedback.accepted,
reject_reason: feedback
.reject_reason
.as_deref()
.map(domain_reject_reason_from_text),
message: feedback.message.clone(),
}
}
fn run_row_from_snapshot(
snapshot: &SquareHoleRunSnapshot,
created_at: Timestamp,
updated_at: Timestamp,
) -> SquareHoleRuntimeRunRow {
let finished_at_ms = if snapshot.status == SQUARE_HOLE_RUN_RUNNING {
0
} else {
snapshot.server_now_ms
};
SquareHoleRuntimeRunRow {
run_id: snapshot.run_id.clone(),
owner_user_id: snapshot.owner_user_id.clone(),
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: snapshot
.server_now_ms
.saturating_sub(snapshot.started_at_ms)
.max(0),
total_shape_count: snapshot.total_shape_count,
completed_shape_count: snapshot.completed_shape_count,
score: snapshot.score,
snapshot_json: to_json_string(snapshot),
created_at,
updated_at,
}
}
fn domain_status_to_text(status: DomainSquareHoleRunStatus) -> &'static str {
match status {
DomainSquareHoleRunStatus::Running => SQUARE_HOLE_RUN_RUNNING,
DomainSquareHoleRunStatus::Won => SQUARE_HOLE_RUN_WON,
DomainSquareHoleRunStatus::Failed => SQUARE_HOLE_RUN_FAILED,
DomainSquareHoleRunStatus::Stopped => SQUARE_HOLE_RUN_STOPPED,
}
}
fn domain_status_from_text(value: &str) -> DomainSquareHoleRunStatus {
match value {
SQUARE_HOLE_RUN_WON | "won" => DomainSquareHoleRunStatus::Won,
SQUARE_HOLE_RUN_FAILED | "failed" => DomainSquareHoleRunStatus::Failed,
SQUARE_HOLE_RUN_STOPPED | "stopped" => DomainSquareHoleRunStatus::Stopped,
_ => DomainSquareHoleRunStatus::Running,
}
}
fn domain_reject_reason_to_text(reason: DomainSquareHoleDropRejectReason) -> &'static str {
reason.as_str()
}
fn domain_reject_reason_from_text(value: &str) -> DomainSquareHoleDropRejectReason {
match value {
"snapshot_version_mismatch" => DomainSquareHoleDropRejectReason::SnapshotVersionMismatch,
"hole_not_found" => DomainSquareHoleDropRejectReason::HoleNotFound,
"incompatible" => DomainSquareHoleDropRejectReason::Incompatible,
"time_up" => DomainSquareHoleDropRejectReason::TimeUp,
_ => DomainSquareHoleDropRejectReason::RunNotActive,
}
}
fn to_u64_ms(value: i64) -> u64 {
value.max(0) as u64
}
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<SquareHoleRunSnapshot, String> {
parse_json(value, "square_hole 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: SquareHoleAgentSessionSnapshot,
) -> SquareHoleAgentSessionProcedureResult {
SquareHoleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
}
}
fn session_error(message: String) -> SquareHoleAgentSessionProcedureResult {
SquareHoleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
}
}
fn work_result(work: SquareHoleWorkSnapshot) -> SquareHoleWorkProcedureResult {
SquareHoleWorkProcedureResult {
ok: true,
work: Some(work),
error_message: None,
}
}
fn work_error(message: String) -> SquareHoleWorkProcedureResult {
SquareHoleWorkProcedureResult {
ok: false,
work: None,
error_message: Some(message),
}
}
fn run_result(run: SquareHoleRunSnapshot) -> SquareHoleRunProcedureResult {
SquareHoleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
}
}
fn run_error(message: String) -> SquareHoleRunProcedureResult {
SquareHoleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
}
}