feat: wire bark battle platform loop
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -34,6 +34,10 @@ use crate::{
|
||||
auth_me::auth_me,
|
||||
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
|
||||
auth_sessions::auth_sessions,
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
|
||||
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
|
||||
},
|
||||
big_fish::{
|
||||
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
|
||||
get_big_fish_session, get_big_fish_works, list_big_fish_gallery,
|
||||
@@ -1001,6 +1005,48 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/bark-battle/drafts",
|
||||
post(create_bark_battle_draft).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/bark-battle/works/publish",
|
||||
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/config",
|
||||
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/runs",
|
||||
post(start_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}",
|
||||
get(get_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}/finish",
|
||||
post(finish_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/square-hole/sessions",
|
||||
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
776
server-rs/crates/api-server/src/bark_battle.rs
Normal file
776
server-rs/crates/api-server/src/bark_battle.rs
Normal file
@@ -0,0 +1,776 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/runtime/match3d") {
|
||||
return Some("match3d");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/bark-battle") {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/square-hole") {
|
||||
return Some("square-hole");
|
||||
}
|
||||
@@ -117,6 +120,7 @@ pub(crate) fn test_creation_entry_config_response()
|
||||
test_creation_type("big-fish", false, true, 20),
|
||||
test_creation_type("puzzle", true, true, 30),
|
||||
test_creation_type("match3d", true, true, 40),
|
||||
test_creation_type("bark-battle", true, true, 45),
|
||||
test_creation_type("square-hole", false, true, 50),
|
||||
test_creation_type("visual-novel", true, false, 60),
|
||||
test_creation_type("airp", true, false, 70),
|
||||
@@ -172,6 +176,10 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
Some("bark-battle"),
|
||||
);
|
||||
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ mod auth_payload;
|
||||
mod auth_public_user;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod bark_battle;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
mod big_fish_draft_compiler;
|
||||
|
||||
@@ -57,8 +57,8 @@ use spacetime_client::{
|
||||
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
|
||||
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
@@ -2061,7 +2061,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_record_response),
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -2667,7 +2669,9 @@ fn parse_puzzle_level_records_from_module_json(
|
||||
level_name: level.level_name,
|
||||
picture_description: level.picture_description,
|
||||
picture_reference: level.picture_reference,
|
||||
background_music: level.background_music.map(map_puzzle_audio_asset_domain_record),
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_domain_record),
|
||||
candidates: level
|
||||
.candidates
|
||||
.into_iter()
|
||||
@@ -4608,8 +4612,7 @@ mod tests {
|
||||
|
||||
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||
.expect("levels should serialize");
|
||||
let payload: Value =
|
||||
serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||
assert_eq!(
|
||||
payload[0]["background_music"]["audio_src"],
|
||||
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
|
||||
|
||||
Reference in New Issue
Block a user