9
server-rs/Cargo.lock
generated
9
server-rs/Cargo.lock
generated
@@ -1562,6 +1562,15 @@ dependencies = [
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-match3d"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-npc"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -15,6 +15,7 @@ members = [
|
||||
"crates/module-combat",
|
||||
"crates/module-inventory",
|
||||
"crates/module-custom-world",
|
||||
"crates/module-match3d",
|
||||
"crates/module-npc",
|
||||
"crates/module-puzzle",
|
||||
"crates/module-progression",
|
||||
@@ -52,4 +53,4 @@ incremental = true
|
||||
[profile.release]
|
||||
opt-level = 3 # 最大优化等级
|
||||
lto = "thin" # 启用 Thin LTO,平衡编译时间和性能
|
||||
codegen-units = 1 # 减少并行代码生成单元,提升优化但增加编译时间
|
||||
codegen-units = 1 # 减少并行代码生成单元,提升优化但增加编译时间
|
||||
|
||||
14
server-rs/crates/module-match3d/Cargo.toml
Normal file
14
server-rs/crates/module-match3d/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-match3d"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
996
server-rs/crates/module-match3d/src/lib.rs
Normal file
996
server-rs/crates/module-match3d/src/lib.rs
Normal file
@@ -0,0 +1,996 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const MATCH3D_SESSION_ID_PREFIX: &str = "match3d-session-";
|
||||
pub const MATCH3D_MESSAGE_ID_PREFIX: &str = "match3d-message-";
|
||||
pub const MATCH3D_PROFILE_ID_PREFIX: &str = "match3d-profile-";
|
||||
pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-";
|
||||
pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-";
|
||||
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
|
||||
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
|
||||
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
|
||||
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
|
||||
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000;
|
||||
pub const MATCH3D_BOARD_RADIUS: f32 = 1.0;
|
||||
|
||||
const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [
|
||||
"red_circle",
|
||||
"yellow_triangle",
|
||||
"purple_diamond",
|
||||
"green_square",
|
||||
"blue_star",
|
||||
"orange_hexagon",
|
||||
"cyan_capsule",
|
||||
"pink_heart",
|
||||
"lime_leaf",
|
||||
"white_moon",
|
||||
];
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DCreationStage {
|
||||
CollectingConfig,
|
||||
DraftReady,
|
||||
ReadyToPublish,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DPublicationStatus {
|
||||
Draft,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DRunStatus {
|
||||
Running,
|
||||
Won,
|
||||
Failed,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DFailureReason {
|
||||
TimeUp,
|
||||
TrayFull,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DItemState {
|
||||
InBoard,
|
||||
InTray,
|
||||
Cleared,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Match3DClickRejectReason {
|
||||
RunNotActive,
|
||||
SnapshotVersionMismatch,
|
||||
ItemNotFound,
|
||||
ItemNotInBoard,
|
||||
ItemNotClickable,
|
||||
TrayFull,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DCreatorConfig {
|
||||
pub theme_text: String,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DResultDraft {
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DWorkProfile {
|
||||
pub work_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub source_session_id: Option<String>,
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publication_status: Match3DPublicationStatus,
|
||||
pub play_count: u32,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Match3DItemSnapshot {
|
||||
pub item_instance_id: String,
|
||||
pub item_type_id: String,
|
||||
pub visual_key: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub radius: f32,
|
||||
pub layer: u32,
|
||||
pub state: Match3DItemState,
|
||||
pub clickable: bool,
|
||||
pub tray_slot_index: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DTraySlot {
|
||||
pub slot_index: u32,
|
||||
pub item_instance_id: Option<String>,
|
||||
pub item_type_id: Option<String>,
|
||||
pub visual_key: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Match3DRunSnapshot {
|
||||
pub run_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub status: Match3DRunStatus,
|
||||
pub started_at_ms: u64,
|
||||
pub duration_limit_ms: u64,
|
||||
pub remaining_ms: u64,
|
||||
pub clear_count: u32,
|
||||
pub total_item_count: u32,
|
||||
pub cleared_item_count: u32,
|
||||
pub board_version: u64,
|
||||
pub items: Vec<Match3DItemSnapshot>,
|
||||
pub tray_slots: Vec<Match3DTraySlot>,
|
||||
pub failure_reason: Option<Match3DFailureReason>,
|
||||
pub last_confirmed_action_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Match3DClickInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub item_instance_id: String,
|
||||
pub client_action_id: String,
|
||||
pub snapshot_version: u64,
|
||||
pub clicked_at_ms: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Match3DClickConfirmation {
|
||||
pub accepted: bool,
|
||||
pub reject_reason: Option<Match3DClickRejectReason>,
|
||||
pub entered_slot_index: Option<u32>,
|
||||
pub cleared_item_instance_ids: Vec<String>,
|
||||
pub run: Match3DRunSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Match3DFieldError {
|
||||
MissingText,
|
||||
MissingOwnerUserId,
|
||||
MissingProfileId,
|
||||
MissingRunId,
|
||||
MissingItemId,
|
||||
InvalidClearCount,
|
||||
InvalidDifficulty,
|
||||
}
|
||||
|
||||
impl fmt::Display for Match3DFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingText => write!(f, "必填文本缺失"),
|
||||
Self::MissingOwnerUserId => write!(f, "owner_user_id 缺失"),
|
||||
Self::MissingProfileId => write!(f, "profile_id 缺失"),
|
||||
Self::MissingRunId => write!(f, "run_id 缺失"),
|
||||
Self::MissingItemId => write!(f, "item_instance_id 缺失"),
|
||||
Self::InvalidClearCount => write!(f, "需要消除次数必须为正整数"),
|
||||
Self::InvalidDifficulty => write!(f, "难度必须在 1 到 10 之间"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for Match3DFieldError {}
|
||||
|
||||
impl Match3DCreationStage {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CollectingConfig => "collecting_config",
|
||||
Self::DraftReady => "draft_ready",
|
||||
Self::ReadyToPublish => "ready_to_publish",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DPublicationStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Draft => "draft",
|
||||
Self::Published => "published",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DRunStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Won => "won",
|
||||
Self::Failed => "failed",
|
||||
Self::Stopped => "stopped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DFailureReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::TimeUp => "time_up",
|
||||
Self::TrayFull => "tray_full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DItemState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InBoard => "in_board",
|
||||
Self::InTray => "in_tray",
|
||||
Self::Cleared => "cleared",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Match3DClickRejectReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::RunNotActive => "run_not_active",
|
||||
Self::SnapshotVersionMismatch => "snapshot_version_mismatch",
|
||||
Self::ItemNotFound => "item_not_found",
|
||||
Self::ItemNotInBoard => "item_not_in_board",
|
||||
Self::ItemNotClickable => "item_not_clickable",
|
||||
Self::TrayFull => "tray_full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_creator_config(
|
||||
theme_text: &str,
|
||||
reference_image_src: Option<String>,
|
||||
clear_count: u32,
|
||||
difficulty: u32,
|
||||
) -> Result<Match3DCreatorConfig, Match3DFieldError> {
|
||||
let theme_text = normalize_required_string(theme_text).ok_or(Match3DFieldError::MissingText)?;
|
||||
if clear_count == 0 {
|
||||
return Err(Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&difficulty) {
|
||||
return Err(Match3DFieldError::InvalidDifficulty);
|
||||
}
|
||||
|
||||
Ok(Match3DCreatorConfig {
|
||||
theme_text,
|
||||
reference_image_src: normalize_optional_string(reference_image_src),
|
||||
clear_count,
|
||||
difficulty,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
|
||||
let game_name = format!("{}抓大鹅", config.theme_text);
|
||||
let summary = format!(
|
||||
"{}主题,{} 次消除目标,难度 {}。",
|
||||
config.theme_text, config.clear_count, config.difficulty
|
||||
);
|
||||
let tags = default_tags_for_theme(&config.theme_text);
|
||||
let blockers = validate_basic_publish_fields(&game_name, &summary, &tags);
|
||||
|
||||
Match3DResultDraft {
|
||||
game_name,
|
||||
theme_text: config.theme_text.clone(),
|
||||
summary,
|
||||
tags,
|
||||
cover_image_src: None,
|
||||
reference_image_src: config.reference_image_src.clone(),
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
publish_ready: blockers.is_empty(),
|
||||
blockers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec<String> {
|
||||
let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags);
|
||||
if draft.clear_count == 0 {
|
||||
blockers.push("需要消除次数必须为正整数".to_string());
|
||||
}
|
||||
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&draft.difficulty) {
|
||||
blockers.push("难度必须在 1 到 10 之间".to_string());
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
pub fn create_work_profile(
|
||||
work_id: String,
|
||||
profile_id: String,
|
||||
owner_user_id: String,
|
||||
source_session_id: Option<String>,
|
||||
draft: &Match3DResultDraft,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<Match3DWorkProfile, Match3DFieldError> {
|
||||
let work_id = normalize_required_string(work_id).ok_or(Match3DFieldError::MissingText)?;
|
||||
let profile_id =
|
||||
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||
let owner_user_id =
|
||||
normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?;
|
||||
|
||||
Ok(Match3DWorkProfile {
|
||||
work_id,
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
source_session_id: normalize_optional_string(source_session_id),
|
||||
game_name: draft.game_name.clone(),
|
||||
theme_text: draft.theme_text.clone(),
|
||||
summary: draft.summary.clone(),
|
||||
tags: normalize_string_list(draft.tags.clone()),
|
||||
cover_image_src: draft.cover_image_src.clone(),
|
||||
reference_image_src: draft.reference_image_src.clone(),
|
||||
clear_count: draft.clear_count,
|
||||
difficulty: draft.difficulty,
|
||||
publication_status: Match3DPublicationStatus::Draft,
|
||||
play_count: 0,
|
||||
updated_at_micros,
|
||||
published_at_micros: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn publish_work_profile(
|
||||
profile: &Match3DWorkProfile,
|
||||
published_at_micros: i64,
|
||||
) -> Result<Match3DWorkProfile, Match3DFieldError> {
|
||||
if profile.clear_count == 0 {
|
||||
return Err(Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&profile.difficulty) {
|
||||
return Err(Match3DFieldError::InvalidDifficulty);
|
||||
}
|
||||
|
||||
let mut next = profile.clone();
|
||||
next.publication_status = Match3DPublicationStatus::Published;
|
||||
next.updated_at_micros = published_at_micros;
|
||||
next.published_at_micros = Some(published_at_micros);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn start_run_with_seed_at(
|
||||
run_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
config: &Match3DCreatorConfig,
|
||||
seed: u64,
|
||||
started_at_ms: u64,
|
||||
) -> Result<Match3DRunSnapshot, Match3DFieldError> {
|
||||
let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?;
|
||||
let owner_user_id =
|
||||
normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?;
|
||||
let profile_id =
|
||||
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||
|
||||
let total_item_count = config
|
||||
.clear_count
|
||||
.checked_mul(MATCH3D_ITEMS_PER_CLEAR)
|
||||
.ok_or(Match3DFieldError::InvalidClearCount)?;
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id,
|
||||
profile_id,
|
||||
owner_user_id,
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: config.clear_count,
|
||||
total_item_count,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: build_initial_items(config.clear_count, config.difficulty, seed),
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
refresh_clickable_flags(&mut run);
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
pub fn confirm_click_at(
|
||||
run: &Match3DRunSnapshot,
|
||||
input: &Match3DClickInput,
|
||||
) -> Result<Match3DClickConfirmation, Match3DFieldError> {
|
||||
let item_instance_id = normalize_required_string(&input.item_instance_id)
|
||||
.ok_or(Match3DFieldError::MissingItemId)?;
|
||||
let client_action_id = normalize_required_string(&input.client_action_id)
|
||||
.unwrap_or_else(|| "match3d-action-unknown".to_string());
|
||||
|
||||
let mut next = resolve_run_timer_at(run, input.clicked_at_ms);
|
||||
if next.status != Match3DRunStatus::Running {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::RunNotActive));
|
||||
}
|
||||
if input.snapshot_version != next.board_version {
|
||||
return Ok(rejected(
|
||||
next,
|
||||
Match3DClickRejectReason::SnapshotVersionMismatch,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(item_index) = next
|
||||
.items
|
||||
.iter()
|
||||
.position(|item| item.item_instance_id == item_instance_id)
|
||||
else {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotFound));
|
||||
};
|
||||
|
||||
if next.items[item_index].state != Match3DItemState::InBoard {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotInBoard));
|
||||
}
|
||||
if !next.items[item_index].clickable {
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
|
||||
}
|
||||
|
||||
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
|
||||
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
|
||||
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
|
||||
};
|
||||
|
||||
let item_type_id = next.items[item_index].item_type_id.clone();
|
||||
next.items[item_index].state = Match3DItemState::InTray;
|
||||
next.items[item_index].clickable = false;
|
||||
next.items[item_index].tray_slot_index = Some(slot_index);
|
||||
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
|
||||
|
||||
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
|
||||
compact_tray(&mut next);
|
||||
next.cleared_item_count = next
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.state == Match3DItemState::Cleared)
|
||||
.count() as u32;
|
||||
|
||||
if next.cleared_item_count >= next.total_item_count {
|
||||
next.status = Match3DRunStatus::Won;
|
||||
} else if first_empty_slot_index(&next.tray_slots).is_none() {
|
||||
next.status = Match3DRunStatus::Failed;
|
||||
next.failure_reason = Some(Match3DFailureReason::TrayFull);
|
||||
}
|
||||
|
||||
refresh_clickable_flags(&mut next);
|
||||
next.board_version += 1;
|
||||
next.last_confirmed_action_id = Some(client_action_id);
|
||||
|
||||
Ok(Match3DClickConfirmation {
|
||||
accepted: true,
|
||||
reject_reason: None,
|
||||
entered_slot_index: Some(slot_index),
|
||||
cleared_item_instance_ids,
|
||||
run: next,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot {
|
||||
let mut next = run.clone();
|
||||
if next.status != Match3DRunStatus::Running {
|
||||
return next;
|
||||
}
|
||||
let elapsed_ms = now_ms.saturating_sub(next.started_at_ms);
|
||||
next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms);
|
||||
if next.remaining_ms == 0 {
|
||||
next.status = Match3DRunStatus::Failed;
|
||||
next.failure_reason = Some(Match3DFailureReason::TimeUp);
|
||||
next.board_version += 1;
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot {
|
||||
let mut next = run.clone();
|
||||
if next.status == Match3DRunStatus::Running {
|
||||
next.status = Match3DRunStatus::Stopped;
|
||||
next.board_version += 1;
|
||||
next.last_confirmed_action_id = normalize_required_string(stopped_action_id);
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) {
|
||||
let board_items = run
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.state == Match3DItemState::InBoard)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for item in &mut run.items {
|
||||
if item.state != Match3DItemState::InBoard {
|
||||
item.clickable = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
item.clickable = !board_items.iter().any(|cover| {
|
||||
cover.layer > item.layer
|
||||
&& fully_covers(cover.x, cover.y, cover.radius, item.x, item.y, item.radius)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec<Match3DItemSnapshot> {
|
||||
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||||
let radius = resolve_item_radius(difficulty);
|
||||
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
|
||||
|
||||
for clear_index in 0..clear_count {
|
||||
let visual_index = (clear_index as usize) % MATCH3D_DEMO_VISUAL_KEYS.len();
|
||||
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||||
let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string();
|
||||
|
||||
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||||
let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius);
|
||||
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
|
||||
items.push(Match3DItemSnapshot {
|
||||
item_instance_id: format!("match3d-item-{instance_index:04}"),
|
||||
item_type_id: item_type_id.clone(),
|
||||
visual_key: visual_key.clone(),
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
layer: instance_index,
|
||||
state: Match3DItemState::InBoard,
|
||||
clickable: true,
|
||||
tray_slot_index: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 洗牌只改变层级顺序,不改变每组三个的可通关性。
|
||||
for index in (1..items.len()).rev() {
|
||||
let swap_index = (rng.next_u32() as usize) % (index + 1);
|
||||
items.swap(index, swap_index);
|
||||
}
|
||||
for (layer, item) in items.iter_mut().enumerate() {
|
||||
item.layer = layer as u32;
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||
let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
|
||||
let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055;
|
||||
radius.max(0.052)
|
||||
}
|
||||
|
||||
fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) {
|
||||
for _ in 0..24 {
|
||||
let x = rng.next_unit_signed() * max_radius;
|
||||
let y = rng.next_unit_signed() * max_radius;
|
||||
if x * x + y * y <= max_radius * max_radius {
|
||||
return (x, y);
|
||||
}
|
||||
}
|
||||
(0.0, 0.0)
|
||||
}
|
||||
|
||||
fn fully_covers(
|
||||
cover_x: f32,
|
||||
cover_y: f32,
|
||||
cover_radius: f32,
|
||||
item_x: f32,
|
||||
item_y: f32,
|
||||
item_radius: f32,
|
||||
) -> bool {
|
||||
let dx = cover_x - item_x;
|
||||
let dy = cover_y - item_y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
distance + item_radius <= cover_radius * 0.96
|
||||
}
|
||||
|
||||
fn empty_tray_slots() -> Vec<Match3DTraySlot> {
|
||||
(0..MATCH3D_TRAY_SLOT_COUNT)
|
||||
.map(|slot_index| Match3DTraySlot {
|
||||
slot_index,
|
||||
item_instance_id: None,
|
||||
item_type_id: None,
|
||||
visual_key: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
|
||||
slots
|
||||
.iter()
|
||||
.find(|slot| slot.item_instance_id.is_none())
|
||||
.map(|slot| slot.slot_index)
|
||||
}
|
||||
|
||||
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
|
||||
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
|
||||
slot.item_instance_id = Some(item.item_instance_id.clone());
|
||||
slot.item_type_id = Some(item.item_type_id.clone());
|
||||
slot.visual_key = Some(item.visual_key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
|
||||
let matched_slot_item_ids = run
|
||||
.tray_slots
|
||||
.iter()
|
||||
.filter(|slot| slot.item_type_id.as_deref() == Some(item_type_id))
|
||||
.filter_map(|slot| slot.item_instance_id.clone())
|
||||
.take(MATCH3D_ITEMS_PER_CLEAR as usize)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if matched_slot_item_ids.len() < MATCH3D_ITEMS_PER_CLEAR as usize {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
for item in &mut run.items {
|
||||
if matched_slot_item_ids.contains(&item.item_instance_id) {
|
||||
item.state = Match3DItemState::Cleared;
|
||||
item.clickable = false;
|
||||
item.tray_slot_index = None;
|
||||
}
|
||||
}
|
||||
for slot in &mut run.tray_slots {
|
||||
if slot
|
||||
.item_instance_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| matched_slot_item_ids.contains(id))
|
||||
{
|
||||
slot.item_instance_id = None;
|
||||
slot.item_type_id = None;
|
||||
slot.visual_key = None;
|
||||
}
|
||||
}
|
||||
|
||||
matched_slot_item_ids
|
||||
}
|
||||
|
||||
fn compact_tray(run: &mut Match3DRunSnapshot) {
|
||||
let mut occupied = run
|
||||
.tray_slots
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
Some((
|
||||
slot.item_instance_id.clone()?,
|
||||
slot.item_type_id.clone()?,
|
||||
slot.visual_key.clone()?,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for slot in &mut run.tray_slots {
|
||||
slot.item_instance_id = None;
|
||||
slot.item_type_id = None;
|
||||
slot.visual_key = None;
|
||||
}
|
||||
|
||||
for (slot_index, (item_instance_id, item_type_id, visual_key)) in occupied.drain(..).enumerate()
|
||||
{
|
||||
let slot_index = slot_index as u32;
|
||||
if let Some(slot) = run
|
||||
.tray_slots
|
||||
.iter_mut()
|
||||
.find(|slot| slot.slot_index == slot_index)
|
||||
{
|
||||
slot.item_instance_id = Some(item_instance_id.clone());
|
||||
slot.item_type_id = Some(item_type_id);
|
||||
slot.visual_key = Some(visual_key);
|
||||
}
|
||||
if let Some(item) = run
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|item| item.item_instance_id == item_instance_id)
|
||||
{
|
||||
item.tray_slot_index = Some(slot_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_run(
|
||||
mut run: Match3DRunSnapshot,
|
||||
reason: Match3DFailureReason,
|
||||
action_id: String,
|
||||
) -> Match3DRunSnapshot {
|
||||
run.status = Match3DRunStatus::Failed;
|
||||
run.failure_reason = Some(reason);
|
||||
run.board_version += 1;
|
||||
run.last_confirmed_action_id = Some(action_id);
|
||||
run
|
||||
}
|
||||
|
||||
fn rejected(
|
||||
run: Match3DRunSnapshot,
|
||||
reject_reason: Match3DClickRejectReason,
|
||||
) -> Match3DClickConfirmation {
|
||||
Match3DClickConfirmation {
|
||||
accepted: false,
|
||||
reject_reason: Some(reject_reason),
|
||||
entered_slot_index: None,
|
||||
cleared_item_instance_ids: Vec::new(),
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_basic_publish_fields(game_name: &str, summary: &str, tags: &[String]) -> Vec<String> {
|
||||
let mut blockers = Vec::new();
|
||||
if normalize_required_string(game_name).is_none() {
|
||||
blockers.push("游戏名称不能为空".to_string());
|
||||
}
|
||||
if normalize_required_string(summary).is_none() {
|
||||
blockers.push("简介不能为空".to_string());
|
||||
}
|
||||
let normalized_tags = normalize_string_list(tags.to_vec());
|
||||
if normalized_tags.is_empty() {
|
||||
blockers.push("至少需要 1 个标签".to_string());
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
fn default_tags_for_theme(theme_text: &str) -> Vec<String> {
|
||||
let mut tags = vec![
|
||||
"抓大鹅".to_string(),
|
||||
"经典消除".to_string(),
|
||||
theme_text.to_string(),
|
||||
];
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
|
||||
struct DeterministicRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl DeterministicRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed.max(1) }
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
let mut value = self.state;
|
||||
value ^= value << 13;
|
||||
value ^= value >> 7;
|
||||
value ^= value << 17;
|
||||
self.state = value;
|
||||
(value >> 32) as u32
|
||||
}
|
||||
|
||||
fn next_unit_signed(&mut self) -> f32 {
|
||||
let value = self.next_u32() as f32 / u32::MAX as f32;
|
||||
value * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_config(clear_count: u32) -> Match3DCreatorConfig {
|
||||
build_creator_config("水果", None, clear_count, 4).expect("config should be valid")
|
||||
}
|
||||
|
||||
fn manual_item(id: &str, type_id: &str, slot: Option<u32>) -> Match3DItemSnapshot {
|
||||
Match3DItemSnapshot {
|
||||
item_instance_id: id.to_string(),
|
||||
item_type_id: type_id.to_string(),
|
||||
visual_key: type_id.to_string(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
radius: 0.08,
|
||||
layer: 0,
|
||||
state: if slot.is_some() {
|
||||
Match3DItemState::InTray
|
||||
} else {
|
||||
Match3DItemState::InBoard
|
||||
},
|
||||
clickable: slot.is_none(),
|
||||
tray_slot_index: slot,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creator_config_requires_positive_clear_count() {
|
||||
let error = build_creator_config("水果", None, 0, 3).expect_err("zero should fail");
|
||||
assert_eq!(error, Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_run_generates_triples() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(12),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
assert_eq!(run.total_item_count, 36);
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||
}
|
||||
assert!(counts.values().all(|count| count % 3 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_three_same_items_clears_and_wins() {
|
||||
let mut run = start_run_with_seed_at(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(1),
|
||||
7,
|
||||
10_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
for item in &mut run.items {
|
||||
item.clickable = true;
|
||||
}
|
||||
|
||||
let ids = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.item_instance_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, item_id) in ids.iter().enumerate() {
|
||||
let input = Match3DClickInput {
|
||||
run_id: run.run_id.clone(),
|
||||
owner_user_id: run.owner_user_id.clone(),
|
||||
item_instance_id: item_id.clone(),
|
||||
client_action_id: format!("action-{index}"),
|
||||
snapshot_version: run.board_version,
|
||||
clicked_at_ms: 11_000 + index as u64,
|
||||
};
|
||||
run = confirm_click_at(&run, &input)
|
||||
.expect("click should confirm")
|
||||
.run;
|
||||
}
|
||||
|
||||
assert_eq!(run.status, Match3DRunStatus::Won);
|
||||
assert_eq!(run.cleared_item_count, 3);
|
||||
assert!(
|
||||
run.tray_slots
|
||||
.iter()
|
||||
.all(|slot| slot.item_instance_id.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_full_fails_when_no_triple_can_clear() {
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id: "run-full".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms: 0,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: 3,
|
||||
total_item_count: 9,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: (0..8)
|
||||
.map(|index| manual_item(&format!("item-{index}"), &format!("type-{index}"), None))
|
||||
.collect(),
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
|
||||
for index in 0..7 {
|
||||
let input = Match3DClickInput {
|
||||
run_id: run.run_id.clone(),
|
||||
owner_user_id: run.owner_user_id.clone(),
|
||||
item_instance_id: format!("item-{index}"),
|
||||
client_action_id: format!("action-{index}"),
|
||||
snapshot_version: run.board_version,
|
||||
clicked_at_ms: 1_000 + index,
|
||||
};
|
||||
run = confirm_click_at(&run, &input)
|
||||
.expect("click should confirm")
|
||||
.run;
|
||||
}
|
||||
|
||||
assert_eq!(run.status, Match3DRunStatus::Failed);
|
||||
assert_eq!(run.failure_reason, Some(Match3DFailureReason::TrayFull));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_expiration_fails_running_run() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-1".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(2),
|
||||
9,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let expired = resolve_run_timer_at(&run, 1_000 + MATCH3D_DEFAULT_DURATION_LIMIT_MS);
|
||||
|
||||
assert_eq!(expired.status, Match3DRunStatus::Failed);
|
||||
assert_eq!(expired.failure_reason, Some(Match3DFailureReason::TimeUp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_covered_item_is_not_clickable() {
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id: "run-cover".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms: 0,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: 1,
|
||||
total_item_count: 2,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: vec![
|
||||
Match3DItemSnapshot {
|
||||
layer: 0,
|
||||
radius: 0.04,
|
||||
..manual_item("bottom", "type-a", None)
|
||||
},
|
||||
Match3DItemSnapshot {
|
||||
layer: 1,
|
||||
radius: 0.08,
|
||||
..manual_item("top", "type-b", None)
|
||||
},
|
||||
],
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
|
||||
refresh_clickable_flags(&mut run);
|
||||
|
||||
let bottom = run
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.item_instance_id == "bottom")
|
||||
.expect("bottom item should exist");
|
||||
assert!(!bottom.clickable);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ pub mod big_fish;
|
||||
pub mod big_fish_works;
|
||||
pub mod creation_agent_document_input;
|
||||
pub mod llm;
|
||||
pub mod match3d_agent;
|
||||
pub mod match3d_runtime;
|
||||
pub mod match3d_works;
|
||||
pub mod puzzle_agent;
|
||||
pub mod puzzle_gallery;
|
||||
pub mod puzzle_runtime;
|
||||
|
||||
137
server-rs/crates/shared-contracts/src/match3d_agent.rs
Normal file
137
server-rs/crates/shared-contracts/src/match3d_agent.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateMatch3DAgentSessionRequest {
|
||||
#[serde(default)]
|
||||
pub seed_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SendMatch3DAgentMessageRequest {
|
||||
pub client_message_id: String,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub quick_fill_requested: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteMatch3DAgentActionRequest {
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub game_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DCreatorConfigResponse {
|
||||
pub theme_text: String,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DResultDraftResponse {
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DAgentMessageResponse {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DAgentSessionSnapshotResponse {
|
||||
pub session_id: String,
|
||||
pub current_turn: u32,
|
||||
pub progress_percent: u32,
|
||||
pub stage: String,
|
||||
#[serde(default)]
|
||||
pub config: Option<Match3DCreatorConfigResponse>,
|
||||
#[serde(default)]
|
||||
pub draft: Option<Match3DResultDraftResponse>,
|
||||
pub messages: Vec<Match3DAgentMessageResponse>,
|
||||
#[serde(default)]
|
||||
pub last_assistant_reply: Option<String>,
|
||||
#[serde(default)]
|
||||
pub published_profile_id: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DAgentSessionResponse {
|
||||
pub session: Match3DAgentSessionSnapshotResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DAgentActionResponse {
|
||||
pub session: Match3DAgentSessionSnapshotResponse,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn create_match3d_session_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(CreateMatch3DAgentSessionRequest {
|
||||
seed_text: Some("水果消除".to_string()),
|
||||
theme_text: Some("水果".to_string()),
|
||||
reference_image_src: Some("data:image/png;base64,abc".to_string()),
|
||||
clear_count: Some(4),
|
||||
difficulty: Some(3),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["seedText"], json!("水果消除"));
|
||||
assert_eq!(payload["themeText"], json!("水果"));
|
||||
assert_eq!(
|
||||
payload["referenceImageSrc"],
|
||||
json!("data:image/png;base64,abc")
|
||||
);
|
||||
assert_eq!(payload["clearCount"], json!(4));
|
||||
}
|
||||
}
|
||||
115
server-rs/crates/shared-contracts/src/match3d_runtime.rs
Normal file
115
server-rs/crates/shared-contracts/src/match3d_runtime.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartMatch3DRunRequest {
|
||||
pub profile_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClickMatch3DItemRequest {
|
||||
pub item_instance_id: String,
|
||||
pub client_action_id: String,
|
||||
pub snapshot_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StopMatch3DRunRequest {
|
||||
pub client_action_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DItemSnapshotResponse {
|
||||
pub item_instance_id: String,
|
||||
pub item_type_id: String,
|
||||
pub visual_key: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub radius: f32,
|
||||
pub layer: u32,
|
||||
pub state: String,
|
||||
pub clickable: bool,
|
||||
#[serde(default)]
|
||||
pub tray_slot_index: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DTraySlotResponse {
|
||||
pub slot_index: u32,
|
||||
#[serde(default)]
|
||||
pub item_instance_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub item_type_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DRunSnapshotResponse {
|
||||
pub run_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub status: String,
|
||||
pub started_at_ms: u64,
|
||||
pub duration_limit_ms: u64,
|
||||
pub remaining_ms: u64,
|
||||
pub clear_count: u32,
|
||||
pub total_item_count: u32,
|
||||
pub cleared_item_count: u32,
|
||||
pub board_version: u64,
|
||||
pub items: Vec<Match3DItemSnapshotResponse>,
|
||||
pub tray_slots: Vec<Match3DTraySlotResponse>,
|
||||
#[serde(default)]
|
||||
pub failure_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_confirmed_action_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DClickConfirmationResponse {
|
||||
pub accepted: bool,
|
||||
#[serde(default)]
|
||||
pub reject_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entered_slot_index: Option<u32>,
|
||||
pub cleared_item_instance_ids: Vec<String>,
|
||||
pub run: Match3DRunSnapshotResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DRunResponse {
|
||||
pub run: Match3DRunSnapshotResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DClickResponse {
|
||||
pub confirmation: Match3DClickConfirmationResponse,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn click_match3d_item_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(ClickMatch3DItemRequest {
|
||||
item_instance_id: "item-1".to_string(),
|
||||
client_action_id: "action-1".to_string(),
|
||||
snapshot_version: 7,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["itemInstanceId"], json!("item-1"));
|
||||
assert_eq!(payload["clientActionId"], json!("action-1"));
|
||||
assert_eq!(payload["snapshotVersion"], json!(7));
|
||||
}
|
||||
}
|
||||
89
server-rs/crates/shared-contracts/src/match3d_works.rs
Normal file
89
server-rs/crates/shared-contracts/src/match3d_works.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PutMatch3DWorkRequest {
|
||||
pub game_name: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorkSummaryResponse {
|
||||
pub work_id: String,
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
#[serde(default)]
|
||||
pub source_session_id: Option<String>,
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publication_status: String,
|
||||
pub play_count: u32,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub published_at: Option<String>,
|
||||
pub publish_ready: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorkProfileResponse {
|
||||
#[serde(flatten)]
|
||||
pub summary: Match3DWorkSummaryResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorksResponse {
|
||||
pub items: Vec<Match3DWorkSummaryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorkDetailResponse {
|
||||
pub item: Match3DWorkProfileResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorkMutationResponse {
|
||||
pub item: Match3DWorkProfileResponse,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn match3d_work_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(PutMatch3DWorkRequest {
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
summary: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string()],
|
||||
cover_image_src: None,
|
||||
reference_image_src: None,
|
||||
clear_count: 4,
|
||||
difficulty: 5,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["gameName"], json!("水果抓大鹅"));
|
||||
assert_eq!(payload["clearCount"], json!(4));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ module-big-fish = { path = "../module-big-fish", default-features = false, featu
|
||||
module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] }
|
||||
module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] }
|
||||
module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] }
|
||||
module-match3d = { path = "../module-match3d", default-features = false }
|
||||
module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] }
|
||||
module-puzzle = { path = "../module-puzzle", default-features = false, features = ["spacetime-types"] }
|
||||
module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] }
|
||||
|
||||
@@ -31,6 +31,7 @@ mod auth;
|
||||
mod big_fish;
|
||||
mod domain_types;
|
||||
mod entry;
|
||||
mod match3d;
|
||||
mod migration;
|
||||
mod puzzle;
|
||||
mod runtime;
|
||||
@@ -41,6 +42,7 @@ pub use auth::*;
|
||||
pub use big_fish::*;
|
||||
pub use domain_types::*;
|
||||
pub use entry::*;
|
||||
pub use match3d::*;
|
||||
pub use migration::*;
|
||||
pub use runtime::*;
|
||||
|
||||
@@ -2856,7 +2858,9 @@ fn list_custom_world_profile_snapshots(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
|
||||
fn build_custom_world_profile_list_snapshot(
|
||||
row: &CustomWorldProfile,
|
||||
) -> CustomWorldProfileSnapshot {
|
||||
let mut snapshot = build_custom_world_profile_snapshot(row);
|
||||
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
|
||||
snapshot
|
||||
|
||||
1642
server-rs/crates/spacetime-module/src/match3d/mod.rs
Normal file
1642
server-rs/crates/spacetime-module/src/match3d/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
86
server-rs/crates/spacetime-module/src/match3d/tables.rs
Normal file
86
server-rs/crates/spacetime-module/src/match3d/tables.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::*;
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = match3d_agent_session,
|
||||
index(accessor = by_match3d_agent_session_owner_user_id, btree(columns = [owner_user_id]))
|
||||
)]
|
||||
pub struct Match3DAgentSessionRow {
|
||||
#[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: String,
|
||||
pub(crate) config_json: String,
|
||||
pub(crate) draft_json: String,
|
||||
pub(crate) last_assistant_reply: String,
|
||||
pub(crate) published_profile_id: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = match3d_agent_message,
|
||||
index(accessor = by_match3d_agent_message_session_id, btree(columns = [session_id]))
|
||||
)]
|
||||
pub struct Match3DAgentMessageRow {
|
||||
#[primary_key]
|
||||
pub(crate) message_id: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) role: String,
|
||||
pub(crate) kind: String,
|
||||
pub(crate) text: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = match3d_work_profile,
|
||||
index(accessor = by_match3d_work_owner_user_id, btree(columns = [owner_user_id])),
|
||||
index(accessor = by_match3d_work_publication_status, btree(columns = [publication_status]))
|
||||
)]
|
||||
pub struct Match3DWorkProfileRow {
|
||||
#[primary_key]
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) source_session_id: String,
|
||||
pub(crate) author_display_name: String,
|
||||
pub(crate) game_name: String,
|
||||
pub(crate) theme_text: String,
|
||||
pub(crate) summary_text: String,
|
||||
pub(crate) tags_json: String,
|
||||
pub(crate) cover_image_src: String,
|
||||
pub(crate) cover_asset_id: String,
|
||||
pub(crate) clear_count: u32,
|
||||
pub(crate) difficulty: u32,
|
||||
pub(crate) config_json: String,
|
||||
pub(crate) publication_status: String,
|
||||
pub(crate) play_count: u32,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
pub(crate) published_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = match3d_runtime_run,
|
||||
index(accessor = by_match3d_run_owner_user_id, btree(columns = [owner_user_id])),
|
||||
index(accessor = by_match3d_run_profile_id, btree(columns = [profile_id]))
|
||||
)]
|
||||
pub struct Match3DRuntimeRunRow {
|
||||
#[primary_key]
|
||||
pub(crate) run_id: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) snapshot_version: u32,
|
||||
pub(crate) started_at_ms: i64,
|
||||
pub(crate) duration_limit_ms: i64,
|
||||
pub(crate) finished_at_ms: i64,
|
||||
pub(crate) elapsed_ms: i64,
|
||||
pub(crate) clear_count: u32,
|
||||
pub(crate) total_item_count: u32,
|
||||
pub(crate) cleared_item_count: u32,
|
||||
pub(crate) failure_reason: String,
|
||||
pub(crate) snapshot_json: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
332
server-rs/crates/spacetime-module/src/match3d/types.rs
Normal file
332
server-rs/crates/spacetime-module/src/match3d/types.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: i64 = 600_000;
|
||||
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
|
||||
pub const MATCH3D_VISUAL_VARIANT_COUNT: u32 = 10;
|
||||
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
|
||||
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
|
||||
|
||||
pub const MATCH3D_STAGE_COLLECTING: &str = "Collecting";
|
||||
pub const MATCH3D_STAGE_READY_TO_COMPILE: &str = "ReadyToCompile";
|
||||
pub const MATCH3D_STAGE_DRAFT_COMPILED: &str = "DraftCompiled";
|
||||
pub const MATCH3D_STAGE_PUBLISHED: &str = "Published";
|
||||
|
||||
pub const MATCH3D_ROLE_USER: &str = "user";
|
||||
pub const MATCH3D_ROLE_ASSISTANT: &str = "assistant";
|
||||
pub const MATCH3D_KIND_TEXT: &str = "text";
|
||||
|
||||
pub const MATCH3D_PUBLICATION_DRAFT: &str = "Draft";
|
||||
pub const MATCH3D_PUBLICATION_PUBLISHED: &str = "Published";
|
||||
|
||||
pub const MATCH3D_RUN_RUNNING: &str = "Running";
|
||||
pub const MATCH3D_RUN_WON: &str = "Won";
|
||||
pub const MATCH3D_RUN_FAILED: &str = "Failed";
|
||||
pub const MATCH3D_RUN_STOPPED: &str = "Stopped";
|
||||
|
||||
pub const MATCH3D_FAILURE_TIME_UP: &str = "TimeUp";
|
||||
pub const MATCH3D_FAILURE_TRAY_FULL: &str = "TrayFull";
|
||||
|
||||
pub const MATCH3D_CLICK_ACCEPTED: &str = "Accepted";
|
||||
pub const MATCH3D_CLICK_REJECTED_NOT_CLICKABLE: &str = "RejectedNotClickable";
|
||||
pub const MATCH3D_CLICK_REJECTED_ALREADY_MOVED: &str = "RejectedAlreadyMoved";
|
||||
pub const MATCH3D_CLICK_REJECTED_TRAY_FULL: &str = "RejectedTrayFull";
|
||||
pub const MATCH3D_CLICK_VERSION_CONFLICT: &str = "VersionConflict";
|
||||
pub const MATCH3D_CLICK_RUN_FINISHED: &str = "RunFinished";
|
||||
|
||||
pub const MATCH3D_ITEM_IN_BOARD: &str = "InBoard";
|
||||
pub const MATCH3D_ITEM_IN_TRAY: &str = "InTray";
|
||||
pub const MATCH3D_ITEM_CLEARED: &str = "Cleared";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DAgentSessionCreateInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub welcome_message_id: String,
|
||||
pub welcome_message_text: String,
|
||||
pub config_json: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DAgentSessionGetInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DAgentMessageSubmitInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub user_message_id: String,
|
||||
pub user_message_text: String,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DAgentMessageFinalizeInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub assistant_message_id: Option<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub config_json: Option<String>,
|
||||
pub progress_percent: u32,
|
||||
pub stage: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DDraftCompileInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub author_display_name: String,
|
||||
pub game_name: Option<String>,
|
||||
pub summary_text: Option<String>,
|
||||
pub tags_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
pub compiled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorkUpdateInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary_text: String,
|
||||
pub tags_json: String,
|
||||
pub cover_image_src: String,
|
||||
pub cover_asset_id: String,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorkPublishInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub published_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorksListInput {
|
||||
pub owner_user_id: String,
|
||||
pub published_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorkGetInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorkDeleteInput {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunStartInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub started_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunGetInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunClickInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub item_instance_id: String,
|
||||
pub client_snapshot_version: u32,
|
||||
pub client_event_id: String,
|
||||
pub clicked_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunStopInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub stopped_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunRestartInput {
|
||||
pub source_run_id: String,
|
||||
pub next_run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub restarted_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunTimeUpInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub finished_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DAgentSessionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub session_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorkProcedureResult {
|
||||
pub ok: bool,
|
||||
pub work_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorksProcedureResult {
|
||||
pub ok: bool,
|
||||
pub items_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunProcedureResult {
|
||||
pub ok: bool,
|
||||
pub run_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DClickItemProcedureResult {
|
||||
pub ok: bool,
|
||||
pub status: String,
|
||||
pub run_json: Option<String>,
|
||||
pub accepted_item_instance_id: Option<String>,
|
||||
pub cleared_item_instance_ids: Vec<String>,
|
||||
pub failure_reason: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DCreatorConfigSnapshot {
|
||||
pub theme_text: String,
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DAgentMessageSnapshot {
|
||||
pub message_id: String,
|
||||
pub session_id: String,
|
||||
pub role: String,
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DDraftSnapshot {
|
||||
pub profile_id: String,
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary_text: String,
|
||||
pub tags: Vec<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DAgentSessionSnapshot {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub seed_text: String,
|
||||
pub current_turn: u32,
|
||||
pub progress_percent: u32,
|
||||
pub stage: String,
|
||||
pub config: Match3DCreatorConfigSnapshot,
|
||||
pub draft: Option<Match3DDraftSnapshot>,
|
||||
pub messages: Vec<Match3DAgentMessageSnapshot>,
|
||||
pub last_assistant_reply: String,
|
||||
pub published_profile_id: Option<String>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DWorkSnapshot {
|
||||
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 summary_text: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover_image_src: String,
|
||||
pub cover_asset_id: String,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub config: Match3DCreatorConfigSnapshot,
|
||||
pub publication_status: String,
|
||||
pub publish_ready: bool,
|
||||
pub play_count: u32,
|
||||
pub updated_at_micros: i64,
|
||||
pub published_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DItemSnapshot {
|
||||
pub item_instance_id: String,
|
||||
pub item_type_id: String,
|
||||
pub visual_key: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub radius: f32,
|
||||
pub layer: u32,
|
||||
pub state: String,
|
||||
pub clickable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DTraySlotSnapshot {
|
||||
pub slot_index: u32,
|
||||
pub item_instance_id: Option<String>,
|
||||
pub item_type_id: Option<String>,
|
||||
pub visual_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DRunSnapshot {
|
||||
pub run_id: String,
|
||||
pub profile_id: String,
|
||||
pub status: String,
|
||||
pub snapshot_version: u32,
|
||||
pub started_at_ms: i64,
|
||||
pub duration_limit_ms: i64,
|
||||
pub server_now_ms: i64,
|
||||
pub remaining_ms: i64,
|
||||
pub clear_count: u32,
|
||||
pub total_item_count: u32,
|
||||
pub cleared_item_count: u32,
|
||||
pub tray_slots: Vec<Match3DTraySlotSnapshot>,
|
||||
pub items: Vec<Match3DItemSnapshot>,
|
||||
pub failure_reason: Option<String>,
|
||||
}
|
||||
@@ -4,6 +4,9 @@ use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
|
||||
use spacetimedb_lib::sats::ser::serde::SerializeWrapper;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::match3d::tables::{
|
||||
match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile,
|
||||
};
|
||||
use crate::puzzle::{
|
||||
puzzle_agent_message, puzzle_agent_session, puzzle_runtime_run, puzzle_work_profile,
|
||||
};
|
||||
@@ -187,6 +190,10 @@ macro_rules! migration_tables {
|
||||
puzzle_agent_message,
|
||||
puzzle_work_profile,
|
||||
puzzle_runtime_run,
|
||||
match3d_agent_session,
|
||||
match3d_agent_message,
|
||||
match3d_work_profile,
|
||||
match3d_runtime_run,
|
||||
big_fish_creation_session,
|
||||
big_fish_agent_message,
|
||||
big_fish_asset_slot
|
||||
|
||||
@@ -1261,8 +1261,9 @@ fn start_puzzle_run_tx(
|
||||
}
|
||||
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||
let started_at_ms = micros_to_millis(input.started_at_micros);
|
||||
let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut run =
|
||||
module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let current_grid_size = run.current_grid_size;
|
||||
let current_profile_id = entry_profile.profile_id.clone();
|
||||
hydrate_puzzle_leaderboard_entries(
|
||||
@@ -1502,13 +1503,11 @@ fn use_puzzle_runtime_prop_tx(
|
||||
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||
let current_run = deserialize_run(&row.snapshot_json)?;
|
||||
let next_run = match input.prop_kind.as_str() {
|
||||
"freezeTime" | "freeze_time" => {
|
||||
module_puzzle::apply_puzzle_freeze_time_at(
|
||||
¤t_run,
|
||||
micros_to_millis(input.used_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
|
||||
¤t_run,
|
||||
micros_to_millis(input.used_at_micros),
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
||||
¤t_run,
|
||||
false,
|
||||
|
||||
Reference in New Issue
Block a user