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

@@ -16,6 +16,7 @@ module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-big-fish = { workspace = true }
module-bark-battle = { workspace = true }
module-combat = { workspace = true }
module-creative-agent = { workspace = true }
module-custom-world = { workspace = true }

View File

@@ -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(

View 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
);
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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())