use crate::*; use serde::{Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; use spacetimedb::AnonymousViewContext; pub(crate) mod tables; mod types; pub use tables::*; pub use types::*; /// Bark Battle 公开广场列表投影。 /// /// HTTP gallery 订阅该 public view 后读取本地 cache;view 只从已发布配置和统计投影 /// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。 #[spacetimedb::view(accessor = bark_battle_gallery_view, public)] pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec { let mut items = ctx .db .bark_battle_published_config() .by_bark_battle_published_owner_user_id() .filter(""..) .filter_map(|row| match build_bark_battle_gallery_view_row(ctx, &row) { Ok(item) => Some(item), Err(error) => { log::warn!( "汪汪声浪公开广场 view 跳过损坏的作品投影 work_id={}: {}", row.work_id, error ); None } }) .collect::>(); items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.work_id.cmp(&right.work_id)) }); items } #[spacetimedb::procedure] pub fn create_bark_battle_draft( ctx: &mut ProcedureContext, input: BarkBattleDraftCreateInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| create_bark_battle_draft_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_draft_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } #[spacetimedb::procedure] pub fn update_bark_battle_draft_config( ctx: &mut ProcedureContext, input: BarkBattleDraftConfigUpsertInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| update_bark_battle_draft_config_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_draft_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } #[spacetimedb::procedure] pub fn publish_bark_battle_work( ctx: &mut ProcedureContext, input: BarkBattleWorkPublishInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| publish_bark_battle_work_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_runtime_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } #[spacetimedb::procedure] pub fn get_bark_battle_runtime_config( ctx: &mut ProcedureContext, input: BarkBattleRuntimeConfigGetInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| get_bark_battle_runtime_config_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_runtime_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } #[spacetimedb::procedure] pub fn start_bark_battle_run( ctx: &mut ProcedureContext, input: BarkBattleRunStartInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| start_bark_battle_run_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_run_result(snapshot), Err(error) => bark_battle_error_result(error), } } #[spacetimedb::procedure] pub fn finish_bark_battle_run( ctx: &mut ProcedureContext, input: BarkBattleRunFinishInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| finish_bark_battle_run_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_run_result(snapshot), Err(error) => bark_battle_error_result(error), } } #[spacetimedb::procedure] pub fn get_bark_battle_run( ctx: &mut ProcedureContext, input: BarkBattleRunGetInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| get_bark_battle_run_tx(tx, input.clone())) { Ok(snapshot) => bark_battle_run_result(snapshot), Err(error) => bark_battle_error_result(error), } } fn create_bark_battle_draft_tx( ctx: &ReducerContext, input: BarkBattleDraftCreateInput, ) -> Result { require_non_empty(&input.draft_id, "bark_battle draft_id")?; require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?; if ctx .db .bark_battle_draft_config() .draft_id() .find(&input.draft_id) .is_some() { return Err("bark_battle_draft_config.draft_id 已存在".to_string()); } let now = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let config = BarkBattleEditorConfigSnapshot { title: normalize_title(input.title.as_deref())?, description: normalize_optional_text(input.description.as_deref()), theme_description: normalize_required_description( &input.theme_description, "theme_description", )?, player_image_description: normalize_required_description( &input.player_image_description, "player_image_description", )?, opponent_image_description: normalize_required_description( &input.opponent_image_description, "opponent_image_description", )?, onomatopoeia: Vec::new(), player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?, }; let row = BarkBattleDraftConfigRow { draft_id: input.draft_id.clone(), owner_user_id: input.owner_user_id.clone(), work_id: input.work_id.clone(), config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: config.difficulty_preset.clone(), leaderboard_enabled: true, config_json: to_json_string(&config), editor_state_json: normalize_json_string( input.editor_state_json.as_deref(), "editor_state_json", )?, created_at: now, updated_at: now, }; ctx.db.bark_battle_draft_config().insert(row.clone()); Ok(draft_snapshot(&row)) } fn update_bark_battle_draft_config_tx( ctx: &ReducerContext, input: BarkBattleDraftConfigUpsertInput, ) -> Result { require_non_empty(&input.draft_id, "bark_battle draft_id")?; require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?; let mut editor_config = parse_editor_config(&input.config_json)?; normalize_editor_config_snapshot(&mut editor_config)?; if editor_config.difficulty_preset != input.difficulty_preset { return Err("bark_battle config_json 与 difficulty_preset 不匹配".to_string()); } let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let existing = ctx .db .bark_battle_draft_config() .draft_id() .find(&input.draft_id) .ok_or_else(|| "bark_battle_draft_config.draft_id 不存在".to_string())?; if existing.owner_user_id != input.owner_user_id || existing.work_id != input.work_id { return Err("bark_battle draft owner/work 不匹配".to_string()); } let mut row = existing; // 中文注释:HTTP BFF 会先读缓存再发更新,订阅缓存可能短暂落后; // 这里按“至少递增 1”兜底,避免前端重复保存素材时被版本号误伤。 row.config_version = input .config_version .max(row.config_version.saturating_add(1)); row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?; row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?; row.config_json = to_json_string(&editor_config); row.updated_at = updated_at; ctx.db .bark_battle_draft_config() .draft_id() .update(row.clone()); Ok(draft_snapshot(&row)) } fn publish_bark_battle_work_tx( ctx: &ReducerContext, input: BarkBattleWorkPublishInput, ) -> Result { require_non_empty(&input.draft_id, "bark_battle draft_id")?; require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); let draft = ctx .db .bark_battle_draft_config() .draft_id() .find(&input.draft_id) .ok_or_else(|| "bark_battle draft 不存在".to_string())?; if draft.owner_user_id != input.owner_user_id || draft.work_id != input.work_id { return Err("bark_battle draft owner/work 不匹配".to_string()); } let published_snapshot_json = match input.published_snapshot_json.as_deref() { Some(value) => { let mut editor_config = parse_editor_config(value)?; normalize_editor_config_snapshot(&mut editor_config)?; if editor_config.difficulty_preset != draft.difficulty_preset { return Err( "bark_battle published_snapshot_json 与草稿 difficulty_preset 不匹配" .to_string(), ); } to_json_string(&editor_config) } None => draft.config_json.clone(), }; let published = BarkBattlePublishedConfigRow { work_id: draft.work_id.clone(), owner_user_id: draft.owner_user_id.clone(), source_draft_id: Some(draft.draft_id.clone()), config_version: draft.config_version, ruleset_version: normalize_ruleset_version(&draft.ruleset_version)?, difficulty_preset: normalize_difficulty(Some(&draft.difficulty_preset))?, leaderboard_enabled: true, config_json: published_snapshot_json.clone(), published_snapshot_json, created_at: published_at, updated_at: published_at, published_at, }; let mut published = published; match ctx .db .bark_battle_published_config() .work_id() .find(&published.work_id) { Some(existing) => { published.created_at = existing.created_at; ctx.db .bark_battle_published_config() .work_id() .update(published.clone()); } None => { ctx.db .bark_battle_published_config() .insert(published.clone()); } } Ok(runtime_config_snapshot(&published)) } fn get_bark_battle_runtime_config_tx( ctx: &ReducerContext, input: BarkBattleRuntimeConfigGetInput, ) -> Result { require_non_empty(&input.work_id, "bark_battle work_id")?; let row = ctx .db .bark_battle_published_config() .work_id() .find(&input.work_id) .ok_or_else(|| "bark_battle published config 不存在".to_string())?; if let Some(owner_user_id) = input.owner_user_id.as_deref() { if !owner_user_id.trim().is_empty() && row.owner_user_id != owner_user_id.trim() { return Err("bark_battle runtime config owner 不匹配".to_string()); } } Ok(runtime_config_snapshot(&row)) } fn start_bark_battle_run_tx( ctx: &ReducerContext, input: BarkBattleRunStartInput, ) -> Result { require_non_empty(&input.run_id, "bark_battle run_id")?; require_non_empty(&input.run_token, "bark_battle run_token")?; require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?; let published = ctx .db .bark_battle_published_config() .work_id() .find(&input.work_id) .ok_or_else(|| "bark_battle published config 不存在".to_string())?; if published.config_version != input.config_version || published.ruleset_version != input.ruleset_version || published.difficulty_preset != input.difficulty_preset { return Err("bark_battle run config/ruleset/difficulty 不匹配".to_string()); } if ctx .db .bark_battle_runtime_run() .run_id() .find(&input.run_id) .is_some() { return Err("bark_battle run_id 已存在".to_string()); } let started_at = ctx.timestamp; let row = BarkBattleRuntimeRunRow { run_id: input.run_id, run_token_hash: hash_run_token(&input.run_token), owner_user_id: input.owner_user_id, work_id: input.work_id, config_version: input.config_version, ruleset_version: input.ruleset_version, difficulty_preset: input.difficulty_preset, leaderboard_enabled: true, status: BARK_BATTLE_RUN_RUNNING.to_string(), client_started_at_micros: input.client_started_at_micros, server_started_at: started_at, client_finished_at_micros: None, server_finished_at: None, metrics_json: "{}".to_string(), server_result: None, validation_status: BARK_BATTLE_VALIDATION_PENDING.to_string(), anti_cheat_flags_json: "[]".to_string(), leaderboard_score: None, score_id: None, created_at: started_at, updated_at: started_at, }; ctx.db.bark_battle_runtime_run().insert(row.clone()); upsert_initial_work_stats(ctx, &row); Ok(run_snapshot(&row)) } fn finish_bark_battle_run_tx( ctx: &ReducerContext, input: BarkBattleRunFinishInput, ) -> Result { require_non_empty(&input.run_id, "bark_battle run_id")?; require_non_empty(&input.run_token, "bark_battle run_token")?; let mut run = ctx .db .bark_battle_runtime_run() .run_id() .find(&input.run_id) .ok_or_else(|| "bark_battle run 不存在".to_string())?; if input.server_finished_at_micros > 0 && input.server_finished_at_micros < run.server_started_at.to_micros_since_unix_epoch() { return Err("bark_battle server_finished_at 早于 run start".to_string()); } if ctx .timestamp .to_micros_since_unix_epoch() .saturating_sub(run.server_started_at.to_micros_since_unix_epoch()) > 10 * 60 * 1_000_000 { return Err("bark_battle run 已过期".to_string()); } if run.run_token_hash != hash_run_token(&input.run_token) { return Err("bark_battle run_token 不匹配".to_string()); } if run.status != BARK_BATTLE_RUN_RUNNING { return Err("bark_battle run 已结束".to_string()); } if run.owner_user_id != input.owner_user_id || run.work_id != input.work_id || run.config_version != input.config_version || run.ruleset_version != input.ruleset_version || run.difficulty_preset != input.difficulty_preset { return Err("bark_battle finish identity/config 不匹配".to_string()); } validate_json::(&input.metrics_json, "metrics_json")?; validate_json::(&input.derived_metrics_json, "derived_metrics_json")?; let difficulty = parse_domain_difficulty(&input.difficulty_preset)?; let ruleset = module_bark_battle::BarkBattleRuleset::for_difficulty(difficulty); let finished_at = ctx.timestamp; let metrics = module_bark_battle::BarkBattleFinishMetrics { duration_ms: input.duration_ms, trigger_count: input.trigger_count, max_volume: millis_to_unit(input.max_volume_millis), average_volume: millis_to_unit(input.average_volume_millis), final_energy: millis_to_energy(input.final_energy_millis), max_combo: input.max_combo, finished_at_micros: finished_at.to_micros_since_unix_epoch(), }; let validation = module_bark_battle::validate_finish_metrics(&ruleset, &metrics); let result = module_bark_battle::adjudicate_result( &ruleset, metrics.final_energy, millis_to_energy(input.opponent_final_energy_millis), ); let leaderboard = if run.leaderboard_enabled { module_bark_battle::compute_leaderboard_score(&ruleset, &metrics, &validation, result) } else { None }; let leaderboard_score = leaderboard.map(compose_leaderboard_score); let score_id = format!("score-{}", input.run_id); let validation_status = validation_status_to_string(validation.decision); let server_result = battle_result_to_string(result); let flags_json = to_json_string(&validation.anti_cheat_flags); ctx.db .bark_battle_score_record() .insert(BarkBattleScoreRecordRow { score_id: score_id.clone(), owner_user_id: run.owner_user_id.clone(), work_id: run.work_id.clone(), run_id: run.run_id.clone(), config_version: run.config_version, ruleset_version: run.ruleset_version.clone(), difficulty_preset: run.difficulty_preset.clone(), leaderboard_enabled: run.leaderboard_enabled, metrics_json: input.metrics_json.clone(), derived_metrics_json: input.derived_metrics_json.clone(), server_result: server_result.clone(), validation_status: validation_status.clone(), anti_cheat_flags_json: flags_json.clone(), leaderboard_score, recorded_at: finished_at, }); if let Some(score) = leaderboard_score { ctx.db .bark_battle_leaderboard_entry() .insert(BarkBattleLeaderboardEntryRow { leaderboard_entry_id: format!("leaderboard-{}", input.run_id), work_id: run.work_id.clone(), owner_user_id: run.owner_user_id.clone(), run_id: run.run_id.clone(), score_id: score_id.clone(), leaderboard_score: score, final_energy: metrics.final_energy, trigger_count: metrics.trigger_count, max_volume: metrics.max_volume, duration_closeness_ms: input.duration_ms.abs_diff(ruleset.standard_duration_ms), finished_at_micros: finished_at.to_micros_since_unix_epoch(), created_at: finished_at, updated_at: finished_at, }); } run.status = BARK_BATTLE_RUN_FINISHED.to_string(); run.client_finished_at_micros = Some(input.client_finished_at_micros); run.server_finished_at = Some(finished_at); run.metrics_json = input.metrics_json; run.server_result = Some(server_result.clone()); run.validation_status = validation_status.clone(); run.anti_cheat_flags_json = flags_json; run.leaderboard_score = leaderboard_score; run.score_id = Some(score_id.clone()); run.updated_at = finished_at; ctx.db .bark_battle_runtime_run() .run_id() .update(run.clone()); upsert_finished_projections( ctx, &run, &score_id, leaderboard_score, metrics.final_energy, metrics.trigger_count, metrics.max_volume, input.duration_ms.abs_diff(ruleset.standard_duration_ms), &server_result, &validation_status, finished_at.to_micros_since_unix_epoch(), finished_at, ); Ok(run_snapshot(&run)) } fn get_bark_battle_run_tx( ctx: &ReducerContext, input: BarkBattleRunGetInput, ) -> Result { let row = ctx .db .bark_battle_runtime_run() .run_id() .find(&input.run_id) .ok_or_else(|| "bark_battle run 不存在".to_string())?; if row.owner_user_id != input.owner_user_id { return Err("bark_battle run owner 不匹配".to_string()); } Ok(run_snapshot(&row)) } fn draft_snapshot(row: &BarkBattleDraftConfigRow) -> BarkBattleDraftConfigSnapshot { BarkBattleDraftConfigSnapshot { draft_id: row.draft_id.clone(), owner_user_id: row.owner_user_id.clone(), work_id: row.work_id.clone(), config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), config_json: row.config_json.clone(), editor_state_json: row.editor_state_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRuntimeConfigSnapshot { BarkBattleRuntimeConfigSnapshot { work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_draft_id: row.source_draft_id.clone(), config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), config_json: row.config_json.clone(), published_snapshot_json: row.published_snapshot_json.clone(), published_at_micros: row.published_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } fn build_bark_battle_gallery_view_row( ctx: &AnonymousViewContext, row: &BarkBattlePublishedConfigRow, ) -> Result { let mut editor_config = parse_editor_config(&row.config_json)?; normalize_editor_config_snapshot(&mut editor_config)?; let stats = ctx .db .bark_battle_work_stats_projection() .work_id() .find(&row.work_id); Ok(BarkBattleGalleryViewRow { work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_draft_id: row.source_draft_id.clone(), config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), title: editor_config.title, description: editor_config.description, theme_description: editor_config.theme_description, player_image_description: editor_config.player_image_description, opponent_image_description: editor_config.opponent_image_description, onomatopoeia: editor_config.onomatopoeia, player_character_image_src: editor_config.player_character_image_src, opponent_character_image_src: editor_config.opponent_character_image_src, ui_background_image_src: editor_config.ui_background_image_src, play_count: stats.as_ref().map(|stats| stats.play_count).unwrap_or(0), finish_count: stats .as_ref() .map(|stats| stats.finished_count) .unwrap_or(0), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), published_at_micros: row.published_at.to_micros_since_unix_epoch(), }) } fn hash_run_token(token: &str) -> String { let digest = Sha256::digest(token.as_bytes()); digest.iter().map(|byte| format!("{byte:02x}")).collect() } fn normalize_json_string(value: Option<&str>, field_name: &str) -> Result { let json = value.unwrap_or("{}").trim(); validate_json::(json, field_name)?; Ok(json.to_string()) } fn require_non_empty(value: &str, label: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("{label} 不能为空")) } else { Ok(()) } } fn normalize_editor_config_snapshot( config: &mut BarkBattleEditorConfigSnapshot, ) -> Result<(), String> { config.title = normalize_title(Some(&config.title))?; config.theme_description = normalize_required_description(&config.theme_description, "theme_description")?; config.player_image_description = normalize_required_description( &config.player_image_description, "player_image_description", )?; config.opponent_image_description = normalize_required_description( &config.opponent_image_description, "opponent_image_description", )?; config.onomatopoeia = normalize_onomatopoeia(std::mem::take(&mut config.onomatopoeia)); config.player_character_image_src = normalize_optional_asset_source( config.player_character_image_src.as_deref(), "player_character_image_src", )?; config.opponent_character_image_src = normalize_optional_asset_source( config.opponent_character_image_src.as_deref(), "opponent_character_image_src", )?; config.ui_background_image_src = normalize_optional_asset_source( config.ui_background_image_src.as_deref(), "ui_background_image_src", )?; config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?; Ok(()) } fn normalize_title(value: Option<&str>) -> Result { let title = value.unwrap_or("汪汪声浪挑战").trim(); if title.is_empty() { return Err("bark_battle title 不能为空".to_string()); } if title.chars().count() > 40 { return Err("bark_battle title 不能超过 40 个字符".to_string()); } Ok(title.to_string()) } fn normalize_optional_text(value: Option<&str>) -> String { value.unwrap_or_default().trim().chars().take(120).collect() } fn normalize_required_description(value: &str, field_name: &str) -> Result { let description = value.trim(); if description.is_empty() { return Err(format!("bark_battle {field_name} 不能为空")); } if description.chars().count() > 240 { return Err(format!("bark_battle {field_name} 不能超过 240 个字符")); } Ok(description.to_string()) } fn normalize_onomatopoeia(words: Vec) -> Vec { words .into_iter() .map(|word| word.trim().chars().take(12).collect::()) .filter(|word| !word.is_empty()) .take(24) .collect() } fn normalize_optional_asset_source( value: Option<&str>, field_name: &str, ) -> Result, String> { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; if value.chars().count() > 512 { return Err(format!("bark_battle {field_name} 不能超过 512 个字符")); } Ok(Some(value.to_string())) } fn normalize_ruleset_version(value: &str) -> Result { let ruleset = value.trim(); if ruleset != BARK_BATTLE_DEFAULT_RULESET_VERSION { return Err("bark_battle ruleset_version 不支持".to_string()); } Ok(ruleset.to_string()) } fn normalize_difficulty(value: Option<&str>) -> Result { let difficulty = value.unwrap_or(BARK_BATTLE_DIFFICULTY_NORMAL).trim(); match difficulty { BARK_BATTLE_DIFFICULTY_EASY | BARK_BATTLE_DIFFICULTY_NORMAL | BARK_BATTLE_DIFFICULTY_HARD => Ok(difficulty.to_string()), _ => Err("bark_battle difficulty_preset 不支持".to_string()), } } fn parse_editor_config(value: &str) -> Result { serde_json::from_str::(value) .map_err(|error| format!("bark_battle config_json JSON 无效: {error}")) } fn validate_json(value: &str, field_name: &str) -> Result<(), String> { serde_json::from_str::(value) .map(|_| ()) .map_err(|error| format!("bark_battle {field_name} JSON 无效: {error}")) } fn bark_battle_draft_config_result( draft_config: BarkBattleDraftConfigSnapshot, ) -> BarkBattleProcedureResult { BarkBattleProcedureResult { ok: true, draft_config: Some(draft_config), runtime_config: None, run: None, error_message: None, } } fn bark_battle_runtime_config_result( runtime_config: BarkBattleRuntimeConfigSnapshot, ) -> BarkBattleProcedureResult { BarkBattleProcedureResult { ok: true, draft_config: None, runtime_config: Some(runtime_config), run: None, error_message: None, } } fn bark_battle_run_result(run: BarkBattleRunSnapshot) -> BarkBattleProcedureResult { BarkBattleProcedureResult { ok: true, draft_config: None, runtime_config: None, run: Some(run), error_message: None, } } fn bark_battle_error_result(error: String) -> BarkBattleProcedureResult { BarkBattleProcedureResult { ok: false, draft_config: None, runtime_config: None, run: None, error_message: Some(error), } } fn to_json_string(value: &T) -> String { serde_json::to_string(value).expect("serialize bark battle snapshot") } fn run_snapshot(row: &BarkBattleRuntimeRunRow) -> BarkBattleRunSnapshot { BarkBattleRunSnapshot { run_id: row.run_id.clone(), owner_user_id: row.owner_user_id.clone(), work_id: row.work_id.clone(), config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), status: row.status.clone(), client_started_at_micros: row.client_started_at_micros, server_started_at_micros: row.server_started_at.to_micros_since_unix_epoch(), client_finished_at_micros: row.client_finished_at_micros, server_finished_at_micros: row .server_finished_at .map(|t| t.to_micros_since_unix_epoch()), metrics_json: row.metrics_json.clone(), server_result: row.server_result.clone(), validation_status: row.validation_status.clone(), anti_cheat_flags_json: row.anti_cheat_flags_json.clone(), leaderboard_score: row.leaderboard_score, score_id: row.score_id.clone(), } } fn upsert_initial_work_stats(ctx: &ReducerContext, run: &BarkBattleRuntimeRunRow) { let now = run.created_at; match ctx .db .bark_battle_work_stats_projection() .work_id() .find(&run.work_id) { Some(mut stats) => { stats.play_count += 1; stats.updated_at = now; ctx.db .bark_battle_work_stats_projection() .work_id() .update(stats); } None => { ctx.db .bark_battle_work_stats_projection() .insert(BarkBattleWorkStatsProjectionRow { work_id: run.work_id.clone(), owner_user_id: run.owner_user_id.clone(), play_count: 1, finished_count: 0, accepted_score_count: 0, leaderboard_entry_count: 0, best_leaderboard_score: None, best_score_id: None, best_run_id: None, average_final_energy: 0.0, average_trigger_count: 0.0, last_finished_at_micros: None, stats_json: "{}".to_string(), updated_at: now, }); } } } #[allow(clippy::too_many_arguments)] fn upsert_finished_projections( ctx: &ReducerContext, run: &BarkBattleRuntimeRunRow, score_id: &str, leaderboard_score: Option, final_energy: f32, trigger_count: u64, max_volume: f32, duration_closeness_ms: u64, server_result: &str, validation_status: &str, finished_at_micros: i64, updated_at: Timestamp, ) { let mut stats = ctx .db .bark_battle_work_stats_projection() .work_id() .find(&run.work_id) .unwrap_or_else(|| BarkBattleWorkStatsProjectionRow { work_id: run.work_id.clone(), owner_user_id: run.owner_user_id.clone(), play_count: 0, finished_count: 0, accepted_score_count: 0, leaderboard_entry_count: 0, best_leaderboard_score: None, best_score_id: None, best_run_id: None, average_final_energy: 0.0, average_trigger_count: 0.0, last_finished_at_micros: None, stats_json: "{}".to_string(), updated_at, }); let previous_finished = stats.finished_count as f32; stats.finished_count += 1; if validation_status != BARK_BATTLE_VALIDATION_REJECTED { stats.accepted_score_count += 1; } if leaderboard_score.is_some() { stats.leaderboard_entry_count += 1; } stats.average_final_energy = ((stats.average_final_energy * previous_finished) + final_energy) / stats.finished_count as f32; stats.average_trigger_count = ((stats.average_trigger_count * previous_finished) + trigger_count as f32) / stats.finished_count as f32; if leaderboard_score > stats.best_leaderboard_score { stats.best_leaderboard_score = leaderboard_score; stats.best_score_id = Some(score_id.to_string()); stats.best_run_id = Some(run.run_id.clone()); } stats.last_finished_at_micros = Some(finished_at_micros); stats.updated_at = updated_at; if ctx .db .bark_battle_work_stats_projection() .work_id() .find(&run.work_id) .is_some() { ctx.db .bark_battle_work_stats_projection() .work_id() .update(stats); } else { ctx.db.bark_battle_work_stats_projection().insert(stats); } let personal_best_id = format!("{}:{}", run.owner_user_id, run.work_id); let should_update_best = validation_status != BARK_BATTLE_VALIDATION_REJECTED && ctx .db .bark_battle_personal_best_projection() .personal_best_id() .find(&personal_best_id) .map(|best| { leaderboard_score > best.leaderboard_score || final_energy > best.final_energy }) .unwrap_or(true); if should_update_best { let row = BarkBattlePersonalBestProjectionRow { personal_best_id: personal_best_id.clone(), owner_user_id: run.owner_user_id.clone(), work_id: run.work_id.clone(), run_id: run.run_id.clone(), score_id: score_id.to_string(), leaderboard_entry_id: leaderboard_score.map(|_| format!("leaderboard-{}", run.run_id)), leaderboard_score, final_energy, trigger_count, max_volume, duration_closeness_ms, server_result: server_result.to_string(), validation_status: validation_status.to_string(), finished_at_micros, summary_json: "{}".to_string(), updated_at, }; if ctx .db .bark_battle_personal_best_projection() .personal_best_id() .find(&personal_best_id) .is_some() { ctx.db .bark_battle_personal_best_projection() .personal_best_id() .update(row); } else { ctx.db.bark_battle_personal_best_projection().insert(row); } } } fn parse_domain_difficulty(value: &str) -> Result { match value { BARK_BATTLE_DIFFICULTY_EASY => Ok(module_bark_battle::DifficultyPreset::Easy), BARK_BATTLE_DIFFICULTY_NORMAL => Ok(module_bark_battle::DifficultyPreset::Normal), BARK_BATTLE_DIFFICULTY_HARD => Ok(module_bark_battle::DifficultyPreset::Hard), _ => Err("bark_battle difficulty_preset 不支持".to_string()), } } fn millis_to_unit(value: u32) -> f32 { value as f32 / 1_000.0 } fn millis_to_energy(value: u32) -> f32 { value as f32 / 1_000.0 } fn validation_status_to_string(decision: module_bark_battle::FinishValidationDecision) -> String { match decision { module_bark_battle::FinishValidationDecision::Accepted => BARK_BATTLE_VALIDATION_ACCEPTED, module_bark_battle::FinishValidationDecision::AcceptedWithFlags => { BARK_BATTLE_VALIDATION_ACCEPTED_WITH_FLAGS } module_bark_battle::FinishValidationDecision::Rejected => BARK_BATTLE_VALIDATION_REJECTED, } .to_string() } fn battle_result_to_string(result: module_bark_battle::BattleResult) -> String { match result { module_bark_battle::BattleResult::PlayerWin => "player_win", module_bark_battle::BattleResult::OpponentWin => "opponent_win", module_bark_battle::BattleResult::Draw => "draw", } .to_string() } fn compose_leaderboard_score(score: module_bark_battle::BarkBattleLeaderboardScore) -> u64 { u64::from(score.final_energy_millis) * 10_000_000 + score.trigger_count.min(9_999) + u64::from(score.max_volume_millis).min(999) * 10_000 + (10_000_u64.saturating_sub(score.duration_closeness_ms.min(10_000))) } #[cfg(test)] mod tests { use super::*; #[test] fn bark_battle_types_are_constructible() { let input = BarkBattleDraftConfigUpsertInput { draft_id: "draft-1".to_string(), owner_user_id: "user-1".to_string(), work_id: "work-1".to_string(), config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: BARK_BATTLE_DIFFICULTY_NORMAL.to_string(), config_json: "{}".to_string(), updated_at_micros: 1_700_000, }; let result = BarkBattleProcedureResult { ok: true, draft_config: Some(BarkBattleDraftConfigSnapshot { draft_id: input.draft_id.clone(), owner_user_id: input.owner_user_id.clone(), work_id: input.work_id.clone(), config_version: input.config_version, ruleset_version: input.ruleset_version.clone(), difficulty_preset: input.difficulty_preset.clone(), config_json: input.config_json.clone(), editor_state_json: "{}".to_string(), created_at_micros: 1_700_000, updated_at_micros: input.updated_at_micros, }), runtime_config: None, run: None, error_message: None, }; assert_eq!(input.draft_id, "draft-1"); assert_eq!(input.ruleset_version, BARK_BATTLE_DEFAULT_RULESET_VERSION); assert!(result.ok); } #[test] fn validates_light_editor_config_before_publish() { assert_eq!( normalize_difficulty(Some(BARK_BATTLE_DIFFICULTY_HARD)).expect("difficulty"), BARK_BATTLE_DIFFICULTY_HARD ); assert!(normalize_difficulty(Some("insane")).is_err()); assert!(normalize_title(Some(" 标题 ")).is_ok()); assert!(normalize_title(Some(" ")).is_err()); } #[test] fn published_snapshot_is_normalized_as_runtime_config() { let mut editor_config = parse_editor_config( &serde_json::json!({ "title": " 汪汪测试杯 ", "description": "", "themeDescription": " 阳光草坪 ", "playerImageDescription": " 主角柴犬 ", "opponentImageDescription": " 对手哈士奇 ", "onomatopoeia": [" 轰汪! ", "冲啊冲啊冲啊冲啊冲啊!", ""], "playerCharacterImageSrc": "/generated-bark-battle-assets/player.png", "opponentCharacterImageSrc": "/generated-bark-battle-assets/opponent.png", "uiBackgroundImageSrc": "/generated-bark-battle-assets/ui.png", "difficultyPreset": "normal" }) .to_string(), ) .expect("published snapshot should parse"); normalize_editor_config_snapshot(&mut editor_config) .expect("published snapshot should normalize"); let config_json = to_json_string(&editor_config); assert!(config_json.contains("/generated-bark-battle-assets/player.png")); assert!(config_json.contains("/generated-bark-battle-assets/opponent.png")); assert!(config_json.contains("/generated-bark-battle-assets/ui.png")); assert!(config_json.contains("阳光草坪")); assert!(config_json.contains("轰汪!")); assert!(config_json.contains("冲啊冲啊冲啊冲啊")); assert!(!config_json.contains("冲啊冲啊冲啊冲啊冲啊!")); assert!(!config_json.contains("\"title\":\" 汪汪测试杯 \"")); assert!(!config_json.contains("themePreset")); assert!(!config_json.contains("playerDogSkinPreset")); assert!(!config_json.contains("opponentDogSkinPreset")); } #[test] fn bark_battle_gallery_view_row_exposes_custom_onomatopoeia() { let mut editor_config = parse_editor_config( &serde_json::json!({ "title": "声浪公开赛", "description": "画廊映射测试", "themeDescription": "霓虹竞技场", "playerImageDescription": "星际猫骑士", "opponentImageDescription": "机器人拳手", "onomatopoeia": [" 轰! ", "炸场!", ""], "difficultyPreset": "normal" }) .to_string(), ) .expect("gallery config should parse"); normalize_editor_config_snapshot(&mut editor_config) .expect("gallery config should normalize"); let row = BarkBattleGalleryViewRow { work_id: "BB-33333333".to_string(), owner_user_id: "user-3".to_string(), source_draft_id: Some("bark-battle-draft-3".to_string()), config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: editor_config.difficulty_preset.clone(), title: editor_config.title, description: editor_config.description, theme_description: editor_config.theme_description, player_image_description: editor_config.player_image_description, opponent_image_description: editor_config.opponent_image_description, onomatopoeia: editor_config.onomatopoeia, player_character_image_src: editor_config.player_character_image_src, opponent_character_image_src: editor_config.opponent_character_image_src, ui_background_image_src: editor_config.ui_background_image_src, play_count: 8, finish_count: 5, updated_at_micros: 1_713_686_401_234_567, published_at_micros: 1_713_686_401_234_000, }; assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]); } }