1638 lines
56 KiB
Rust
1638 lines
56 KiB
Rust
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(¤t.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, ¤t, 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(¤t)?;
|
||
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(¤t)
|
||
};
|
||
let snapshot = build_work_snapshot(&next)?;
|
||
replace_work(ctx, ¤t, next);
|
||
if !current.source_session_id.is_empty() {
|
||
if let Some(session) = ctx
|
||
.db
|
||
.square_hole_agent_session()
|
||
.session_id()
|
||
.find(¤t.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(¤t.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(¤t.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(¤t.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(¤t.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),
|
||
}
|
||
}
|