init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,238 @@
use crate::big_fish::tables::{big_fish_asset_slot, big_fish_creation_session};
use crate::*;
#[spacetimedb::procedure]
pub fn generate_big_fish_asset(
ctx: &mut ProcedureContext,
input: BigFishAssetGenerateInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| generate_big_fish_asset_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn publish_big_fish_game(
ctx: &mut ProcedureContext,
input: BigFishPublishInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| publish_big_fish_game_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
pub(crate) fn generate_big_fish_asset_tx(
ctx: &ReducerContext,
input: BigFishAssetGenerateInput,
) -> Result<BigFishSessionSnapshot, String> {
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
validate_asset_generate_input(&input, &draft).map_err(|error| error.to_string())?;
let slot = build_generated_asset_slot(
&input.session_id,
&draft,
input.asset_kind,
input.level,
input.motion_key.clone(),
input.asset_url.clone(),
input.generated_at_micros,
)
.map_err(|error| error.to_string())?;
upsert_big_fish_asset_slot(ctx, slot);
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
let updated_at = Timestamp::from_micros_since_unix_epoch(input.generated_at_micros);
let uses_placeholder = input
.asset_url
.as_deref()
.map(str::trim)
.is_none_or(str::is_empty);
let reply = match (input.asset_kind, uses_placeholder) {
(BigFishAssetKind::LevelMainImage, true) => "本级主图占位图已生成,可在结果页继续预览。",
(BigFishAssetKind::LevelMainImage, false) => "本级主图已正式生成,可在结果页继续预览。",
(BigFishAssetKind::LevelMotion, true) => "本级动作占位图已生成,可在结果页继续预览。",
(BigFishAssetKind::LevelMotion, false) => "本级动作图已正式生成,可在结果页继续预览。",
(BigFishAssetKind::StageBackground, true) => {
"活动区域背景占位图已生成,可在结果页继续预览。"
}
(BigFishAssetKind::StageBackground, false) => {
"活动区域背景已正式生成,可在结果页继续预览。"
}
}
.to_string();
let next_stage = if coverage.publish_ready {
BigFishCreationStage::ReadyToPublish
} else {
BigFishCreationStage::AssetRefining
};
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: if coverage.publish_ready { 96 } else { 88 },
stage: next_stage,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: serialize_asset_coverage(&coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready,
created_at: session.created_at,
updated_at,
};
replace_big_fish_session(ctx, &session, next_session);
append_big_fish_system_message(
ctx,
&input.session_id,
format!("big-fish-message-asset-{}", input.generated_at_micros),
reply,
input.generated_at_micros,
);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn publish_big_fish_game_tx(
ctx: &ReducerContext,
input: BigFishPublishInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_publish_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
let coverage = build_asset_coverage(
Some(&draft),
&list_big_fish_asset_slots(ctx, &session.session_id),
);
if !coverage.publish_ready {
return Err(format!(
"big_fish 发布校验未通过:{}",
coverage.blockers.join("")
));
}
let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros);
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: 100,
stage: BigFishCreationStage::Published,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: serialize_asset_coverage(&coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some("玩法已发布,可以进入测试运行态。".to_string()),
publish_ready: true,
created_at: session.created_at,
updated_at: published_at,
};
replace_big_fish_session(ctx, &session, next_session);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn list_big_fish_asset_slots(
ctx: &ReducerContext,
session_id: &str,
) -> Vec<BigFishAssetSlotSnapshot> {
let mut slots = ctx
.db
.big_fish_asset_slot()
.iter()
.filter(|slot| slot.session_id == session_id)
.map(|slot| BigFishAssetSlotSnapshot {
slot_id: slot.slot_id,
session_id: slot.session_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
slots.sort_by_key(|slot| {
(
slot.level.unwrap_or(0),
slot.asset_kind.as_str().to_string(),
slot.motion_key.clone().unwrap_or_default(),
slot.slot_id.clone(),
)
});
slots
}
pub(crate) fn upsert_big_fish_asset_slot(ctx: &ReducerContext, slot: BigFishAssetSlotSnapshot) {
if let Some(existing) = ctx.db.big_fish_asset_slot().slot_id().find(&slot.slot_id) {
ctx.db
.big_fish_asset_slot()
.slot_id()
.delete(&existing.slot_id);
}
ctx.db.big_fish_asset_slot().insert(BigFishAssetSlot {
slot_id: slot.slot_id,
session_id: slot.session_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at: Timestamp::from_micros_since_unix_epoch(slot.updated_at_micros),
});
}

View File

@@ -0,0 +1,9 @@
mod assets;
mod runtime;
mod session;
mod tables;
pub use assets::*;
pub use runtime::*;
pub use session::*;
pub use tables::*;

View File

@@ -0,0 +1,190 @@
use crate::big_fish::tables::{big_fish_creation_session, big_fish_runtime_run};
use crate::*;
#[spacetimedb::procedure]
pub fn start_big_fish_run(
ctx: &mut ProcedureContext,
input: BigFishRunStartInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_big_fish_input(
ctx: &mut ProcedureContext,
input: BigFishRunInputSubmitInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_big_fish_run(
ctx: &mut ProcedureContext,
input: BigFishRunGetInput,
) -> BigFishRunProcedureResult {
match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) {
Ok(run) => BigFishRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => BigFishRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
fn start_big_fish_run_tx(
ctx: &ReducerContext,
input: BigFishRunStartInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_start_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.is_some()
{
return Err("big_fish_runtime_run.run_id 已存在".to_string());
}
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
let snapshot = build_initial_runtime_snapshot(
input.run_id.clone(),
input.session_id.clone(),
&draft,
input.started_at_micros,
);
let now = Timestamp::from_micros_since_unix_epoch(input.started_at_micros);
ctx.db.big_fish_runtime_run().insert(BigFishRuntimeRun {
run_id: input.run_id,
session_id: input.session_id,
owner_user_id: input.owner_user_id,
status: snapshot.status,
snapshot_json: serialize_runtime_snapshot(&snapshot).map_err(|error| error.to_string())?,
last_input_x: 0.0,
last_input_y: 0.0,
tick: snapshot.tick,
created_at: now,
updated_at: now,
});
Ok(snapshot)
}
fn submit_big_fish_input_tx(
ctx: &ReducerContext,
input: BigFishRunInputSubmitInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_input_submit_input(&input).map_err(|error| error.to_string())?;
let run = ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&run.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let draft = session
.draft_json
.as_deref()
.ok_or_else(|| "big_fish.draft 尚未编译".to_string())
.and_then(|value| deserialize_draft(value).map_err(|error| error.to_string()))?;
let current_snapshot =
deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())?;
let next_snapshot = advance_runtime_snapshot(
current_snapshot,
&draft.runtime_params,
input.input_x,
input.input_y,
input.submitted_at_micros,
);
replace_big_fish_run(
ctx,
&run,
BigFishRuntimeRun {
run_id: run.run_id.clone(),
session_id: run.session_id.clone(),
owner_user_id: run.owner_user_id.clone(),
status: next_snapshot.status,
snapshot_json: serialize_runtime_snapshot(&next_snapshot)
.map_err(|error| error.to_string())?,
last_input_x: input.input_x,
last_input_y: input.input_y,
tick: next_snapshot.tick,
created_at: run.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros),
},
);
Ok(next_snapshot)
}
fn get_big_fish_run_tx(
ctx: &ReducerContext,
input: BigFishRunGetInput,
) -> Result<BigFishRuntimeSnapshot, String> {
validate_run_get_input(&input).map_err(|error| error.to_string())?;
let run = ctx
.db
.big_fish_runtime_run()
.run_id()
.find(&input.run_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_runtime_run 不存在".to_string())?;
deserialize_runtime_snapshot(&run.snapshot_json).map_err(|error| error.to_string())
}
fn replace_big_fish_run(
ctx: &ReducerContext,
current: &BigFishRuntimeRun,
next: BigFishRuntimeRun,
) {
ctx.db
.big_fish_runtime_run()
.run_id()
.delete(&current.run_id);
ctx.db.big_fish_runtime_run().insert(next);
}

View File

@@ -0,0 +1,759 @@
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
use crate::*;
#[spacetimedb::procedure]
pub fn create_big_fish_session(
ctx: &mut ProcedureContext,
input: BigFishSessionCreateInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| create_big_fish_session_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_big_fish_session(
ctx: &mut ProcedureContext,
input: BigFishSessionGetInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| get_big_fish_session_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_big_fish_works(
ctx: &mut ProcedureContext,
input: BigFishWorksListInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| list_big_fish_works_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn delete_big_fish_work(
ctx: &mut ProcedureContext,
input: BigFishWorkDeleteInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| delete_big_fish_work_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_big_fish_message(
ctx: &mut ProcedureContext,
input: BigFishMessageSubmitInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| submit_big_fish_message_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn finalize_big_fish_agent_message_turn(
ctx: &mut ProcedureContext,
input: BigFishMessageFinalizeInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| finalize_big_fish_agent_message_turn_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn compile_big_fish_draft(
ctx: &mut ProcedureContext,
input: BigFishDraftCompileInput,
) -> BigFishSessionProcedureResult {
match ctx.try_with_tx(|tx| compile_big_fish_draft_tx(tx, input.clone())) {
Ok(session) => BigFishSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => BigFishSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
pub(crate) fn create_big_fish_session_tx(
ctx: &ReducerContext,
input: BigFishSessionCreateInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_session_create_input(&input).map_err(|error| error.to_string())?;
if ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.is_some()
{
return Err("big_fish_creation_session.session_id 已存在".to_string());
}
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&input.welcome_message_id)
.is_some()
{
return Err("big_fish_agent_message.message_id 已存在".to_string());
}
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
let anchor_pack = infer_anchor_pack(&input.seed_text, None);
let asset_coverage = build_asset_coverage(None, &[]);
ctx.db
.big_fish_creation_session()
.insert(BigFishCreationSession {
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: 20,
stage: BigFishCreationStage::CollectingAnchors,
anchor_pack_json: serialize_anchor_pack(&anchor_pack)
.map_err(|error| error.to_string())?,
draft_json: None,
asset_coverage_json: serialize_asset_coverage(&asset_coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(input.welcome_message_text.clone()),
publish_ready: false,
created_at,
updated_at: created_at,
});
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: input.welcome_message_id,
session_id: input.session_id.clone(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::Chat,
text: input.welcome_message_text,
created_at,
});
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn get_big_fish_session_tx(
ctx: &ReducerContext,
input: BigFishSessionGetInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_session_get_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
build_big_fish_session_snapshot(ctx, &session)
}
pub(crate) fn list_big_fish_works_tx(
ctx: &ReducerContext,
input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_works_list_input(&input).map_err(|error| error.to_string())?;
let mut items = ctx
.db
.big_fish_creation_session()
.iter()
.filter(|row| {
row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row)
})
.map(|row| build_big_fish_work_summary(ctx, &row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.work_id.cmp(&right.work_id))
});
Ok(items)
}
fn should_include_big_fish_work(ctx: &ReducerContext, row: &BigFishCreationSession) -> bool {
if big_fish_session_has_direct_work_content(row) {
return true;
}
ctx.db.big_fish_agent_message().iter().any(|message| {
message.session_id == row.session_id
&& matches!(message.role, BigFishAgentMessageRole::User)
})
}
fn big_fish_session_has_direct_work_content(row: &BigFishCreationSession) -> bool {
// 助手欢迎语和默认 anchorPack 只是工作台初始状态,不应被当成草稿作品。
!row.seed_text.trim().is_empty()
|| row.draft_json.is_some()
|| row.stage == BigFishCreationStage::Published
}
pub(crate) fn delete_big_fish_work_tx(
ctx: &ReducerContext,
input: BigFishWorkDeleteInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_session_get_input(&BigFishSessionGetInput {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
})
.map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
// 删除作品时同步清理 Agent 消息、素材槽与运行快照,避免创作页消失后残留孤儿数据。
ctx.db
.big_fish_creation_session()
.session_id()
.delete(&session.session_id);
for message in ctx
.db
.big_fish_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.big_fish_agent_message()
.message_id()
.delete(&message.message_id);
}
for slot in ctx
.db
.big_fish_asset_slot()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id);
}
for run in ctx
.db
.big_fish_runtime_run()
.iter()
.filter(|row| {
row.session_id == input.session_id && row.owner_user_id == input.owner_user_id
})
.collect::<Vec<_>>()
{
ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id);
}
list_big_fish_works_tx(
ctx,
BigFishWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn submit_big_fish_message_tx(
ctx: &ReducerContext,
input: BigFishMessageSubmitInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_message_submit_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&input.user_message_id)
.is_some()
{
return Err("big_fish_agent_message.user_message_id 已存在".to_string());
}
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&input.assistant_message_id)
.is_some()
{
return Err("big_fish_agent_message.assistant_message_id 已存在".to_string());
}
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: input.user_message_id,
session_id: input.session_id.clone(),
role: BigFishAgentMessageRole::User,
kind: BigFishAgentMessageKind::Chat,
text: input.user_message_text.trim().to_string(),
created_at: submitted_at,
});
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: BigFishCreationStage::CollectingAnchors,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready,
created_at: session.created_at,
updated_at: submitted_at,
};
replace_big_fish_session(ctx, &session, next_session);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn finalize_big_fish_agent_message_turn_tx(
ctx: &ReducerContext,
input: BigFishMessageFinalizeInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_message_finalize_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
if let Some(error_message) = input
.error_message
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: session.draft_json.clone(),
asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: session.last_assistant_reply.clone(),
publish_ready: session.publish_ready,
created_at: session.created_at,
updated_at,
};
replace_big_fish_session(ctx, &session, next_session);
return Err(error_message.to_string());
}
let assistant_message_id = input
.assistant_message_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "big_fish assistant_message_id 不能为空".to_string())?
.to_string();
let assistant_reply_text = input
.assistant_reply_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "big_fish assistant_reply_text 不能为空".to_string())?
.to_string();
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&assistant_message_id)
.is_some()
{
return Err("big_fish_agent_message.assistant_message_id 已存在".to_string());
}
let next_anchor_pack =
deserialize_anchor_pack(&input.anchor_pack_json).map_err(|error| error.to_string())?;
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: assistant_message_id,
session_id: input.session_id.clone(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::Chat,
text: assistant_reply_text.clone(),
created_at: updated_at,
});
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn.saturating_add(1),
progress_percent: input.progress_percent.min(100),
stage: input.stage,
anchor_pack_json: serialize_anchor_pack(&next_anchor_pack)
.map_err(|error| error.to_string())?,
draft_json: session.draft_json.clone(),
asset_coverage_json: session.asset_coverage_json.clone(),
last_assistant_reply: Some(assistant_reply_text),
publish_ready: session.publish_ready,
created_at: session.created_at,
updated_at,
};
replace_big_fish_session(ctx, &session, next_session);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn compile_big_fish_draft_tx(
ctx: &ReducerContext,
input: BigFishDraftCompileInput,
) -> Result<BigFishSessionSnapshot, String> {
validate_draft_compile_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
let anchor_pack =
deserialize_anchor_pack(&session.anchor_pack_json).map_err(|error| error.to_string())?;
let draft = compile_default_draft(&anchor_pack);
let asset_slots = list_big_fish_asset_slots(ctx, &session.session_id);
let coverage = build_asset_coverage(Some(&draft), &asset_slots);
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
let reply = "第一版玩法草稿已编译完成,可以在结果页逐级生成主图、动作和场地背景。".to_string();
let next_session = BigFishCreationSession {
session_id: session.session_id.clone(),
owner_user_id: session.owner_user_id.clone(),
seed_text: session.seed_text.clone(),
current_turn: session.current_turn,
progress_percent: 80,
stage: BigFishCreationStage::DraftReady,
anchor_pack_json: session.anchor_pack_json.clone(),
draft_json: Some(serialize_draft(&draft).map_err(|error| error.to_string())?),
asset_coverage_json: serialize_asset_coverage(&coverage)
.map_err(|error| error.to_string())?,
last_assistant_reply: Some(reply.clone()),
publish_ready: coverage.publish_ready,
created_at: session.created_at,
updated_at: compiled_at,
};
replace_big_fish_session(ctx, &session, next_session);
append_big_fish_system_message(
ctx,
&input.session_id,
format!("big-fish-message-compile-{}", input.compiled_at_micros),
reply,
input.compiled_at_micros,
);
get_big_fish_session_tx(
ctx,
BigFishSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn build_big_fish_session_snapshot(
ctx: &ReducerContext,
row: &BigFishCreationSession,
) -> Result<BigFishSessionSnapshot, String> {
let anchor_pack =
deserialize_anchor_pack(&row.anchor_pack_json).unwrap_or_else(|_| empty_anchor_pack());
let draft = row
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id);
let asset_coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
let mut messages = ctx
.db
.big_fish_agent_message()
.iter()
.filter(|message| message.session_id == row.session_id)
.map(|message| BigFishAgentMessageSnapshot {
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_key(|message| (message.created_at_micros, message.message_id.clone()));
Ok(BigFishSessionSnapshot {
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,
anchor_pack,
draft,
asset_slots,
asset_coverage,
messages,
last_assistant_reply: row.last_assistant_reply.clone(),
publish_ready: row.publish_ready,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn build_big_fish_work_summary(
ctx: &ReducerContext,
row: &BigFishCreationSession,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let asset_slots = list_big_fish_asset_slots(ctx, &row.session_id);
let coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
let cover_image_src = asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::StageBackground)
.and_then(|slot| slot.asset_url.clone())
.or_else(|| {
asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::LevelMainImage)
.and_then(|slot| slot.asset_url.clone())
});
let title = draft
.as_ref()
.map(|value| value.title.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "未命名大鱼草稿".to_string());
let subtitle = draft
.as_ref()
.map(|value| value.subtitle.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "等待整理玩法草稿".to_string());
let summary = draft
.as_ref()
.map(|value| value.core_fun.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
row.last_assistant_reply
.clone()
.unwrap_or_else(|| "继续补齐锚点后即可生成玩法草稿。".to_string())
});
Ok(BigFishWorkSummarySnapshot {
work_id: format!("big-fish-work-{}", row.session_id),
source_session_id: row.session_id.clone(),
title,
subtitle,
summary,
cover_image_src,
status: if row.stage == BigFishCreationStage::Published {
"published".to_string()
} else {
"draft".to_string()
},
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
publish_ready: coverage.publish_ready,
level_count: draft
.as_ref()
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT),
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
})
}
pub(crate) fn replace_big_fish_session(
ctx: &ReducerContext,
current: &BigFishCreationSession,
next: BigFishCreationSession,
) {
ctx.db
.big_fish_creation_session()
.session_id()
.delete(&current.session_id);
ctx.db.big_fish_creation_session().insert(next);
}
pub(crate) fn append_big_fish_system_message(
ctx: &ReducerContext,
session_id: &str,
message_id: String,
text: String,
created_at_micros: i64,
) {
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&message_id)
.is_some()
{
return;
}
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id,
session_id: session_id.to_string(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::ActionResult,
text,
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
});
}
#[cfg(test)]
mod tests {
use super::*;
fn build_test_big_fish_session(
seed_text: &str,
draft_json: Option<&str>,
stage: BigFishCreationStage,
) -> BigFishCreationSession {
BigFishCreationSession {
session_id: "big-fish-session-1".to_string(),
owner_user_id: "user-1".to_string(),
seed_text: seed_text.to_string(),
current_turn: 0,
progress_percent: 20,
stage,
anchor_pack_json: "{}".to_string(),
draft_json: draft_json.map(str::to_string),
asset_coverage_json: "{}".to_string(),
last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()),
publish_ready: false,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
}
}
#[test]
fn big_fish_direct_work_content_ignores_empty_created_session() {
let empty_session =
build_test_big_fish_session("", None, BigFishCreationStage::CollectingAnchors);
let seeded_session = build_test_big_fish_session(
"想做深海吞噬成长",
None,
BigFishCreationStage::CollectingAnchors,
);
let drafted_session = build_test_big_fish_session(
"",
Some(r#"{"title":"深海吞噬"}"#),
BigFishCreationStage::DraftReady,
);
let published_session =
build_test_big_fish_session("", None, BigFishCreationStage::Published);
assert!(!big_fish_session_has_direct_work_content(&empty_session));
assert!(big_fish_session_has_direct_work_content(&seeded_session));
assert!(big_fish_session_has_direct_work_content(&drafted_session));
assert!(big_fish_session_has_direct_work_content(&published_session));
}
}

View File

@@ -0,0 +1,72 @@
use crate::*;
#[spacetimedb::table(
accessor = big_fish_creation_session,
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct BigFishCreationSession {
#[primary_key]
pub(crate) session_id: String,
pub(crate) owner_user_id: String,
pub(crate) seed_text: String,
pub(crate) current_turn: u32,
pub(crate) progress_percent: u32,
pub(crate) stage: BigFishCreationStage,
pub(crate) anchor_pack_json: String,
pub(crate) draft_json: Option<String>,
pub(crate) asset_coverage_json: String,
pub(crate) last_assistant_reply: Option<String>,
pub(crate) publish_ready: bool,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_agent_message,
index(accessor = by_big_fish_message_session_id, btree(columns = [session_id]))
)]
pub struct BigFishAgentMessage {
#[primary_key]
pub(crate) message_id: String,
pub(crate) session_id: String,
pub(crate) role: BigFishAgentMessageRole,
pub(crate) kind: BigFishAgentMessageKind,
pub(crate) text: String,
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_asset_slot,
index(accessor = by_big_fish_asset_session_id, btree(columns = [session_id]))
)]
pub struct BigFishAssetSlot {
#[primary_key]
pub(crate) slot_id: String,
pub(crate) session_id: String,
pub(crate) asset_kind: BigFishAssetKind,
pub(crate) level: Option<u32>,
pub(crate) motion_key: Option<String>,
pub(crate) status: BigFishAssetStatus,
pub(crate) asset_url: Option<String>,
pub(crate) prompt_snapshot: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = big_fish_runtime_run,
index(accessor = by_big_fish_run_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_big_fish_run_session_id, btree(columns = [session_id]))
)]
pub struct BigFishRuntimeRun {
#[primary_key]
pub(crate) run_id: String,
pub(crate) session_id: String,
pub(crate) owner_user_id: String,
pub(crate) status: BigFishRunStatus,
pub(crate) snapshot_json: String,
pub(crate) last_input_x: f32,
pub(crate) last_input_y: f32,
pub(crate) tick: u64,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}