Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -5,11 +5,29 @@
|
||||
use shared_kernel::normalize_required_string;
|
||||
|
||||
use crate::{
|
||||
BigFishAssetSlotSnapshot, build_asset_coverage,
|
||||
commands::EvaluateBigFishPublishReadinessCommand, domain::BigFishPublishReadiness,
|
||||
errors::BigFishApplicationError, events::BigFishDomainEvent,
|
||||
BIG_FISH_DEFAULT_LEVEL_COUNT, BIG_FISH_TARGET_WILD_COUNT, BigFishAssetSlotSnapshot,
|
||||
build_asset_coverage,
|
||||
commands::{
|
||||
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
|
||||
},
|
||||
domain::{
|
||||
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
|
||||
BigFishRuntimeSnapshot, BigFishVector2,
|
||||
},
|
||||
errors::BigFishApplicationError,
|
||||
events::BigFishDomainEvent,
|
||||
};
|
||||
|
||||
const VIEW_WIDTH: f32 = 720.0;
|
||||
const VIEW_HEIGHT: f32 = 1280.0;
|
||||
const WORLD_HALF_WIDTH: f32 = 1400.0;
|
||||
const WORLD_HALF_HEIGHT: f32 = 2400.0;
|
||||
const DEFAULT_WILD_COUNT: usize = 28;
|
||||
const LEADER_SPEED: f32 = 210.0;
|
||||
const FOLLOWER_SPEED: f32 = 170.0;
|
||||
const WILD_SPEED: f32 = 74.0;
|
||||
const TICK_SECONDS: f32 = 0.1;
|
||||
|
||||
/// 发布门禁应用结果,供 adapter 持久化快照或转换成 API DTO。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct EvaluateBigFishPublishReadinessResult {
|
||||
@@ -17,6 +35,13 @@ pub struct EvaluateBigFishPublishReadinessResult {
|
||||
pub events: Vec<BigFishDomainEvent>,
|
||||
}
|
||||
|
||||
/// 运行态推进应用结果。
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BigFishRuntimeResult {
|
||||
pub snapshot: BigFishRuntimeSnapshot,
|
||||
pub events: Vec<BigFishDomainEvent>,
|
||||
}
|
||||
|
||||
/// 评估 Big Fish 作品是否具备发布条件。
|
||||
///
|
||||
/// 规则只依赖草稿和资产槽:草稿必须存在,等级主图、基础动作和背景图
|
||||
@@ -51,6 +76,508 @@ pub fn evaluate_publish_readiness(
|
||||
})
|
||||
}
|
||||
|
||||
/// 开始一局 Big Fish 运行态。
|
||||
///
|
||||
/// 领域层生成初始实体池,adapter 只负责把快照序列化并写入运行表。
|
||||
pub fn start_big_fish_run(
|
||||
command: StartBigFishRunCommand,
|
||||
) -> Result<BigFishRuntimeResult, BigFishApplicationError> {
|
||||
let run_id =
|
||||
normalize_required_string(command.run_id).ok_or(BigFishApplicationError::MissingRunId)?;
|
||||
let session_id = normalize_required_string(command.session_id)
|
||||
.ok_or(BigFishApplicationError::MissingSessionId)?;
|
||||
let owner_user_id = normalize_required_string(command.owner_user_id)
|
||||
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
|
||||
let win_level = command
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.runtime_params.win_level)
|
||||
.or(command.work_level_count)
|
||||
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT)
|
||||
.clamp(1, 32);
|
||||
let wild_count = command
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.runtime_params.spawn_target_count as usize)
|
||||
.unwrap_or(BIG_FISH_TARGET_WILD_COUNT)
|
||||
.max(DEFAULT_WILD_COUNT);
|
||||
let leader = build_entity("owned-1".to_string(), 1, 0.0, 0.0);
|
||||
let mut wild_entities = vec![
|
||||
build_entity("wild-open-1".to_string(), 1, 92.0, 0.0),
|
||||
build_entity("wild-open-2".to_string(), 1, -118.0, 46.0),
|
||||
];
|
||||
while wild_entities.len() < wild_count {
|
||||
wild_entities.push(build_wild_entity(
|
||||
0,
|
||||
wild_entities.len() as u64,
|
||||
1,
|
||||
win_level,
|
||||
&leader.position,
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = BigFishRuntimeSnapshot {
|
||||
run_id: run_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
status: BigFishRunStatus::Running,
|
||||
tick: 0,
|
||||
player_level: 1,
|
||||
win_level,
|
||||
leader_entity_id: Some(leader.entity_id.clone()),
|
||||
owned_entities: vec![leader.clone()],
|
||||
wild_entities,
|
||||
camera_center: leader.position,
|
||||
last_input: BigFishVector2 { x: 0.0, y: 0.0 },
|
||||
event_log: vec!["开局生成同级可收编目标".to_string()],
|
||||
updated_at_micros: command.started_at_micros,
|
||||
};
|
||||
|
||||
Ok(BigFishRuntimeResult {
|
||||
snapshot,
|
||||
events: vec![BigFishDomainEvent::RuntimeRunStarted {
|
||||
run_id,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
occurred_at_micros: command.started_at_micros,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据最新输入推进一帧运行态。
|
||||
///
|
||||
/// 这里是 Big Fish 运行态真相源;前端只能提交输入并渲染返回快照。
|
||||
pub fn submit_big_fish_input(
|
||||
command: SubmitBigFishInputCommand,
|
||||
) -> Result<BigFishRuntimeResult, BigFishApplicationError> {
|
||||
let owner_user_id = normalize_required_string(command.owner_user_id)
|
||||
.ok_or(BigFishApplicationError::MissingOwnerUserId)?;
|
||||
if !command.x.is_finite() || !command.y.is_finite() {
|
||||
return Err(BigFishApplicationError::InvalidRuntimeInput);
|
||||
}
|
||||
|
||||
let mut snapshot = command.current_snapshot;
|
||||
if snapshot.status != BigFishRunStatus::Running {
|
||||
return Ok(BigFishRuntimeResult {
|
||||
snapshot,
|
||||
events: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let next_tick = snapshot.tick.saturating_add(1);
|
||||
let normalized_input = normalize_vector(command.x, command.y);
|
||||
let mut sorted_owned = refresh_leader(std::mem::take(&mut snapshot.owned_entities));
|
||||
let Some(current_leader) = sorted_owned.first().cloned() else {
|
||||
snapshot.status = BigFishRunStatus::Failed;
|
||||
snapshot.event_log = tail_events(vec!["己方实体归零,本局失败".to_string()]);
|
||||
snapshot.updated_at_micros = command.submitted_at_micros;
|
||||
return Ok(BigFishRuntimeResult {
|
||||
events: settlement_events(&snapshot, owner_user_id, command.submitted_at_micros),
|
||||
snapshot,
|
||||
});
|
||||
};
|
||||
|
||||
let next_leader = move_leader(¤t_leader, &normalized_input);
|
||||
let mut owned_entities = vec![next_leader.clone()];
|
||||
for (index, follower) in sorted_owned.drain(1..).enumerate() {
|
||||
owned_entities.push(move_follower(&follower, &next_leader, index + 1));
|
||||
}
|
||||
let mut wild_entities = snapshot
|
||||
.wild_entities
|
||||
.into_iter()
|
||||
.map(|entity| move_wild_entity(&entity, next_tick))
|
||||
.collect::<Vec<_>>();
|
||||
let mut events = snapshot.event_log;
|
||||
let mut removed_wild = Vec::<String>::new();
|
||||
let mut removed_owned = Vec::<String>::new();
|
||||
let mut newly_owned = Vec::<BigFishRuntimeEntitySnapshot>::new();
|
||||
|
||||
for owned in &owned_entities {
|
||||
if removed_owned.contains(&owned.entity_id) {
|
||||
continue;
|
||||
}
|
||||
for wild in &wild_entities {
|
||||
if removed_wild.contains(&wild.entity_id) {
|
||||
continue;
|
||||
}
|
||||
if distance(owned, wild) > owned.radius + wild.radius {
|
||||
continue;
|
||||
}
|
||||
if owned.level >= wild.level {
|
||||
removed_wild.push(wild.entity_id.clone());
|
||||
newly_owned.push(build_entity(
|
||||
format!("owned-from-{}-{next_tick}", wild.entity_id),
|
||||
wild.level,
|
||||
wild.position.x,
|
||||
wild.position.y,
|
||||
));
|
||||
events.push(format!("收编 {} 级实体", wild.level));
|
||||
} else {
|
||||
removed_owned.push(owned.entity_id.clone());
|
||||
events.push(format!(
|
||||
"{} 级己方实体被 {} 级野生实体吃掉",
|
||||
owned.level, wild.level
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
owned_entities.retain(|entity| !removed_owned.contains(&entity.entity_id));
|
||||
owned_entities.extend(newly_owned);
|
||||
wild_entities.retain(|entity| !removed_wild.contains(&entity.entity_id));
|
||||
|
||||
let merge_result = merge_owned_entities(owned_entities, next_tick);
|
||||
owned_entities = refresh_leader(merge_result.owned_entities);
|
||||
events.extend(merge_result.events);
|
||||
|
||||
let player_level = owned_entities
|
||||
.iter()
|
||||
.map(|entity| entity.level)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let leader = owned_entities.first().cloned();
|
||||
let camera_center = leader
|
||||
.as_ref()
|
||||
.map(|entity| entity.position.clone())
|
||||
.unwrap_or(snapshot.camera_center);
|
||||
|
||||
wild_entities = wild_entities
|
||||
.into_iter()
|
||||
.filter_map(|entity| {
|
||||
let should_cull = entity.level == player_level
|
||||
|| entity.level >= player_level.saturating_add(3)
|
||||
|| entity.level.saturating_add(3) <= player_level;
|
||||
let offscreen_seconds = if should_cull && is_offscreen(&entity, &camera_center) {
|
||||
entity.offscreen_seconds + TICK_SECONDS
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(offscreen_seconds < 3.0).then_some(BigFishRuntimeEntitySnapshot {
|
||||
offscreen_seconds,
|
||||
..entity
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
while wild_entities.len() < DEFAULT_WILD_COUNT {
|
||||
wild_entities.push(build_wild_entity(
|
||||
next_tick,
|
||||
wild_entities.len() as u64 + next_tick,
|
||||
player_level.max(1),
|
||||
snapshot.win_level,
|
||||
&camera_center,
|
||||
));
|
||||
}
|
||||
|
||||
let status = if owned_entities.is_empty() {
|
||||
events.push("己方实体归零,本局失败".to_string());
|
||||
BigFishRunStatus::Failed
|
||||
} else if player_level >= snapshot.win_level {
|
||||
events.push("获得最高等级实体,通关".to_string());
|
||||
BigFishRunStatus::Won
|
||||
} else {
|
||||
BigFishRunStatus::Running
|
||||
};
|
||||
|
||||
let next_snapshot = BigFishRuntimeSnapshot {
|
||||
run_id: snapshot.run_id,
|
||||
session_id: snapshot.session_id,
|
||||
status,
|
||||
tick: next_tick,
|
||||
player_level,
|
||||
win_level: snapshot.win_level,
|
||||
leader_entity_id: leader.map(|entity| entity.entity_id),
|
||||
owned_entities,
|
||||
wild_entities,
|
||||
camera_center,
|
||||
last_input: normalized_input,
|
||||
event_log: tail_events(events),
|
||||
updated_at_micros: command.submitted_at_micros,
|
||||
};
|
||||
let events = settlement_events(&next_snapshot, owner_user_id, command.submitted_at_micros);
|
||||
|
||||
Ok(BigFishRuntimeResult {
|
||||
snapshot: next_snapshot,
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serialize_runtime_snapshot(
|
||||
snapshot: &BigFishRuntimeSnapshot,
|
||||
) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(snapshot)
|
||||
}
|
||||
|
||||
pub fn deserialize_runtime_snapshot(
|
||||
value: &str,
|
||||
) -> Result<BigFishRuntimeSnapshot, serde_json::Error> {
|
||||
serde_json::from_str(value)
|
||||
}
|
||||
|
||||
fn build_entity(entity_id: String, level: u32, x: f32, y: f32) -> BigFishRuntimeEntitySnapshot {
|
||||
BigFishRuntimeEntitySnapshot {
|
||||
entity_id,
|
||||
level,
|
||||
position: BigFishVector2 { x, y },
|
||||
radius: entity_radius(level),
|
||||
offscreen_seconds: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_radius(level: u32) -> f32 {
|
||||
18.0 + level as f32 * 4.0
|
||||
}
|
||||
|
||||
fn normalize_vector(x: f32, y: f32) -> BigFishVector2 {
|
||||
let length = (x * x + y * y).sqrt();
|
||||
if length <= 0.001 {
|
||||
return BigFishVector2 { x: 0.0, y: 0.0 };
|
||||
}
|
||||
let capped = length.min(1.0);
|
||||
BigFishVector2 {
|
||||
x: (x / length) * capped,
|
||||
y: (y / length) * capped,
|
||||
}
|
||||
}
|
||||
|
||||
fn distance(first: &BigFishRuntimeEntitySnapshot, second: &BigFishRuntimeEntitySnapshot) -> f32 {
|
||||
let x = first.position.x - second.position.x;
|
||||
let y = first.position.y - second.position.y;
|
||||
(x * x + y * y).sqrt()
|
||||
}
|
||||
|
||||
fn clamp(value: f32, min: f32, max: f32) -> f32 {
|
||||
value.max(min).min(max)
|
||||
}
|
||||
|
||||
fn spawn_level(player_level: u32, win_level: u32, index: u64) -> u32 {
|
||||
if player_level <= 1 && index % 4 < 2 {
|
||||
return 1;
|
||||
}
|
||||
let deltas = [-2_i32, -1, 1, 2];
|
||||
let delta = deltas[(index as usize) % deltas.len()];
|
||||
(player_level as i32 + delta).clamp(1, win_level as i32) as u32
|
||||
}
|
||||
|
||||
fn spawn_position(center: &BigFishVector2, index: u64) -> BigFishVector2 {
|
||||
let side = index % 4;
|
||||
let offset = ((index * 97) % 980) as f32 - 490.0;
|
||||
match side {
|
||||
0 => BigFishVector2 {
|
||||
x: center.x - VIEW_WIDTH * 0.72,
|
||||
y: center.y + offset,
|
||||
},
|
||||
1 => BigFishVector2 {
|
||||
x: center.x + VIEW_WIDTH * 0.72,
|
||||
y: center.y + offset,
|
||||
},
|
||||
2 => BigFishVector2 {
|
||||
x: center.x + offset,
|
||||
y: center.y - VIEW_HEIGHT * 0.64,
|
||||
},
|
||||
_ => BigFishVector2 {
|
||||
x: center.x + offset,
|
||||
y: center.y + VIEW_HEIGHT * 0.64,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wild_entity(
|
||||
tick: u64,
|
||||
index: u64,
|
||||
player_level: u32,
|
||||
win_level: u32,
|
||||
center: &BigFishVector2,
|
||||
) -> BigFishRuntimeEntitySnapshot {
|
||||
let level = spawn_level(player_level, win_level, index);
|
||||
let position = spawn_position(center, index);
|
||||
build_entity(
|
||||
format!("wild-{tick}-{index}"),
|
||||
level,
|
||||
position.x,
|
||||
position.y,
|
||||
)
|
||||
}
|
||||
|
||||
fn move_leader(
|
||||
leader: &BigFishRuntimeEntitySnapshot,
|
||||
input: &BigFishVector2,
|
||||
) -> BigFishRuntimeEntitySnapshot {
|
||||
BigFishRuntimeEntitySnapshot {
|
||||
position: BigFishVector2 {
|
||||
x: clamp(
|
||||
leader.position.x + input.x * LEADER_SPEED * TICK_SECONDS,
|
||||
-WORLD_HALF_WIDTH,
|
||||
WORLD_HALF_WIDTH,
|
||||
),
|
||||
y: clamp(
|
||||
leader.position.y + input.y * LEADER_SPEED * TICK_SECONDS,
|
||||
-WORLD_HALF_HEIGHT,
|
||||
WORLD_HALF_HEIGHT,
|
||||
),
|
||||
},
|
||||
..leader.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn move_follower(
|
||||
follower: &BigFishRuntimeEntitySnapshot,
|
||||
leader: &BigFishRuntimeEntitySnapshot,
|
||||
index: usize,
|
||||
) -> BigFishRuntimeEntitySnapshot {
|
||||
let slot_y = (index as f32 * 0.7).sin() * 42.0;
|
||||
let target = BigFishVector2 {
|
||||
x: leader.position.x - 52.0 - index as f32 * 10.0,
|
||||
y: leader.position.y + slot_y,
|
||||
};
|
||||
let delta_x = target.x - follower.position.x;
|
||||
let delta_y = target.y - follower.position.y;
|
||||
let direction = normalize_vector(delta_x, delta_y);
|
||||
let step = (FOLLOWER_SPEED * TICK_SECONDS).min((delta_x * delta_x + delta_y * delta_y).sqrt());
|
||||
BigFishRuntimeEntitySnapshot {
|
||||
position: BigFishVector2 {
|
||||
x: follower.position.x + direction.x * step,
|
||||
y: follower.position.y + direction.y * step,
|
||||
},
|
||||
..follower.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn move_wild_entity(
|
||||
entity: &BigFishRuntimeEntitySnapshot,
|
||||
tick: u64,
|
||||
) -> BigFishRuntimeEntitySnapshot {
|
||||
let phase =
|
||||
tick as f32 * 0.23 + entity.level as f32 * 0.91 + entity.entity_id.len() as f32 * 0.13;
|
||||
BigFishRuntimeEntitySnapshot {
|
||||
position: BigFishVector2 {
|
||||
x: clamp(
|
||||
entity.position.x
|
||||
+ phase.cos() * (WILD_SPEED + entity.level as f32 * 3.0) * TICK_SECONDS,
|
||||
-WORLD_HALF_WIDTH,
|
||||
WORLD_HALF_WIDTH,
|
||||
),
|
||||
y: clamp(
|
||||
entity.position.y
|
||||
+ (phase * 0.72).sin()
|
||||
* (WILD_SPEED + entity.level as f32 * 3.0)
|
||||
* TICK_SECONDS,
|
||||
-WORLD_HALF_HEIGHT,
|
||||
WORLD_HALF_HEIGHT,
|
||||
),
|
||||
},
|
||||
..entity.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct MergeOwnedEntitiesResult {
|
||||
owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
|
||||
events: Vec<String>,
|
||||
}
|
||||
|
||||
fn merge_owned_entities(
|
||||
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
|
||||
tick: u64,
|
||||
) -> MergeOwnedEntitiesResult {
|
||||
let mut events = Vec::new();
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for level in 1..32 {
|
||||
let same_level = owned_entities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entity)| entity.level == level)
|
||||
.take(3)
|
||||
.map(|(index, entity)| (index, entity.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
if same_level.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let center =
|
||||
same_level
|
||||
.iter()
|
||||
.fold(BigFishVector2 { x: 0.0, y: 0.0 }, |acc, (_, entity)| {
|
||||
BigFishVector2 {
|
||||
x: acc.x + entity.position.x / 3.0,
|
||||
y: acc.y + entity.position.y / 3.0,
|
||||
}
|
||||
});
|
||||
let remove_indices = same_level
|
||||
.iter()
|
||||
.map(|(index, _)| *index)
|
||||
.collect::<Vec<_>>();
|
||||
owned_entities = owned_entities
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, entity)| (!remove_indices.contains(&index)).then_some(entity))
|
||||
.collect();
|
||||
owned_entities.push(build_entity(
|
||||
format!("owned-merge-{}-{tick}", level + 1),
|
||||
level + 1,
|
||||
center.x,
|
||||
center.y,
|
||||
));
|
||||
events.push(format!("3 个 {level} 级实体合成 {} 级", level + 1));
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MergeOwnedEntitiesResult {
|
||||
owned_entities,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_offscreen(entity: &BigFishRuntimeEntitySnapshot, camera_center: &BigFishVector2) -> bool {
|
||||
entity.position.x + entity.radius < camera_center.x - VIEW_WIDTH / 2.0
|
||||
|| entity.position.x - entity.radius > camera_center.x + VIEW_WIDTH / 2.0
|
||||
|| entity.position.y + entity.radius < camera_center.y - VIEW_HEIGHT / 2.0
|
||||
|| entity.position.y - entity.radius > camera_center.y + VIEW_HEIGHT / 2.0
|
||||
}
|
||||
|
||||
fn refresh_leader(
|
||||
mut owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
|
||||
) -> Vec<BigFishRuntimeEntitySnapshot> {
|
||||
owned_entities.sort_by(|left, right| {
|
||||
right
|
||||
.level
|
||||
.cmp(&left.level)
|
||||
.then_with(|| left.entity_id.cmp(&right.entity_id))
|
||||
});
|
||||
owned_entities
|
||||
}
|
||||
|
||||
fn tail_events(events: Vec<String>) -> Vec<String> {
|
||||
events
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn settlement_events(
|
||||
snapshot: &BigFishRuntimeSnapshot,
|
||||
owner_user_id: String,
|
||||
occurred_at_micros: i64,
|
||||
) -> Vec<BigFishDomainEvent> {
|
||||
if snapshot.status == BigFishRunStatus::Running {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![BigFishDomainEvent::RuntimeRunSettled {
|
||||
run_id: snapshot.run_id.clone(),
|
||||
session_id: snapshot.session_id.clone(),
|
||||
owner_user_id,
|
||||
status: snapshot.status.as_str().to_string(),
|
||||
occurred_at_micros,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -133,4 +660,63 @@ mod tests {
|
||||
assert!(result.readiness.publish_ready);
|
||||
assert!(result.readiness.blockers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_big_fish_run_builds_server_owned_initial_snapshot() {
|
||||
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
|
||||
let result = start_big_fish_run(StartBigFishRunCommand {
|
||||
run_id: "big-fish-run-1".to_string(),
|
||||
session_id: "big-fish-session-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
draft: Some(draft),
|
||||
work_level_count: None,
|
||||
started_at_micros: 1,
|
||||
})
|
||||
.expect("run");
|
||||
|
||||
assert_eq!(result.snapshot.status, BigFishRunStatus::Running);
|
||||
assert_eq!(result.snapshot.player_level, 1);
|
||||
assert_eq!(result.snapshot.win_level, 8);
|
||||
assert!(!result.snapshot.wild_entities.is_empty());
|
||||
assert_eq!(result.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_big_fish_input_advances_and_keeps_runtime_truth_in_domain() {
|
||||
let mut result = start_big_fish_run(StartBigFishRunCommand {
|
||||
run_id: "big-fish-run-2".to_string(),
|
||||
session_id: "big-fish-session-2".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
draft: None,
|
||||
work_level_count: Some(3),
|
||||
started_at_micros: 1,
|
||||
})
|
||||
.expect("run");
|
||||
result.snapshot.wild_entities = vec![BigFishRuntimeEntitySnapshot {
|
||||
entity_id: "wild-touching".to_string(),
|
||||
level: 1,
|
||||
position: BigFishVector2 { x: 10.0, y: 0.0 },
|
||||
radius: 22.0,
|
||||
offscreen_seconds: 0.0,
|
||||
}];
|
||||
|
||||
let advanced = submit_big_fish_input(SubmitBigFishInputCommand {
|
||||
owner_user_id: "user-1".to_string(),
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
submitted_at_micros: 2,
|
||||
current_snapshot: result.snapshot,
|
||||
})
|
||||
.expect("advanced");
|
||||
|
||||
assert_eq!(advanced.snapshot.tick, 1);
|
||||
assert!(advanced.snapshot.owned_entities.len() >= 2);
|
||||
assert!(
|
||||
advanced
|
||||
.snapshot
|
||||
.event_log
|
||||
.iter()
|
||||
.any(|event| event.contains("收编"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 用于表达创建会话、写入消息、更新资产槽和推进运行态等输入。
|
||||
|
||||
use crate::BigFishGameDraft;
|
||||
use crate::{BigFishGameDraft, domain::BigFishRuntimeSnapshot};
|
||||
|
||||
/// 评估作品是否可以发布的纯领域命令。
|
||||
///
|
||||
@@ -15,3 +15,24 @@ pub struct EvaluateBigFishPublishReadinessCommand {
|
||||
pub draft: Option<BigFishGameDraft>,
|
||||
pub evaluated_at_micros: i64,
|
||||
}
|
||||
|
||||
/// 开始一局 Big Fish 运行态的纯领域命令。
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StartBigFishRunCommand {
|
||||
pub run_id: String,
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub draft: Option<BigFishGameDraft>,
|
||||
pub work_level_count: Option<u32>,
|
||||
pub started_at_micros: i64,
|
||||
}
|
||||
|
||||
/// 提交方向输入并推进一帧的纯领域命令。
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SubmitBigFishInputCommand {
|
||||
pub owner_user_id: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub submitted_at_micros: i64,
|
||||
pub current_snapshot: BigFishRuntimeSnapshot,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//! 后续迁移创作会话、资产槽和运行态聚合时,只保留玩法状态与规则;
|
||||
//! 图片生成、OSS 与 HTTP handler 均留在 adapter 层。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
/// 发布门禁的领域判定结果。
|
||||
///
|
||||
/// 这里不保存外部任务状态,只表达当前聚合快照是否满足发布条件。
|
||||
@@ -14,3 +18,62 @@ pub struct BigFishPublishReadiness {
|
||||
pub blockers: Vec<String>,
|
||||
pub evaluated_at_micros: i64,
|
||||
}
|
||||
|
||||
/// 运行态一局的状态。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BigFishRunStatus {
|
||||
Running,
|
||||
Won,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 运行态二维坐标。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishVector2 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// 运行态实体快照。
|
||||
///
|
||||
/// 只表达服务端结算后的事实,前端不能据此反推规则并本地裁决。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishRuntimeEntitySnapshot {
|
||||
pub entity_id: String,
|
||||
pub level: u32,
|
||||
pub position: BigFishVector2,
|
||||
pub radius: f32,
|
||||
pub offscreen_seconds: f32,
|
||||
}
|
||||
|
||||
/// 运行态一局快照。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishRuntimeSnapshot {
|
||||
pub run_id: String,
|
||||
pub session_id: String,
|
||||
pub status: BigFishRunStatus,
|
||||
pub tick: u64,
|
||||
pub player_level: u32,
|
||||
pub win_level: u32,
|
||||
pub leader_entity_id: Option<String>,
|
||||
pub owned_entities: Vec<BigFishRuntimeEntitySnapshot>,
|
||||
pub wild_entities: Vec<BigFishRuntimeEntitySnapshot>,
|
||||
pub camera_center: BigFishVector2,
|
||||
pub last_input: BigFishVector2,
|
||||
pub event_log: Vec<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl BigFishRunStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Won => "won",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use std::{error::Error, fmt};
|
||||
pub enum BigFishApplicationError {
|
||||
MissingSessionId,
|
||||
MissingOwnerUserId,
|
||||
MissingRunId,
|
||||
InvalidRuntimeInput,
|
||||
}
|
||||
|
||||
impl fmt::Display for BigFishApplicationError {
|
||||
@@ -18,6 +20,8 @@ impl fmt::Display for BigFishApplicationError {
|
||||
match self {
|
||||
Self::MissingSessionId => f.write_str("big_fish.session_id 不能为空"),
|
||||
Self::MissingOwnerUserId => f.write_str("big_fish.owner_user_id 不能为空"),
|
||||
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
|
||||
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,17 @@ pub enum BigFishDomainEvent {
|
||||
blockers: Vec<String>,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
RuntimeRunStarted {
|
||||
run_id: String,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
RuntimeRunSettled {
|
||||
run_id: String,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
status: String,
|
||||
occurred_at_micros: i64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,9 +4,18 @@ mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
pub use application::{EvaluateBigFishPublishReadinessResult, evaluate_publish_readiness};
|
||||
pub use commands::EvaluateBigFishPublishReadinessCommand;
|
||||
pub use domain::BigFishPublishReadiness;
|
||||
pub use application::{
|
||||
BigFishRuntimeResult, EvaluateBigFishPublishReadinessResult, deserialize_runtime_snapshot,
|
||||
evaluate_publish_readiness, serialize_runtime_snapshot, start_big_fish_run,
|
||||
submit_big_fish_input,
|
||||
};
|
||||
pub use commands::{
|
||||
EvaluateBigFishPublishReadinessCommand, StartBigFishRunCommand, SubmitBigFishInputCommand,
|
||||
};
|
||||
pub use domain::{
|
||||
BigFishPublishReadiness, BigFishRunStatus, BigFishRuntimeEntitySnapshot,
|
||||
BigFishRuntimeSnapshot, BigFishVector2,
|
||||
};
|
||||
pub use errors::BigFishApplicationError;
|
||||
pub use events::BigFishDomainEvent;
|
||||
|
||||
@@ -343,6 +352,40 @@ pub struct BigFishPlayRecordInput {
|
||||
pub played_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunStartInput {
|
||||
pub run_id: String,
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub started_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunGetInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishInputSubmitInput {
|
||||
pub run_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub submitted_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunProcedureResult {
|
||||
pub ok: bool,
|
||||
pub run_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BigFishFieldError {
|
||||
MissingSessionId,
|
||||
@@ -352,6 +395,8 @@ pub enum BigFishFieldError {
|
||||
MissingDraft,
|
||||
InvalidLevel,
|
||||
InvalidAssetKind,
|
||||
MissingRunId,
|
||||
InvalidRuntimeInput,
|
||||
}
|
||||
|
||||
impl BigFishCreationStage {
|
||||
@@ -691,6 +736,39 @@ pub fn validate_play_record_input(input: &BigFishPlayRecordInput) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_run_start_input(input: &BigFishRunStartInput) -> Result<(), BigFishFieldError> {
|
||||
validate_session_owner(&input.session_id, &input.owner_user_id)?;
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_run_get_input(input: &BigFishRunGetInput) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_input_submit_input(
|
||||
input: &BigFishInputSubmitInput,
|
||||
) -> Result<(), BigFishFieldError> {
|
||||
if normalize_required_string(&input.run_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingRunId);
|
||||
}
|
||||
if normalize_required_string(&input.owner_user_id).is_none() {
|
||||
return Err(BigFishFieldError::MissingOwnerUserId);
|
||||
}
|
||||
if !input.x.is_finite() || !input.y.is_finite() {
|
||||
return Err(BigFishFieldError::InvalidRuntimeInput);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn serialize_anchor_pack(anchor_pack: &BigFishAnchorPack) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(anchor_pack)
|
||||
}
|
||||
@@ -903,6 +981,8 @@ impl fmt::Display for BigFishFieldError {
|
||||
Self::MissingDraft => f.write_str("big_fish.draft 尚未编译"),
|
||||
Self::InvalidLevel => f.write_str("big_fish.level 不在合法等级范围内"),
|
||||
Self::InvalidAssetKind => f.write_str("big_fish.asset_kind 或动作位非法"),
|
||||
Self::MissingRunId => f.write_str("big_fish.run_id 不能为空"),
|
||||
Self::InvalidRuntimeInput => f.write_str("big_fish.runtime_input 非法"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user