Extend square-hole creation flow with visual asset timeout guard

This commit is contained in:
kdletters
2026-05-05 15:27:09 +08:00
parent 2252afb080
commit 60b667a9d1
30 changed files with 2838 additions and 215 deletions

View File

@@ -13,9 +13,11 @@ use axum::{
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_square_hole::{
SQUARE_HOLE_MESSAGE_ID_PREFIX, SQUARE_HOLE_PROFILE_ID_PREFIX, SQUARE_HOLE_RUN_ID_PREFIX,
SQUARE_HOLE_SESSION_ID_PREFIX,
SQUARE_HOLE_SESSION_ID_PREFIX, default_background_prompt, normalize_hole_options,
normalize_shape_options,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -24,8 +26,9 @@ use shared_contracts::{
CreateSquareHoleSessionRequest, ExecuteSquareHoleActionRequest,
SendSquareHoleMessageRequest, SquareHoleActionResponse, SquareHoleAgentMessageResponse,
SquareHoleAnchorItemResponse, SquareHoleAnchorPackResponse,
SquareHoleCreatorConfigResponse, SquareHoleResultDraftResponse, SquareHoleSessionResponse,
SquareHoleSessionSnapshotResponse,
SquareHoleCreatorConfigResponse, SquareHoleHoleOptionResponse,
SquareHoleResultDraftResponse, SquareHoleSessionResponse,
SquareHoleSessionSnapshotResponse, SquareHoleShapeOptionResponse,
},
square_hole_runtime::{
DropSquareHoleShapeRequest, SquareHoleDropFeedbackResponse, SquareHoleDropResponse,
@@ -33,7 +36,9 @@ use shared_contracts::{
SquareHoleShapeSnapshotResponse, StartSquareHoleRunRequest, StopSquareHoleRunRequest,
},
square_hole_works::{
PutSquareHoleWorkRequest, SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
PutSquareHoleWorkRequest, SquareHoleHoleOptionResponse as SquareHoleWorkHoleOptionResponse,
SquareHoleShapeOptionResponse as SquareHoleWorkShapeOptionResponse,
SquareHoleWorkDetailResponse, SquareHoleWorkMutationResponse,
SquareHoleWorkProfileResponse, SquareHoleWorkSummaryResponse, SquareHoleWorksResponse,
},
};
@@ -42,11 +47,11 @@ use spacetime_client::{
SpacetimeClientError, SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput,
SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord,
SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput,
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleSnapshotRecord,
SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRecord,
SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput,
SquareHoleRunTimeUpRecordInput, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord,
SquareHoleWorkUpdateRecordInput,
SquareHoleCreatorConfigRecord, SquareHoleDropFeedbackRecord, SquareHoleHoleOptionRecord,
SquareHoleHoleSnapshotRecord, SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput,
SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput,
SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord,
SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput,
};
use crate::{
@@ -54,6 +59,10 @@ use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
openai_image_generation::{
build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
},
request_context::RequestContext,
square_hole_agent_turn::{
SquareHoleAgentTurnRequest, build_finalize_record_input, run_square_hole_agent_turn,
@@ -77,6 +86,37 @@ struct SquareHoleConfigJson {
twist_rule: String,
shape_count: u32,
difficulty: u32,
#[serde(default)]
shape_options: Vec<SquareHoleConfigShapeOptionJson>,
#[serde(default)]
hole_options: Vec<SquareHoleConfigHoleOptionJson>,
#[serde(default)]
background_prompt: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
cover_image_src: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
background_image_src: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleConfigShapeOptionJson {
option_id: String,
shape_kind: String,
label: String,
image_prompt: String,
#[serde(default, deserialize_with = "deserialize_optional_string_as_default")]
image_src: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleConfigHoleOptionJson {
hole_id: String,
hole_kind: String,
label: String,
#[serde(default)]
bonus: bool,
}
#[derive(Clone, Debug, Deserialize)]
@@ -299,25 +339,37 @@ pub async fn execute_square_hole_agent_action(
"sessionId",
)?;
if payload.action.trim() != "square_hole_compile_draft" {
return Err(square_hole_bad_request(
&request_context,
SQUARE_HOLE_AGENT_PROVIDER,
"unknown square hole action",
));
}
let session = compile_square_hole_draft_for_session(
&state,
&request_context,
&authenticated,
session_id,
payload.game_name,
payload.summary,
payload.tags,
payload.cover_image_src,
)
.await?;
let session = match payload.action.trim() {
"square_hole_compile_draft" => {
compile_square_hole_draft_for_session(
&state,
&request_context,
&authenticated,
session_id,
payload.game_name,
payload.summary,
payload.tags,
payload.cover_image_src,
)
.await?
}
"square_hole_generate_visual_assets" => {
generate_square_hole_visual_assets_for_session(
&state,
&request_context,
&authenticated,
session_id,
)
.await?
}
_ => {
return Err(square_hole_bad_request(
&request_context,
SQUARE_HOLE_AGENT_PROVIDER,
"unknown square hole action",
));
}
};
Ok(json_success_body(
Some(&request_context),
@@ -491,6 +543,18 @@ pub async fn put_square_hole_work(
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or(existing.theme_text);
let shape_options_json = payload
.shape_options
.clone()
.map(square_hole_work_shape_options_to_records)
.unwrap_or_else(|| existing.shape_options.clone());
let shape_options_json = serialize_square_hole_shape_option_records(&shape_options_json);
let hole_options_json = payload
.hole_options
.clone()
.map(square_hole_work_hole_options_to_records)
.unwrap_or_else(|| existing.hole_options.clone());
let hole_options_json = serialize_square_hole_hole_option_records(&hole_options_json);
let item = state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
@@ -502,6 +566,15 @@ pub async fn put_square_hole_work(
summary_text: payload.summary,
tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
cover_image_src: payload.cover_image_src.unwrap_or_default(),
background_prompt: payload
.background_prompt
.filter(|value| !value.trim().is_empty())
.unwrap_or(existing.background_prompt),
background_image_src: payload
.background_image_src
.unwrap_or(existing.background_image_src.unwrap_or_default()),
shape_options_json,
hole_options_json,
shape_count: payload.shape_count,
difficulty: payload.difficulty,
updated_at_micros: current_utc_micros(),
@@ -986,6 +1059,231 @@ async fn compile_square_hole_draft_for_session(
})
}
async fn generate_square_hole_visual_assets_for_session(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
) -> Result<SquareHoleAgentSessionRecord, Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.get_square_hole_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let profile_id = session
.draft
.as_ref()
.map(|draft| draft.profile_id.clone())
.ok_or_else(|| {
square_hole_bad_request(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
"square hole 草稿尚未编译,不能生成图片资产",
)
})?;
let mut work = state
.spacetime_client()
.get_square_hole_work_detail(profile_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let cover_image_src = match work.cover_image_src.clone() {
Some(value) if !value.trim().is_empty() => Some(value),
_ => Some(
generate_square_hole_image_data_url(
state,
build_square_hole_cover_prompt(&work).as_str(),
"16:9",
"生成方洞挑战封面图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
),
};
let background_image_src = match work.background_image_src.clone() {
Some(value) if !value.trim().is_empty() => Some(value),
_ => Some(
generate_square_hole_image_data_url(
state,
build_square_hole_background_prompt(&work).as_str(),
"16:9",
"生成方洞挑战背景图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
),
};
let mut shape_options = work.shape_options.clone();
let prompt_work = work.clone();
for option in shape_options.iter_mut() {
if option
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
continue;
}
option.image_src = Some(
generate_square_hole_image_data_url(
state,
build_square_hole_shape_prompt(&prompt_work, option).as_str(),
"1:1",
"生成方洞挑战形状贴图失败",
)
.await
.map_err(|error| {
square_hole_error_response(request_context, SQUARE_HOLE_AGENT_PROVIDER, error)
})?,
);
}
work = state
.spacetime_client()
.update_square_hole_work(SquareHoleWorkUpdateRecordInput {
profile_id,
owner_user_id: owner_user_id.clone(),
game_name: work.game_name.clone(),
theme_text: work.theme_text.clone(),
twist_rule: work.twist_rule.clone(),
summary_text: work.summary.clone(),
tags_json: serde_json::to_string(&normalize_tags(work.tags.clone()))
.unwrap_or_default(),
cover_image_src: cover_image_src.clone().unwrap_or_default(),
background_prompt: work.background_prompt.clone(),
background_image_src: background_image_src.clone().unwrap_or_default(),
shape_options_json: serialize_square_hole_shape_option_records(&shape_options),
hole_options_json: serialize_square_hole_hole_option_records(&work.hole_options),
shape_count: work.shape_count,
difficulty: work.difficulty,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
let mut next_session = state
.spacetime_client()
.get_square_hole_agent_session(session_id, owner_user_id)
.await
.map_err(|error| {
square_hole_error_response(
request_context,
SQUARE_HOLE_AGENT_PROVIDER,
map_square_hole_client_error(error),
)
})?;
if let Some(draft) = next_session.draft.as_mut() {
draft.cover_image_src = work.cover_image_src.clone();
draft.background_image_src = work.background_image_src.clone();
draft.background_prompt = work.background_prompt.clone();
draft.shape_options = work.shape_options.clone();
draft.hole_options = work.hole_options.clone();
}
Ok(next_session)
}
async fn generate_square_hole_image_data_url(
state: &AppState,
prompt: &str,
size: &str,
failure_context: &str,
) -> Result<String, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
prompt,
Some(build_square_hole_negative_prompt().as_str()),
size,
1,
&[],
failure_context,
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:上游未返回图片"),
}))
})?;
Ok(format!(
"data:{};base64,{}",
image.mime_type,
BASE64_STANDARD.encode(image.bytes)
))
}
fn build_square_hole_cover_prompt(work: &SquareHoleWorkProfileRecord) -> String {
format!(
"移动端休闲游戏封面图。主题:{}。玩法反差:{}。画面主体是贴着主题图案的几何形状正在靠近不同洞口,视觉清晰、色彩明快、偏游戏资产质感。不要文字、不要 UI、不要水印。",
clean_prompt_text(&work.theme_text, "奇怪形状"),
clean_prompt_text(&work.twist_rule, "反直觉分拣")
)
}
fn build_square_hole_background_prompt(work: &SquareHoleWorkProfileRecord) -> String {
let custom_prompt = work.background_prompt.trim();
if !custom_prompt.is_empty() {
return format!(
"移动端休闲游戏运行背景。{}。画面中央预留清晰操作空间,边缘可有主题装饰,低噪声,不要文字、不要 UI、不要水印。",
custom_prompt
);
}
format!(
"移动端休闲游戏运行背景。主题:{}。柔和纵深、玩具盒或舞台感,中央预留清晰操作空间,不要文字、不要 UI、不要水印。",
clean_prompt_text(&work.theme_text, "奇怪形状")
)
}
fn build_square_hole_shape_prompt(
work: &SquareHoleWorkProfileRecord,
option: &SquareHoleShapeOptionRecord,
) -> String {
let image_prompt = option.image_prompt.trim();
let option_prompt = if image_prompt.is_empty() {
format!("{} 主题的 {}", work.theme_text, option.label)
} else {
image_prompt.to_string()
};
format!(
"单个游戏道具贴图,透明或干净浅色背景。几何形状:{}。主题贴图:{}。要求主体居中、边缘清晰、适合贴在可拖拽形状上,不要文字、不要 UI、不要水印。",
clean_prompt_text(&option.label, "形状"),
clean_prompt_text(&option_prompt, "主题图案")
)
}
fn build_square_hole_negative_prompt() -> String {
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
}
fn map_square_hole_agent_session_response(
session: SquareHoleAgentSessionRecord,
) -> SquareHoleSessionSnapshotResponse {
@@ -1084,6 +1382,19 @@ fn map_square_hole_config_response(
twist_rule: config.twist_rule,
shape_count: config.shape_count,
difficulty: config.difficulty,
shape_options: config
.shape_options
.into_iter()
.map(map_square_hole_shape_option_response)
.collect(),
hole_options: config
.hole_options
.into_iter()
.map(map_square_hole_hole_option_response)
.collect(),
background_prompt: config.background_prompt,
cover_image_src: config.cover_image_src,
background_image_src: config.background_image_src,
}
}
@@ -1097,6 +1408,19 @@ fn map_square_hole_draft_response(
twist_rule: draft.twist_rule,
summary: draft.summary,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
background_prompt: draft.background_prompt,
background_image_src: draft.background_image_src,
shape_options: draft
.shape_options
.into_iter()
.map(map_square_hole_shape_option_response)
.collect(),
hole_options: draft
.hole_options
.into_iter()
.map(map_square_hole_hole_option_response)
.collect(),
shape_count: draft.shape_count,
difficulty: draft.difficulty,
publish_ready: draft.publish_ready,
@@ -1130,6 +1454,18 @@ fn map_square_hole_work_summary_response(
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
background_prompt: item.background_prompt,
background_image_src: item.background_image_src,
shape_options: item
.shape_options
.into_iter()
.map(map_square_hole_work_shape_option_response)
.collect(),
hole_options: item
.hole_options
.into_iter()
.map(map_square_hole_work_hole_option_response)
.collect(),
shape_count: item.shape_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
@@ -1164,6 +1500,7 @@ fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapsh
best_combo: run.best_combo,
score: run.score,
rule_label: run.rule_label,
background_image_src: run.background_image_src,
current_shape: run.current_shape.map(map_square_hole_shape_response),
holes: run
.holes
@@ -1182,6 +1519,7 @@ fn map_square_hole_shape_response(
shape_kind: item.shape_kind,
label: item.label,
color: item.color,
image_src: item.image_src,
}
}
@@ -1194,6 +1532,53 @@ fn map_square_hole_hole_response(
label: slot.label,
x: slot.x,
y: slot.y,
bonus: slot.bonus,
}
}
fn map_square_hole_shape_option_response(
item: SquareHoleShapeOptionRecord,
) -> SquareHoleShapeOptionResponse {
SquareHoleShapeOptionResponse {
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
fn map_square_hole_hole_option_response(
item: SquareHoleHoleOptionRecord,
) -> SquareHoleHoleOptionResponse {
SquareHoleHoleOptionResponse {
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
bonus: item.bonus,
}
}
fn map_square_hole_work_shape_option_response(
item: SquareHoleShapeOptionRecord,
) -> SquareHoleWorkShapeOptionResponse {
SquareHoleWorkShapeOptionResponse {
option_id: item.option_id,
shape_kind: item.shape_kind,
label: item.label,
image_prompt: item.image_prompt,
image_src: item.image_src,
}
}
fn map_square_hole_work_hole_option_response(
item: SquareHoleHoleOptionRecord,
) -> SquareHoleWorkHoleOptionResponse {
SquareHoleWorkHoleOptionResponse {
hole_id: item.hole_id,
hole_kind: item.hole_kind,
label: item.label,
bonus: item.bonus,
}
}
@@ -1234,6 +1619,24 @@ fn build_config_from_create_request(
.difficulty
.unwrap_or(SQUARE_HOLE_DEFAULT_DIFFICULTY)
.clamp(1, 10),
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
Vec::new(),
payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
)),
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(Vec::new())),
background_prompt: default_background_prompt(
payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.unwrap_or(SQUARE_HOLE_DEFAULT_THEME),
),
cover_image_src: String::new(),
background_image_src: String::new(),
}
}
@@ -1246,12 +1649,27 @@ fn resolve_config_or_default(
twist_rule: config.twist_rule.clone(),
shape_count: config.shape_count.max(1),
difficulty: config.difficulty.clamp(1, 10),
shape_options: square_hole_shape_records_to_config_json(config.shape_options.clone()),
hole_options: square_hole_hole_records_to_config_json(config.hole_options.clone()),
background_prompt: config.background_prompt.clone(),
cover_image_src: config.cover_image_src.clone().unwrap_or_default(),
background_image_src: config.background_image_src.clone().unwrap_or_default(),
})
.unwrap_or_else(|| SquareHoleConfigJson {
theme_text: SQUARE_HOLE_DEFAULT_THEME.to_string(),
twist_rule: SQUARE_HOLE_DEFAULT_TWIST_RULE.to_string(),
shape_count: SQUARE_HOLE_DEFAULT_SHAPE_COUNT,
difficulty: SQUARE_HOLE_DEFAULT_DIFFICULTY,
shape_options: square_hole_shape_records_to_config_json(normalize_shape_options(
Vec::new(),
SQUARE_HOLE_DEFAULT_THEME,
)),
hole_options: square_hole_hole_records_to_config_json(normalize_hole_options(
Vec::new(),
)),
background_prompt: default_background_prompt(SQUARE_HOLE_DEFAULT_THEME),
cover_image_src: String::new(),
background_image_src: String::new(),
})
}
@@ -1259,6 +1677,13 @@ fn serialize_square_hole_config(config: &SquareHoleConfigJson) -> Option<String>
serde_json::to_string(config).ok()
}
fn deserialize_optional_string_as_default<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
fn build_seed_text(
payload: &CreateSquareHoleSessionRequest,
config: &SquareHoleConfigJson,
@@ -1291,6 +1716,118 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
result
}
fn square_hole_shape_records_to_config_json(
options: Vec<impl Into<SquareHoleConfigShapeOptionJson>>,
) -> Vec<SquareHoleConfigShapeOptionJson> {
options.into_iter().map(Into::into).collect()
}
fn square_hole_hole_records_to_config_json(
options: Vec<impl Into<SquareHoleConfigHoleOptionJson>>,
) -> Vec<SquareHoleConfigHoleOptionJson> {
options.into_iter().map(Into::into).collect()
}
fn square_hole_work_shape_options_to_records(
options: Vec<SquareHoleWorkShapeOptionResponse>,
) -> Vec<SquareHoleShapeOptionRecord> {
options
.into_iter()
.map(|option| SquareHoleShapeOptionRecord {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
image_prompt: option.image_prompt,
image_src: option.image_src.filter(|value| !value.trim().is_empty()),
})
.collect()
}
fn square_hole_work_hole_options_to_records(
options: Vec<SquareHoleWorkHoleOptionResponse>,
) -> Vec<SquareHoleHoleOptionRecord> {
options
.into_iter()
.map(|option| SquareHoleHoleOptionRecord {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
})
.collect()
}
fn serialize_square_hole_shape_option_records(options: &[SquareHoleShapeOptionRecord]) -> String {
let json_options: Vec<SquareHoleConfigShapeOptionJson> =
options.iter().cloned().map(Into::into).collect();
serde_json::to_string(&json_options).unwrap_or_default()
}
fn serialize_square_hole_hole_option_records(options: &[SquareHoleHoleOptionRecord]) -> String {
let json_options: Vec<SquareHoleConfigHoleOptionJson> =
options.iter().cloned().map(Into::into).collect();
serde_json::to_string(&json_options).unwrap_or_default()
}
fn clean_prompt_text(value: &str, fallback: &str) -> String {
let cleaned = value
.trim()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
fallback.to_string()
} else {
cleaned.chars().take(180).collect()
}
}
impl From<module_square_hole::SquareHoleShapeOption> for SquareHoleConfigShapeOptionJson {
fn from(option: module_square_hole::SquareHoleShapeOption) -> Self {
Self {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
impl From<SquareHoleShapeOptionRecord> for SquareHoleConfigShapeOptionJson {
fn from(option: SquareHoleShapeOptionRecord) -> Self {
Self {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
impl From<module_square_hole::SquareHoleHoleOption> for SquareHoleConfigHoleOptionJson {
fn from(option: module_square_hole::SquareHoleHoleOption) -> Self {
Self {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
}
}
}
impl From<SquareHoleHoleOptionRecord> for SquareHoleConfigHoleOptionJson {
fn from(option: SquareHoleHoleOptionRecord) -> Self {
Self {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
bonus: option.bonus,
}
}
}
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,