1121 lines
42 KiB
Rust
1121 lines
42 KiB
Rust
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<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()]);
|
||
}
|
||
}
|