use crate::*; use serde::{Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; pub(crate) mod tables; mod types; pub use tables::*; pub use types::*; #[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_preset: normalize_required_preset(&input.theme_preset, "theme_preset")?, player_dog_skin_preset: normalize_required_preset( &input.player_dog_skin_preset, "player_dog_skin_preset", )?, opponent_dog_skin_preset: normalize_required_preset( &input.opponent_dog_skin_preset, "opponent_dog_skin_preset", )?, difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?, leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true), }; 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: config.leaderboard_enabled, 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 editor_config = parse_editor_config(&input.config_json)?; validate_editor_config_snapshot(&editor_config)?; if editor_config.difficulty_preset != input.difficulty_preset || editor_config.leaderboard_enabled != input.leaderboard_enabled { return Err("bark_battle config_json 与行字段不一致".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()); } if input.config_version <= existing.config_version { return Err("bark_battle draft config_version 必须递增".to_string()); } let mut row = existing; row.config_version = input.config_version; row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?; row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?; row.leaderboard_enabled = input.leaderboard_enabled; row.config_json = input.config_json; 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 = 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: draft.leaderboard_enabled, config_json: draft.config_json.clone(), published_snapshot_json: match input.published_snapshot_json.as_deref() { Some(value) => normalize_json_string(Some(value), "published_snapshot_json")?, None => draft.config_json.clone(), }, 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: published.leaderboard_enabled, 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(), leaderboard_enabled: row.leaderboard_enabled, 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(), leaderboard_enabled: row.leaderboard_enabled, 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 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 validate_editor_config_snapshot(config: &BarkBattleEditorConfigSnapshot) -> Result<(), String> { normalize_title(Some(&config.title))?; normalize_required_preset(&config.theme_preset, "theme_preset")?; normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?; normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_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_preset(value: &str, field_name: &str) -> Result { let preset = value.trim(); if preset.is_empty() { return Err(format!("bark_battle {field_name} 不能为空")); } Ok(preset.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(), leaderboard_enabled: row.leaderboard_enabled, 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(), leaderboard_enabled: true, 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(), leaderboard_enabled: input.leaderboard_enabled, 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()); } }