feat: wire bark battle platform loop
Some checks are pending
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-05-14 18:20:46 +08:00
parent 8c6ec9e6e4
commit 1d7ef7e4b6
73 changed files with 7933 additions and 107 deletions

View File

@@ -13,6 +13,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
module-ai = { workspace = true, features = ["spacetime-types"] }
module-assets = { workspace = true, features = ["spacetime-types"] }
module-bark-battle = { workspace = true }
module-big-fish = { workspace = true, features = ["spacetime-types"] }
module-combat = { workspace = true, features = ["spacetime-types"] }
module-inventory = { workspace = true, features = ["spacetime-types"] }
@@ -26,6 +27,7 @@ module-runtime = { workspace = true, features = ["spacetime-types"] }
module-runtime-item = { workspace = true, features = ["spacetime-types"] }
module-square-hole = { workspace = true }
module-story = { workspace = true, features = ["spacetime-types"] }
sha2 = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, features = ["unstable"] }
spacetimedb-lib = { workspace = true, features = ["serde"] }

View File

@@ -0,0 +1,872 @@
use crate::*;
use serde::Serialize;
use serde::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_json_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_json_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_json_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_json_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_json_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_json_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_json_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_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<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 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<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 = 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<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: 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<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(),
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<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 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<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_preset(value: &str, field_name: &str) -> Result<String, String> {
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<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_json_result<T: Serialize>(value: &T) -> BarkBattleProcedureResult {
BarkBattleProcedureResult {
ok: true,
row_json: Some(to_json_string(value)),
error_message: None,
}
}
fn bark_battle_error_result(error: String) -> BarkBattleProcedureResult {
BarkBattleProcedureResult {
ok: false,
row_json: 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(),
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<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(),
leaderboard_enabled: true,
config_json: "{}".to_string(),
updated_at_micros: 1_700_000,
};
let result = BarkBattleProcedureResult {
ok: true,
row_json: Some(input.config_json.clone()),
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());
}
}

View File

