777 lines
29 KiB
Rust
777 lines
29 KiB
Rust
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Extension, Path, State, rejection::JsonRejection},
|
|
http::{HeaderName, StatusCode, header},
|
|
response::Response,
|
|
};
|
|
use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset};
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use shared_contracts::bark_battle::{
|
|
BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, BarkBattleDifficultyPreset,
|
|
BarkBattleDraftConfig, BarkBattleDraftCreateRequest, BarkBattleFinishStatus,
|
|
BarkBattlePublishedConfig, BarkBattleRunFinishRequest, BarkBattleRunFinishResponse,
|
|
BarkBattleRunStartRequest, BarkBattleRunStartResponse, BarkBattleScoreSummary,
|
|
BarkBattleServerResult, BarkBattleWorkPublishRequest,
|
|
};
|
|
use shared_kernel::{
|
|
build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros,
|
|
offset_datetime_to_unix_micros, parse_rfc3339,
|
|
};
|
|
use spacetime_client::{
|
|
BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord,
|
|
BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
|
};
|
|
use time::{Duration as TimeDuration, OffsetDateTime};
|
|
|
|
use crate::{
|
|
api_response::json_success_body,
|
|
auth::AuthenticatedAccessToken,
|
|
http_error::AppError,
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
|
};
|
|
|
|
const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime";
|
|
const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-";
|
|
const BARK_BATTLE_WORK_ID_PREFIX: &str = "bark-battle-work-";
|
|
const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
|
|
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
|
|
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
|
|
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
|
|
|
|
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BarkBattleRunSnapshotRecord {
|
|
run_id: String,
|
|
work_id: String,
|
|
config_version: u64,
|
|
ruleset_version: String,
|
|
difficulty_preset: String,
|
|
#[serde(default)]
|
|
client_started_at_micros: i64,
|
|
#[serde(default)]
|
|
server_started_at_micros: i64,
|
|
#[serde(default)]
|
|
server_finished_at_micros: Option<i64>,
|
|
#[serde(default)]
|
|
metrics_json: String,
|
|
#[serde(default)]
|
|
server_result: Option<String>,
|
|
#[serde(default)]
|
|
validation_status: String,
|
|
#[serde(default)]
|
|
anti_cheat_flags_json: String,
|
|
#[serde(default)]
|
|
leaderboard_score: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BarkBattleDraftConfigSnapshotRecord {
|
|
draft_id: String,
|
|
#[allow(dead_code)]
|
|
work_id: String,
|
|
#[allow(dead_code)]
|
|
config_version: u64,
|
|
#[allow(dead_code)]
|
|
ruleset_version: String,
|
|
#[serde(default)]
|
|
config_json: String,
|
|
updated_at_micros: i64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BarkBattleRuntimeConfigSnapshotRecord {
|
|
work_id: String,
|
|
source_draft_id: Option<String>,
|
|
config_version: u64,
|
|
ruleset_version: String,
|
|
#[serde(default)]
|
|
config_json: String,
|
|
published_at_micros: i64,
|
|
updated_at_micros: i64,
|
|
}
|
|
|
|
pub async fn create_bark_battle_draft(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<BarkBattleDraftCreateRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
|
let now = current_utc_micros();
|
|
let draft = state
|
|
.spacetime_client()
|
|
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
|
|
draft_id: build_prefixed_uuid_id(BARK_BATTLE_DRAFT_ID_PREFIX),
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX),
|
|
title: Some(payload.title),
|
|
description: payload.description,
|
|
theme_preset: payload.theme_preset,
|
|
player_dog_skin_preset: payload.player_dog_skin_preset,
|
|
opponent_dog_skin_preset: payload.opponent_dog_skin_preset,
|
|
difficulty_preset: Some(
|
|
difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(),
|
|
),
|
|
leaderboard_enabled: Some(payload.leaderboard_enabled),
|
|
editor_state_json: Some("{}".to_string()),
|
|
created_at_micros: now,
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let draft = map_draft_config_record(draft, &request_context)?;
|
|
Ok(json_success_body(Some(&request_context), draft))
|
|
}
|
|
|
|
pub async fn publish_bark_battle_work(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<BarkBattleWorkPublishRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
|
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
|
|
let work_id = payload
|
|
.work_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX));
|
|
let published_snapshot_json = payload
|
|
.published_snapshot
|
|
.as_ref()
|
|
.map(serde_json::to_string)
|
|
.transpose()
|
|
.map_err(|error| {
|
|
bark_battle_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": format!("publishedSnapshot JSON 序列化失败: {error}"),
|
|
})),
|
|
)
|
|
})?;
|
|
let published = state
|
|
.spacetime_client()
|
|
.publish_bark_battle_work(BarkBattleWorkPublishRecordInput {
|
|
draft_id: payload.draft_id,
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
work_id,
|
|
published_snapshot_json,
|
|
published_at_micros: current_utc_micros(),
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let published = map_published_config_record(published, &request_context)?;
|
|
Ok(json_success_body(Some(&request_context), published))
|
|
}
|
|
|
|
pub async fn get_bark_battle_runtime_config(
|
|
State(state): State<AppState>,
|
|
Path(work_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &work_id, "workId")?;
|
|
|
|
let config = state
|
|
.spacetime_client()
|
|
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let config = map_runtime_config_record(config, &request_context)?;
|
|
|
|
Ok(json_success_body(Some(&request_context), config))
|
|
}
|
|
|
|
pub async fn start_bark_battle_run(
|
|
State(state): State<AppState>,
|
|
Path(work_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
|
let request = maybe_payload.unwrap_or_else(|| BarkBattleRunStartRequest {
|
|
work_id: work_id.clone(),
|
|
config_version: None,
|
|
source_route: None,
|
|
client_runtime_version: None,
|
|
});
|
|
let work_id = if request.work_id.trim().is_empty() {
|
|
work_id
|
|
} else {
|
|
request.work_id.trim().to_string()
|
|
};
|
|
ensure_non_empty(&request_context, &work_id, "workId")?;
|
|
|
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
let runtime_config = state
|
|
.spacetime_client()
|
|
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let runtime_config = map_runtime_config_record(runtime_config, &request_context)?;
|
|
if !request.work_id.trim().is_empty() && request.work_id.trim() != work_id {
|
|
return Err(bark_battle_bad_request(
|
|
&request_context,
|
|
"workId 与路径参数不一致",
|
|
));
|
|
}
|
|
|
|
if let Some(expected_version) = request.config_version {
|
|
if expected_version != runtime_config.config_version {
|
|
return Err(bark_battle_bad_request(
|
|
&request_context,
|
|
"configVersion 与已发布配置不一致",
|
|
));
|
|
}
|
|
}
|
|
|
|
let client_started_at_micros = current_utc_micros();
|
|
let run_token = build_prefixed_uuid_id(BARK_BATTLE_RUN_TOKEN_PREFIX);
|
|
let run = state
|
|
.spacetime_client()
|
|
.start_bark_battle_run(BarkBattleRunStartRecordInput {
|
|
run_id: build_prefixed_uuid_id(BARK_BATTLE_RUN_ID_PREFIX),
|
|
run_token: run_token.clone(),
|
|
owner_user_id: owner_user_id.clone(),
|
|
work_id: work_id.clone(),
|
|
config_version: u64::from(runtime_config.config_version),
|
|
ruleset_version: runtime_config.ruleset_version.clone(),
|
|
difficulty_preset: difficulty_to_spacetime_string(&runtime_config.difficulty_preset)
|
|
.to_string(),
|
|
client_started_at_micros,
|
|
server_started_at_micros: client_started_at_micros,
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let run_snapshot = parse_run_record(run, &request_context)?;
|
|
|
|
record_work_play_start_after_success(
|
|
&state,
|
|
&request_context,
|
|
WorkPlayTrackingDraft::new(
|
|
BARK_BATTLE_PLAY_TYPE_ID,
|
|
work_id.clone(),
|
|
&authenticated,
|
|
"/api/runtime/bark-battle/...",
|
|
)
|
|
.extra(json!({
|
|
"runId": run_snapshot.run_id,
|
|
"workId": work_id,
|
|
"configVersion": runtime_config.config_version,
|
|
"rulesetVersion": runtime_config.ruleset_version,
|
|
"difficultyPreset": runtime_config.difficulty_preset,
|
|
"sourceRoute": request.source_route,
|
|
"clientRuntimeVersion": request.client_runtime_version,
|
|
})),
|
|
)
|
|
.await;
|
|
|
|
let server_started_at = format_timestamp_micros(run_snapshot.server_started_at_micros);
|
|
let expires_at = format_timestamp_micros(
|
|
run_snapshot
|
|
.server_started_at_micros
|
|
.saturating_add(BARK_BATTLE_RUN_TTL_SECONDS * 1_000_000),
|
|
);
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
BarkBattleRunStartResponse {
|
|
run_id: run_snapshot.run_id,
|
|
run_token,
|
|
work_id: run_snapshot.work_id,
|
|
config_version: runtime_config.config_version,
|
|
ruleset_version: runtime_config.ruleset_version.clone(),
|
|
difficulty_preset: runtime_config.difficulty_preset.clone(),
|
|
runtime_config,
|
|
server_started_at,
|
|
expires_at,
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn get_bark_battle_run(
|
|
State(state): State<AppState>,
|
|
Path(run_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
) -> Result<Json<Value>, Response> {
|
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
|
let run = state
|
|
.spacetime_client()
|
|
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let run = parse_run_record(run, &request_context)?;
|
|
|
|
Ok(json_success_body(Some(&request_context), run))
|
|
}
|
|
|
|
pub async fn finish_bark_battle_run(
|
|
State(state): State<AppState>,
|
|
Path(run_id): Path<String>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
|
|
) -> Result<Json<Value>, Response> {
|
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
|
ensure_non_empty(&request_context, &payload.work_id, "workId")?;
|
|
ensure_non_empty(&request_context, &payload.run_token, "runToken")?;
|
|
if payload.run_id != run_id {
|
|
return Err(bark_battle_bad_request(
|
|
&request_context,
|
|
"runId 与路径参数不一致",
|
|
));
|
|
}
|
|
if payload.ruleset_version != BARK_BATTLE_RULESET_VERSION_V1 {
|
|
return Err(bark_battle_bad_request(
|
|
&request_context,
|
|
"rulesetVersion 不支持",
|
|
));
|
|
}
|
|
|
|
let client_finished_at_micros = parse_client_time_to_micros(&payload.client_finished_at)
|
|
.map_err(|message| bark_battle_bad_request(&request_context, &message))?;
|
|
let derived = &payload.derived_metrics;
|
|
let opponent_final_energy = derive_server_opponent_final_energy(derived);
|
|
let metrics_json = serde_json::to_string(&json!({
|
|
"clientStartedAt": payload.client_started_at,
|
|
"clientFinishedAt": payload.client_finished_at,
|
|
"durationMs": payload.duration_ms,
|
|
"derivedMetrics": payload.derived_metrics,
|
|
"clientResult": payload.client_result,
|
|
"sampleDigest": payload.sample_digest,
|
|
"clientRuntimeVersion": payload.client_runtime_version,
|
|
}))
|
|
.unwrap_or_else(|_| "{}".to_string());
|
|
let derived_metrics_json = serde_json::to_string(derived).unwrap_or_else(|_| "{}".to_string());
|
|
|
|
let run = state
|
|
.spacetime_client()
|
|
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
|
|
run_id,
|
|
run_token: payload.run_token,
|
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
|
work_id: payload.work_id.clone(),
|
|
config_version: u64::from(payload.config_version),
|
|
ruleset_version: payload.ruleset_version.clone(),
|
|
difficulty_preset: difficulty_to_spacetime_string(&payload.difficulty_preset)
|
|
.to_string(),
|
|
client_finished_at_micros,
|
|
server_finished_at_micros: current_utc_micros(),
|
|
duration_ms: payload.duration_ms,
|
|
trigger_count: u64::from(derived.trigger_count),
|
|
max_volume_millis: unit_to_millis(derived.max_volume),
|
|
average_volume_millis: unit_to_millis(derived.average_volume),
|
|
final_energy_millis: energy_to_millis(derived.final_energy),
|
|
opponent_final_energy_millis: energy_to_millis(opponent_final_energy),
|
|
max_combo: derived.combo_max,
|
|
metrics_json,
|
|
derived_metrics_json,
|
|
})
|
|
.await
|
|
.map_err(|error| {
|
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
|
})?;
|
|
let run = parse_run_record(run, &request_context)?;
|
|
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
map_finish_response(run, &payload.derived_metrics),
|
|
))
|
|
}
|
|
|
|
fn map_finish_response(
|
|
run: BarkBattleRunSnapshotRecord,
|
|
fallback_metrics: &BarkBattleDerivedMetrics,
|
|
) -> BarkBattleRunFinishResponse {
|
|
let score_summary =
|
|
parse_score_summary(&run.metrics_json).unwrap_or_else(|| BarkBattleScoreSummary {
|
|
duration_ms: 0,
|
|
trigger_count: fallback_metrics.trigger_count,
|
|
max_volume: fallback_metrics.max_volume,
|
|
average_volume: fallback_metrics.average_volume,
|
|
final_energy: fallback_metrics.final_energy,
|
|
combo_max: fallback_metrics.combo_max,
|
|
});
|
|
BarkBattleRunFinishResponse {
|
|
status: parse_finish_status(&run.validation_status),
|
|
run_id: run.run_id,
|
|
work_id: run.work_id,
|
|
config_version: run.config_version.min(u64::from(u32::MAX)) as u32,
|
|
ruleset_version: run.ruleset_version,
|
|
difficulty_preset: parse_difficulty_lossy(&run.difficulty_preset),
|
|
server_result: parse_server_result_lossy(run.server_result.as_deref()),
|
|
score_summary,
|
|
leaderboard_score: run.leaderboard_score,
|
|
anti_cheat_flags: parse_string_vec(&run.anti_cheat_flags_json),
|
|
updated_at: format_timestamp_micros(
|
|
run.server_finished_at_micros
|
|
.unwrap_or(run.server_started_at_micros),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn parse_run_record(
|
|
value: BarkBattleRunRecord,
|
|
request_context: &RequestContext,
|
|
) -> Result<BarkBattleRunSnapshotRecord, Response> {
|
|
serde_json::from_value(value).map_err(|error| {
|
|
bark_battle_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": format!("Bark Battle run JSON 解析失败: {error}"),
|
|
})),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn parse_draft_snapshot_record(
|
|
value: Value,
|
|
request_context: &RequestContext,
|
|
) -> Result<BarkBattleDraftConfigSnapshotRecord, Response> {
|
|
serde_json::from_value(value)
|
|
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "draft config", error))
|
|
}
|
|
|
|
fn parse_runtime_snapshot_record(
|
|
value: Value,
|
|
request_context: &RequestContext,
|
|
) -> Result<BarkBattleRuntimeConfigSnapshotRecord, Response> {
|
|
serde_json::from_value(value)
|
|
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "runtime config", error))
|
|
}
|
|
|
|
fn map_draft_config_record(
|
|
value: Value,
|
|
request_context: &RequestContext,
|
|
) -> Result<BarkBattleDraftConfig, Response> {
|
|
let snapshot = parse_draft_snapshot_record(value, request_context)?;
|
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
|
Ok(BarkBattleDraftConfig {
|
|
draft_id: snapshot.draft_id,
|
|
title: editor_config.title,
|
|
description: editor_config.description,
|
|
theme_preset: editor_config.theme_preset,
|
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
|
difficulty_preset: editor_config.difficulty_preset,
|
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
|
})
|
|
}
|
|
|
|
fn map_runtime_config_record(
|
|
value: Value,
|
|
request_context: &RequestContext,
|
|
) -> Result<shared_contracts::bark_battle::BarkBattleRuntimeConfig, Response> {
|
|
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
|
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
|
let ruleset = BarkBattleRuleset::v1();
|
|
Ok(shared_contracts::bark_battle::BarkBattleRuntimeConfig {
|
|
work_id: snapshot.work_id,
|
|
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
|
|
ruleset_version: snapshot.ruleset_version,
|
|
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
|
|
duration_ms: ruleset.standard_duration_ms,
|
|
energy_min: 0.0,
|
|
energy_max: 100.0,
|
|
draw_threshold: ruleset.draw_threshold_energy as f32,
|
|
min_bark_gap_ms: ruleset.min_bark_gap_ms,
|
|
difficulty_preset: editor_config.difficulty_preset,
|
|
theme_preset: editor_config.theme_preset,
|
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
|
})
|
|
}
|
|
|
|
fn map_published_config_record(
|
|
value: Value,
|
|
request_context: &RequestContext,
|
|
) -> Result<BarkBattlePublishedConfig, Response> {
|
|
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
|
|
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
|
Ok(BarkBattlePublishedConfig {
|
|
work_id: snapshot.work_id,
|
|
draft_id: snapshot.source_draft_id,
|
|
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
|
|
ruleset_version: snapshot.ruleset_version,
|
|
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
|
|
title: editor_config.title,
|
|
description: editor_config.description,
|
|
theme_preset: editor_config.theme_preset,
|
|
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
|
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
|
difficulty_preset: editor_config.difficulty_preset,
|
|
leaderboard_enabled: editor_config.leaderboard_enabled,
|
|
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
|
published_at: format_timestamp_micros(snapshot.published_at_micros),
|
|
})
|
|
}
|
|
|
|
fn parse_editor_config_record(
|
|
config_json: &str,
|
|
request_context: &RequestContext,
|
|
) -> Result<BarkBattleConfigEditorPayload, Response> {
|
|
serde_json::from_str(config_json).map_err(|error| {
|
|
bark_battle_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": format!("Bark Battle configJson 解析失败: {error}"),
|
|
})),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn bark_battle_snapshot_parse_error(
|
|
request_context: &RequestContext,
|
|
label: &str,
|
|
error: serde_json::Error,
|
|
) -> Response {
|
|
bark_battle_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": format!("Bark Battle {label} JSON 解析失败: {error}"),
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn bark_battle_json<T>(
|
|
payload: Result<Json<T>, JsonRejection>,
|
|
request_context: &RequestContext,
|
|
) -> Result<Json<T>, Response> {
|
|
payload.map_err(|error| {
|
|
bark_battle_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": error.body_text(),
|
|
})),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn ensure_non_empty(
|
|
request_context: &RequestContext,
|
|
value: &str,
|
|
field_name: &str,
|
|
) -> Result<(), Response> {
|
|
if value.trim().is_empty() {
|
|
return Err(bark_battle_bad_request(
|
|
request_context,
|
|
&format!("{field_name} is required"),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
|
|
bark_battle_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": message,
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn map_bark_battle_client_error(error: SpacetimeClientError) -> AppError {
|
|
let status = match &error {
|
|
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("不存在")
|
|
|| message.contains("not found")
|
|
|| message.contains("does not exist") =>
|
|
{
|
|
StatusCode::NOT_FOUND
|
|
}
|
|
SpacetimeClientError::Procedure(message)
|
|
if message.contains("不能为空")
|
|
|| message.contains("不匹配")
|
|
|| message.contains("不支持")
|
|
|| message.contains("已结束")
|
|
|| message.contains("已存在") =>
|
|
{
|
|
StatusCode::BAD_REQUEST
|
|
}
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
};
|
|
|
|
AppError::from_status(status).with_details(json!({
|
|
"provider": "spacetimedb",
|
|
"message": error.to_string(),
|
|
}))
|
|
}
|
|
|
|
fn bark_battle_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
|
let mut response = error.into_response_with_context(Some(request_context));
|
|
response.headers_mut().insert(
|
|
HeaderName::from_static("x-genarrative-provider"),
|
|
header::HeaderValue::from_static(BARK_BATTLE_RUNTIME_PROVIDER),
|
|
);
|
|
response
|
|
}
|
|
|
|
fn parse_client_time_to_micros(value: &str) -> Result<i64, String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("client timestamp is required".to_string());
|
|
}
|
|
if let Ok(micros) = trimmed.parse::<i64>() {
|
|
return Ok(micros);
|
|
}
|
|
parse_rfc3339(trimmed).map(offset_datetime_to_unix_micros)
|
|
}
|
|
|
|
fn current_utc_micros() -> i64 {
|
|
let duration = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default();
|
|
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
|
}
|
|
|
|
fn unit_to_millis(value: f32) -> u32 {
|
|
(value.clamp(0.0, 1.0) * 1_000.0).round() as u32
|
|
}
|
|
|
|
fn energy_to_millis(value: f32) -> u32 {
|
|
(value.clamp(0.0, 100.0) * 1_000.0).round() as u32
|
|
}
|
|
|
|
fn derive_server_opponent_final_energy(metrics: &BarkBattleDerivedMetrics) -> f32 {
|
|
let ruleset = BarkBattleRuleset::v1();
|
|
let pressure = (metrics.average_volume * 24.0)
|
|
+ (metrics.max_volume * 16.0)
|
|
+ (metrics.trigger_count as f32 * 0.35)
|
|
+ (metrics.combo_max as f32 * 0.2);
|
|
(ruleset.max_final_energy - pressure).clamp(ruleset.min_final_energy, ruleset.max_final_energy)
|
|
}
|
|
|
|
fn difficulty_to_spacetime_string(value: &BarkBattleDifficultyPreset) -> &'static str {
|
|
match value {
|
|
BarkBattleDifficultyPreset::Easy => "easy",
|
|
BarkBattleDifficultyPreset::Normal => "normal",
|
|
BarkBattleDifficultyPreset::Hard => "hard",
|
|
}
|
|
}
|
|
|
|
fn parse_difficulty(value: &str) -> Result<BarkBattleDifficultyPreset, AppError> {
|
|
match value {
|
|
"easy" => Ok(BarkBattleDifficultyPreset::Easy),
|
|
"normal" => Ok(BarkBattleDifficultyPreset::Normal),
|
|
"hard" => Ok(BarkBattleDifficultyPreset::Hard),
|
|
_ => Err(
|
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
|
"message": format!("Bark Battle difficultyPreset 不支持: {value}"),
|
|
})),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn parse_difficulty_lossy(value: &str) -> BarkBattleDifficultyPreset {
|
|
parse_difficulty(value).unwrap_or(BarkBattleDifficultyPreset::Normal)
|
|
}
|
|
|
|
fn parse_finish_status(value: &str) -> BarkBattleFinishStatus {
|
|
match value {
|
|
"accepted" => BarkBattleFinishStatus::Accepted,
|
|
"accepted_with_flags" => BarkBattleFinishStatus::AcceptedWithFlags,
|
|
"rejected" => BarkBattleFinishStatus::Rejected,
|
|
_ => BarkBattleFinishStatus::Rejected,
|
|
}
|
|
}
|
|
|
|
fn parse_server_result_lossy(value: Option<&str>) -> BarkBattleServerResult {
|
|
match value {
|
|
Some("player_win") => BarkBattleServerResult::PlayerWin,
|
|
Some("opponent_win") => BarkBattleServerResult::OpponentWin,
|
|
Some("draw") => BarkBattleServerResult::Draw,
|
|
_ => BarkBattleServerResult::Draw,
|
|
}
|
|
}
|
|
|
|
fn parse_score_summary(metrics_json: &str) -> Option<BarkBattleScoreSummary> {
|
|
let value: Value = serde_json::from_str(metrics_json).ok()?;
|
|
let derived = value.get("derivedMetrics")?;
|
|
Some(BarkBattleScoreSummary {
|
|
duration_ms: value.get("durationMs")?.as_u64()?,
|
|
trigger_count: derived
|
|
.get("triggerCount")?
|
|
.as_u64()?
|
|
.min(u64::from(u32::MAX)) as u32,
|
|
max_volume: derived.get("maxVolume")?.as_f64()? as f32,
|
|
average_volume: derived.get("averageVolume")?.as_f64()? as f32,
|
|
final_energy: derived.get("finalEnergy")?.as_f64()? as f32,
|
|
combo_max: derived.get("comboMax")?.as_u64()?.min(u64::from(u32::MAX)) as u32,
|
|
})
|
|
}
|
|
|
|
fn parse_string_vec(value: &str) -> Vec<String> {
|
|
serde_json::from_str(value).unwrap_or_default()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
|
|
let seconds = micros.div_euclid(1_000_000);
|
|
let subsec_micros = micros.rem_euclid(1_000_000);
|
|
let Ok(value) = OffsetDateTime::from_unix_timestamp(seconds)
|
|
.map(|value| value + TimeDuration::microseconds(subsec_micros))
|
|
else {
|
|
return format_timestamp_micros(micros);
|
|
};
|
|
format_rfc3339(value).unwrap_or_else(|_| format_timestamp_micros(micros))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn unit_and_energy_are_clamped_to_spacetime_millis() {
|
|
assert_eq!(unit_to_millis(0.625), 625);
|
|
assert_eq!(unit_to_millis(3.0), 1000);
|
|
assert_eq!(energy_to_millis(88.456), 88_456);
|
|
assert_eq!(energy_to_millis(120.0), 100_000);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_rfc3339_and_numeric_client_timestamps() {
|
|
assert_eq!(
|
|
parse_client_time_to_micros("1713686401234567").unwrap(),
|
|
1_713_686_401_234_567
|
|
);
|
|
assert_eq!(
|
|
parse_client_time_to_micros("2024-04-21T04:00:01.234567Z").unwrap(),
|
|
1_713_672_001_234_567
|
|
);
|
|
}
|
|
}
|