Files
Genarrative/server-rs/crates/spacetime-module/src/bark_battle.rs
2026-05-22 05:00:07 +08:00

1121 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 后读取本地 cacheview 只从已发布配置和统计投影
/// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。
#[spacetimedb::view(accessor = bark_battle_gallery_view, public)]
pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec<BarkBattleGalleryViewRow> {
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::<Vec<_>>();
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<BarkBattleDraftConfigSnapshot, String> {
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<BarkBattleDraftConfigSnapshot, String> {
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<BarkBattleRuntimeConfigSnapshot, String> {
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<BarkBattleRuntimeConfigSnapshot, String> {
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<BarkBattleRunSnapshot, String> {
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<BarkBattleRunSnapshot, String> {
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::<serde_json::Value>(&input.metrics_json, "metrics_json")?;
validate_json::<serde_json::Value>(&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<BarkBattleRunSnapshot, String> {
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<BarkBattleGalleryViewRow, String> {
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<String, String> {
let json = value.unwrap_or("{}").trim();
validate_json::<serde_json::Value>(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<String, String> {
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<String, String> {
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<String>) -> Vec<String> {
words
.into_iter()
.map(|word| word.trim().chars().take(12).collect::<String>())
.filter(|word| !word.is_empty())
.take(24)
.collect()
}
fn normalize_optional_asset_source(
value: Option<&str>,
field_name: &str,
) -> Result<Option<String>, 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<String, String> {
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<String, String> {
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<BarkBattleEditorConfigSnapshot, String> {
serde_json::from_str::<BarkBattleEditorConfigSnapshot>(value)
.map_err(|error| format!("bark_battle config_json JSON 无效: {error}"))
}
fn validate_json<T: DeserializeOwned>(value: &str, field_name: &str) -> Result<(), String> {
serde_json::from_str::<T>(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<T: Serialize>(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<u64>,
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<module_bark_battle::DifficultyPreset, String> {
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()]);
}
}