@@ -0,0 +1,172 @@
use crate::*;
#[spacetimedb::table(
accessor = bark_battle_draft_config,
index(accessor = by_bark_battle_draft_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_bark_battle_draft_work_id, btree(columns = [work_id]))
)]
#[derive(Clone)]
pub struct BarkBattleDraftConfigRow {
#[primary_key]
pub(crate) draft_id: String,
pub(crate) owner_user_id: String,
pub(crate) work_id: String,
pub(crate) config_version: u64,
pub(crate) ruleset_version: String,
pub(crate) difficulty_preset: String,
pub(crate) leaderboard_enabled: bool,
pub(crate) config_json: String,
pub(crate) editor_state_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = bark_battle_published_config,
index(accessor = by_bark_battle_published_owner_user_id, btree(columns = [owner_user_id]))
)]
#[derive(Clone)]
pub struct BarkBattlePublishedConfigRow {
#[primary_key]
pub(crate) work_id: String,
pub(crate) owner_user_id: String,
pub(crate) source_draft_id: Option<String>,
pub(crate) config_version: u64,
pub(crate) ruleset_version: String,
pub(crate) difficulty_preset: String,
pub(crate) leaderboard_enabled: bool,
pub(crate) config_json: String,
pub(crate) published_snapshot_json: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Timestamp,
}
#[spacetimedb::table(
accessor = bark_battle_runtime_run,
index(accessor = by_bark_battle_run_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_bark_battle_run_work_id, btree(columns = [work_id]))
)]
#[derive(Clone)]
pub struct BarkBattleRuntimeRunRow {
#[primary_key]
pub(crate) run_id: String,
pub(crate) run_token_hash: String,
pub(crate) owner_user_id: String,
pub(crate) work_id: String,
pub(crate) config_version: u64,
pub(crate) ruleset_version: String,
pub(crate) difficulty_preset: String,
pub(crate) leaderboard_enabled: bool,
pub(crate) status: String,
pub(crate) client_started_at_micros: i64,
pub(crate) server_started_at: Timestamp,
pub(crate) client_finished_at_micros: Option<i64>,
pub(crate) server_finished_at: Option<Timestamp>,
pub(crate) metrics_json: String,
pub(crate) server_result: Option<String>,
pub(crate) validation_status: String,
pub(crate) anti_cheat_flags_json: String,
pub(crate) leaderboard_score: Option<u64>,
pub(crate) score_id: Option<String>,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = bark_battle_score_record,
index(accessor = by_bark_battle_score_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_bark_battle_score_work_id, btree(columns = [work_id])),
index(accessor = by_bark_battle_score_run_id, btree(columns = [run_id]))
)]
#[derive(Clone)]
pub struct BarkBattleScoreRecordRow {
#[primary_key]
pub(crate) score_id: String,
pub(crate) owner_user_id: String,
pub(crate) work_id: String,
pub(crate) run_id: String,
pub(crate) config_version: u64,
pub(crate) ruleset_version: String,
pub(crate) difficulty_preset: String,
pub(crate) leaderboard_enabled: bool,
pub(crate) metrics_json: String,
pub(crate) derived_metrics_json: String,
pub(crate) server_result: String,
pub(crate) validation_status: String,
pub(crate) anti_cheat_flags_json: String,
pub(crate) leaderboard_score: Option<u64>,
pub(crate) recorded_at: Timestamp,
}
#[spacetimedb::table(
accessor = bark_battle_leaderboard_entry,
index(accessor = by_bark_battle_leaderboard_work_score, btree(columns = [work_id, leaderboard_score])),
index(accessor = by_bark_battle_leaderboard_owner_work, btree(columns = [owner_user_id, work_id]))
)]
#[derive(Clone)]
pub struct BarkBattleLeaderboardEntryRow {
#[primary_key]
pub(crate) leaderboard_entry_id: String,
pub(crate) work_id: String,
pub(crate) owner_user_id: String,
pub(crate) run_id: String,
pub(crate) score_id: String,
pub(crate) leaderboard_score: u64,
pub(crate) final_energy: f32,
pub(crate) trigger_count: u64,
pub(crate) max_volume: f32,
pub(crate) duration_closeness_ms: u64,
pub(crate) finished_at_micros: i64,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = bark_battle_work_stats_projection,
index(accessor = by_bark_battle_work_stats_owner_user_id, btree(columns = [owner_user_id]))
)]
#[derive(Clone)]
pub struct BarkBattleWorkStatsProjectionRow {
#[primary_key]
pub(crate) work_id: String,
pub(crate) owner_user_id: String,
pub(crate) play_count: u64,
pub(crate) finished_count: u64,
pub(crate) accepted_score_count: u64,
pub(crate) leaderboard_entry_count: u64,
pub(crate) best_leaderboard_score: Option<u64>,
pub(crate) best_score_id: Option<String>,
pub(crate) best_run_id: Option<String>,
pub(crate) average_final_energy: f32,
pub(crate) average_trigger_count: f32,
pub(crate) last_finished_at_micros: Option<i64>,
pub(crate) stats_json: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = bark_battle_personal_best_projection,
index(accessor = by_bark_battle_personal_best_work_id, btree(columns = [work_id])),
index(accessor = by_bark_battle_personal_best_owner_work, btree(columns = [owner_user_id, work_id]))
)]
#[derive(Clone)]
pub struct BarkBattlePersonalBestProjectionRow {
#[primary_key]
pub(crate) personal_best_id: String,
pub(crate) owner_user_id: String,
pub(crate) work_id: String,
pub(crate) run_id: String,
pub(crate) score_id: String,
pub(crate) leaderboard_entry_id: Option<String>,
pub(crate) leaderboard_score: Option<u64>,
pub(crate) final_energy: f32,
pub(crate) trigger_count: u64,
pub(crate) max_volume: f32,
pub(crate) duration_closeness_ms: u64,
pub(crate) server_result: String,
pub(crate) validation_status: String,
pub(crate) finished_at_micros: i64,
pub(crate) summary_json: String,
pub(crate) updated_at: Timestamp,
}

View File

