抓大鹅F3实现
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-30 21:01:36 +08:00
parent 22f6e6f4e7
commit 08815d98bc
39 changed files with 5891 additions and 18 deletions

9
server-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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 # 减少并行代码生成单元,提升优化但增加编译时间

View 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 }

View 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);
}
}

View File

@@ -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;

View 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));
}
}

View 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));
}
}

View 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));
}
}

View File

@@ -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"] }

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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,
}

View 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>,
}

View File

@@ -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

View File

@@ -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(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?
}
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
false,