1
server-rs/Cargo.lock
generated
1
server-rs/Cargo.lock
generated
@@ -2691,6 +2691,7 @@ dependencies = [
|
||||
"module-combat",
|
||||
"module-custom-world",
|
||||
"module-inventory",
|
||||
"module-match3d",
|
||||
"module-npc",
|
||||
"module-progression",
|
||||
"module-puzzle",
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
|
||||
// 首版 demo 使用固定 10 组颜色形状 key;后续真实题材素材接入时仍保持 item_type_id 三个一组。
|
||||
const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [
|
||||
"red_circle",
|
||||
"yellow_triangle",
|
||||
@@ -163,6 +164,7 @@ pub struct Match3DRunSnapshot {
|
||||
pub clear_count: u32,
|
||||
pub total_item_count: u32,
|
||||
pub cleared_item_count: u32,
|
||||
/// 领域内部权威快照版本;HTTP DTO 对外映射为 snapshotVersion。
|
||||
pub board_version: u64,
|
||||
pub items: Vec<Match3DItemSnapshot>,
|
||||
pub tray_slots: Vec<Match3DTraySlot>,
|
||||
@@ -303,6 +305,7 @@ pub fn build_creator_config(
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据已确认的题材、消除次数和难度编译首版结果草稿。
|
||||
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
|
||||
let game_name = format!("{}抓大鹅", config.theme_text);
|
||||
let summary = format!(
|
||||
@@ -310,9 +313,7 @@ pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft
|
||||
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 {
|
||||
let mut draft = Match3DResultDraft {
|
||||
game_name,
|
||||
theme_text: config.theme_text.clone(),
|
||||
summary,
|
||||
@@ -321,13 +322,18 @@ pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft
|
||||
reference_image_src: config.reference_image_src.clone(),
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
publish_ready: blockers.is_empty(),
|
||||
blockers,
|
||||
}
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
};
|
||||
draft.blockers = validate_result_publish_fields(&draft);
|
||||
draft.publish_ready = draft.blockers.is_empty();
|
||||
|
||||
draft
|
||||
}
|
||||
|
||||
/// 校验发布所需基础字段;试玩通关不是首版发布门槛。
|
||||
pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec<String> {
|
||||
let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags);
|
||||
let mut blockers = validate_result_publish_fields(draft);
|
||||
if draft.clear_count == 0 {
|
||||
blockers.push("需要消除次数必须为正整数".to_string());
|
||||
}
|
||||
@@ -337,6 +343,7 @@ pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec<String>
|
||||
blockers
|
||||
}
|
||||
|
||||
/// 将结果草稿转换为可保存的作品 profile,实际持久化由 SpacetimeDB 分支负责。
|
||||
pub fn create_work_profile(
|
||||
work_id: String,
|
||||
profile_id: String,
|
||||
@@ -371,6 +378,7 @@ pub fn create_work_profile(
|
||||
})
|
||||
}
|
||||
|
||||
/// 发布作品时只改变发布状态和时间戳,不在领域层写数据库。
|
||||
pub fn publish_work_profile(
|
||||
profile: &Match3DWorkProfile,
|
||||
published_at_micros: i64,
|
||||
@@ -389,6 +397,7 @@ pub fn publish_work_profile(
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// 用确定性 seed 生成单局初始快照,便于后端权威复现和测试。
|
||||
pub fn start_run_with_seed_at(
|
||||
run_id: String,
|
||||
owner_user_id: String,
|
||||
@@ -428,6 +437,7 @@ pub fn start_run_with_seed_at(
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
/// 后端权威确认一次点击:校验版本、可点击性、入槽、三消和胜负。
|
||||
pub fn confirm_click_at(
|
||||
run: &Match3DRunSnapshot,
|
||||
input: &Match3DClickInput,
|
||||
@@ -502,6 +512,7 @@ pub fn confirm_click_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据权威时间刷新剩余时间;前端本地倒计时归零后仍需走后端确认。
|
||||
pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot {
|
||||
let mut next = run.clone();
|
||||
if next.status != Match3DRunStatus::Running {
|
||||
@@ -517,6 +528,7 @@ pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRun
|
||||
next
|
||||
}
|
||||
|
||||
/// 停止当前运行态,用于试玩或玩家主动退出。
|
||||
pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot {
|
||||
let mut next = run.clone();
|
||||
if next.status == Match3DRunStatus::Running {
|
||||
@@ -527,6 +539,7 @@ pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match
|
||||
next
|
||||
}
|
||||
|
||||
/// 以 2D 圆形近似判断遮挡:完全被更高层物品覆盖的物品不可点击。
|
||||
pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) {
|
||||
let board_items = run
|
||||
.items
|
||||
@@ -761,6 +774,19 @@ fn validate_basic_publish_fields(game_name: &str, summary: &str, tags: &[String]
|
||||
blockers
|
||||
}
|
||||
|
||||
fn validate_result_publish_fields(draft: &Match3DResultDraft) -> Vec<String> {
|
||||
let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags);
|
||||
if draft
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
.is_none()
|
||||
{
|
||||
blockers.push("封面图不能为空".to_string());
|
||||
}
|
||||
blockers
|
||||
}
|
||||
|
||||
fn default_tags_for_theme(theme_text: &str) -> Vec<String> {
|
||||
let mut tags = vec![
|
||||
"抓大鹅".to_string(),
|
||||
@@ -831,6 +857,17 @@ mod tests {
|
||||
assert_eq!(error, Match3DFieldError::InvalidClearCount);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_requires_cover_before_publish() {
|
||||
let mut draft = compile_result_draft(&test_config(2));
|
||||
|
||||
assert!(!draft.publish_ready);
|
||||
assert!(draft.blockers.contains(&"封面图不能为空".to_string()));
|
||||
|
||||
draft.cover_image_src = Some("https://example.com/cover.png".to_string());
|
||||
assert!(validate_publish_requirements(&draft).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_run_generates_triples() {
|
||||
let run = start_run_with_seed_at(
|
||||
|
||||
@@ -9,9 +9,12 @@ pub struct StartMatch3DRunRequest {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClickMatch3DItemRequest {
|
||||
#[serde(default)]
|
||||
pub run_id: Option<String>,
|
||||
pub item_instance_id: String,
|
||||
pub client_action_id: String,
|
||||
pub snapshot_version: u64,
|
||||
pub client_snapshot_version: u64,
|
||||
pub client_event_id: String,
|
||||
pub clicked_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -55,13 +58,16 @@ pub struct Match3DRunSnapshotResponse {
|
||||
pub profile_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub status: String,
|
||||
/// 对外 HTTP 快照版本。领域层内部字段名为 board_version,facade 需要在这里完成映射。
|
||||
pub snapshot_version: u64,
|
||||
pub started_at_ms: u64,
|
||||
pub duration_limit_ms: u64,
|
||||
#[serde(default)]
|
||||
pub server_now_ms: Option<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)]
|
||||
@@ -102,14 +108,18 @@ mod tests {
|
||||
#[test]
|
||||
fn click_match3d_item_request_uses_camel_case() {
|
||||
let payload = serde_json::to_value(ClickMatch3DItemRequest {
|
||||
run_id: Some("run-1".to_string()),
|
||||
item_instance_id: "item-1".to_string(),
|
||||
client_action_id: "action-1".to_string(),
|
||||
snapshot_version: 7,
|
||||
client_snapshot_version: 7,
|
||||
client_event_id: "event-1".to_string(),
|
||||
clicked_at_ms: 12_345,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["runId"], json!("run-1"));
|
||||
assert_eq!(payload["itemInstanceId"], json!("item-1"));
|
||||
assert_eq!(payload["clientActionId"], json!("action-1"));
|
||||
assert_eq!(payload["snapshotVersion"], json!(7));
|
||||
assert_eq!(payload["clientSnapshotVersion"], json!(7));
|
||||
assert_eq!(payload["clientEventId"], json!("event-1"));
|
||||
assert_eq!(payload["clickedAtMs"], json!(12_345));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,9 +775,7 @@ fn click_match3d_item_tx(
|
||||
Ok(click_result(
|
||||
status,
|
||||
next,
|
||||
confirmation
|
||||
.accepted
|
||||
.then_some(input.item_instance_id),
|
||||
confirmation.accepted.then_some(input.item_instance_id),
|
||||
confirmation.cleared_item_instance_ids,
|
||||
))
|
||||
}
|
||||
@@ -790,6 +788,7 @@ fn stop_match3d_run_tx(
|
||||
let stopped_at_ms = input.stopped_at_ms.max(current_server_ms(ctx));
|
||||
let snapshot = deserialize_snapshot(&row.snapshot_json)?;
|
||||
let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id);
|
||||
let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(stopped_at_ms));
|
||||
let domain_run = stop_domain_run_at(&domain_run, "match3d-stop".to_string());
|
||||
let next = snapshot_from_domain(&domain_run, stopped_at_ms);
|
||||
persist_snapshot(ctx, &row, &next, stopped_at_ms);
|
||||
@@ -966,24 +965,22 @@ fn build_initial_run_snapshot(
|
||||
seed,
|
||||
domain_started_at_ms,
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
DomainMatch3DRunSnapshot {
|
||||
run_id: run_id.to_string(),
|
||||
profile_id: work.profile_id.clone(),
|
||||
owner_user_id: work.owner_user_id.clone(),
|
||||
status: DomainMatch3DRunStatus::Running,
|
||||
started_at_ms: domain_started_at_ms,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64,
|
||||
clear_count: work.clear_count.max(1),
|
||||
total_item_count: work.clear_count.max(1).saturating_mul(3),
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: Vec::new(),
|
||||
tray_slots: Vec::new(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
}
|
||||
.unwrap_or_else(|_| DomainMatch3DRunSnapshot {
|
||||
run_id: run_id.to_string(),
|
||||
profile_id: work.profile_id.clone(),
|
||||
owner_user_id: work.owner_user_id.clone(),
|
||||
status: DomainMatch3DRunStatus::Running,
|
||||
started_at_ms: domain_started_at_ms,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64,
|
||||
clear_count: work.clear_count.max(1),
|
||||
total_item_count: work.clear_count.max(1).saturating_mul(3),
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: Vec::new(),
|
||||
tray_slots: Vec::new(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
});
|
||||
snapshot_from_domain(&domain_run, started_at_ms)
|
||||
}
|
||||
@@ -1256,10 +1253,7 @@ fn domain_config_from_snapshot(
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot_from_domain(
|
||||
run: &DomainMatch3DRunSnapshot,
|
||||
server_now_ms: i64,
|
||||
) -> Match3DRunSnapshot {
|
||||
fn snapshot_from_domain(run: &DomainMatch3DRunSnapshot, server_now_ms: i64) -> Match3DRunSnapshot {
|
||||
Match3DRunSnapshot {
|
||||
run_id: run.run_id.clone(),
|
||||
profile_id: run.profile_id.clone(),
|
||||
@@ -1278,7 +1272,10 @@ fn snapshot_from_domain(
|
||||
.map(snapshot_tray_slot_from_domain)
|
||||
.collect(),
|
||||
items: run.items.iter().map(snapshot_item_from_domain).collect(),
|
||||
failure_reason: run.failure_reason.map(domain_failure_to_text).map(str::to_string),
|
||||
failure_reason: run
|
||||
.failure_reason
|
||||
.map(domain_failure_to_text)
|
||||
.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1298,7 +1295,11 @@ fn domain_snapshot_from_snapshot(
|
||||
total_item_count: snapshot.total_item_count,
|
||||
cleared_item_count: snapshot.cleared_item_count,
|
||||
board_version: snapshot.snapshot_version as u64,
|
||||
items: snapshot.items.iter().map(domain_item_from_snapshot).collect(),
|
||||
items: snapshot
|
||||
.items
|
||||
.iter()
|
||||
.map(domain_item_from_snapshot)
|
||||
.collect(),
|
||||
tray_slots: snapshot
|
||||
.tray_slots
|
||||
.iter()
|
||||
@@ -1627,14 +1628,12 @@ mod tests {
|
||||
assert!(confirmation.accepted);
|
||||
assert_eq!(confirmation.cleared_item_instance_ids.len(), 3);
|
||||
assert!(
|
||||
next
|
||||
.tray_slots
|
||||
next.tray_slots
|
||||
.iter()
|
||||
.all(|slot| slot.item_instance_id.is_none())
|
||||
);
|
||||
assert!(
|
||||
next
|
||||
.items
|
||||
next.items
|
||||
.iter()
|
||||
.all(|item| item.state == MATCH3D_ITEM_CLEARED)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user