@@ -0,0 +1,177 @@
use crate::*;
use serde::{Deserialize, Serialize};
pub const BARK_BATTLE_DEFAULT_RULESET_VERSION: &str =
module_bark_battle::BARK_BATTLE_RULESET_VERSION_V1;
pub const BARK_BATTLE_DIFFICULTY_EASY: &str = "easy";
pub const BARK_BATTLE_DIFFICULTY_NORMAL: &str = "normal";
pub const BARK_BATTLE_DIFFICULTY_HARD: &str = "hard";
pub const BARK_BATTLE_RUN_PENDING: &str = "Pending";
pub const BARK_BATTLE_RUN_RUNNING: &str = "Running";
pub const BARK_BATTLE_RUN_FINISHED: &str = "Finished";
pub const BARK_BATTLE_RUN_ABORTED: &str = "Aborted";
pub const BARK_BATTLE_VALIDATION_PENDING: &str = "pending";
pub const BARK_BATTLE_VALIDATION_ACCEPTED: &str = "accepted";
pub const BARK_BATTLE_VALIDATION_ACCEPTED_WITH_FLAGS: &str = "accepted_with_flags";
pub const BARK_BATTLE_VALIDATION_REJECTED: &str = "rejected";
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleDraftCreateInput {
pub draft_id: String,
pub owner_user_id: String,
pub work_id: String,
pub title: Option<String>,
pub description: Option<String>,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: String,
pub difficulty_preset: Option<String>,
pub leaderboard_enabled: Option<bool>,
pub editor_state_json: Option<String>,
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleDraftConfigUpsertInput {
pub draft_id: String,
pub owner_user_id: String,
pub work_id: String,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleWorkPublishInput {
pub draft_id: String,
pub owner_user_id: String,
pub work_id: String,
pub published_snapshot_json: Option<String>,
pub published_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleRuntimeConfigGetInput {
pub work_id: String,
pub owner_user_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleRunStartInput {
pub run_id: String,
pub run_token: String,
pub owner_user_id: String,
pub work_id: String,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub client_started_at_micros: i64,
pub server_started_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleRunFinishInput {
pub run_id: String,
pub run_token: String,
pub owner_user_id: String,
pub work_id: String,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub client_finished_at_micros: i64,
pub server_finished_at_micros: i64,
pub duration_ms: u64,
pub trigger_count: u64,
pub max_volume_millis: u32,
pub average_volume_millis: u32,
pub final_energy_millis: u32,
pub opponent_final_energy_millis: u32,
pub max_combo: u32,
pub metrics_json: String,
pub derived_metrics_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleRunGetInput {
pub run_id: String,
pub owner_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct BarkBattleProcedureResult {
pub ok: bool,
pub row_json: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleEditorConfigSnapshot {
pub title: String,
pub description: String,
pub theme_preset: String,
pub player_dog_skin_preset: String,
pub opponent_dog_skin_preset: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleDraftConfigSnapshot {
pub draft_id: String,
pub owner_user_id: String,
pub work_id: String,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String,
pub editor_state_json: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleRuntimeConfigSnapshot {
pub work_id: String,
pub owner_user_id: String,
pub source_draft_id: Option<String>,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String,
pub published_snapshot_json: String,
pub published_at_micros: i64,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleRunSnapshot {
pub run_id: String,
pub owner_user_id: String,
pub work_id: String,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub status: String,
pub client_started_at_micros: i64,
pub server_started_at_micros: i64,
pub client_finished_at_micros: Option<i64>,
pub server_finished_at_micros: Option<i64>,
pub metrics_json: String,
pub server_result: Option<String>,
pub validation_status: String,
pub anti_cheat_flags_json: String,
pub leaderboard_score: Option<u64>,
pub score_id: Option<String>,
}

View File

@@ -23,6 +23,7 @@ pub use spacetimedb::{
mod ai;
mod asset_metadata;
mod auth;
mod bark_battle;
mod big_fish;
mod custom_world;
mod domain_types;
@@ -38,6 +39,7 @@ mod visual_novel;
pub use ai::*;
pub use asset_metadata::*;
pub use auth::*;
pub use bark_battle::*;
pub use big_fish::*;
pub use custom_world::*;
pub use domain_types::*;

View File

@@ -6,6 +6,11 @@ use spacetimedb::sats::de::serde::DeserializeWrapper;
use spacetimedb::sats::ser::serde::SerializeWrapper;
use std::collections::HashSet;
use crate::bark_battle::tables::{
bark_battle_draft_config, bark_battle_leaderboard_entry, bark_battle_personal_best_projection,
bark_battle_published_config, bark_battle_runtime_run, bark_battle_score_record,
bark_battle_work_stats_projection,
};
use crate::big_fish::big_fish_runtime_run;
use crate::match3d::tables::{
match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile,
@@ -216,6 +221,13 @@ macro_rules! migration_tables {
puzzle_event,
puzzle_runtime_run,
puzzle_leaderboard_entry,
bark_battle_draft_config,
bark_battle_published_config,
bark_battle_runtime_run,
bark_battle_score_record,
bark_battle_leaderboard_entry,
bark_battle_work_stats_projection,
bark_battle_personal_best_projection,
match3d_agent_session,
match3d_agent_message,
match3d_work_profile,

View File

@@ -1,13 +1,13 @@
pub mod analytics_date_dimension;
pub mod creation_entry_config;
mod browse_history;
pub mod creation_entry_config;
mod profile;
mod settings;
mod snapshots;
pub use analytics_date_dimension::*;
pub use creation_entry_config::*;
pub use browse_history::*;
pub use creation_entry_config::*;
pub use profile::*;
pub use settings::*;
pub use snapshots::*;