use flatland_protocol::frame::{
read_client_message, read_server_message, write_client_message, write_server_message,
};
use flatland_protocol::types::{
HarvestResult, HiredWorkerView, NpcView, ResourceNodeState, ResourceNodeView, TickDelta,
WorkerModeView, WorkerRouteKindView, WorkerRouteStopView, WorkerRouteView, WorkerStateView,
WorkerVitalsSummary, WorkerWithdrawItemView,
};
use flatland_protocol::{
AuthCredential, ChatChannel, ChatMessage, ClientMessage, CraftResult, DeathNotice, EntityState,
Hello, Intent, InteractionNotice, ItemStack, ServerMessage, ShopCatalog, ShopOffer,
ShopOfferKind, Snapshot, Transform, UseResult, Velocity2D, Welcome, WorldClock, WorldCoord,
PROTOCOL_VERSION,
};
use uuid::Uuid;
async fn assert_server_roundtrip(msg: ServerMessage) {
let mut buf = Vec::new();
write_server_message(&mut buf, &msg)
.await
.unwrap_or_else(|e| panic!("encode failed for {msg:?}: {e}"));
let decoded = read_server_message(&mut buf.as_slice())
.await
.unwrap_or_else(|e| panic!("decode failed for {msg:?}: {e}"));
assert_eq!(msg, decoded, "round-trip mismatch");
}
async fn assert_client_roundtrip(msg: ClientMessage) {
let mut buf = Vec::new();
write_client_message(&mut buf, &msg)
.await
.unwrap_or_else(|e| panic!("encode failed for {msg:?}: {e}"));
let decoded = read_client_message(&mut buf.as_slice())
.await
.unwrap_or_else(|e| panic!("decode failed for {msg:?}: {e}"));
assert_eq!(msg, decoded, "round-trip mismatch");
}
fn empty_snapshot() -> Snapshot {
Snapshot {
tick: 1,
chunk_rev: 0,
content_rev: 0,
publish_rev: 0,
entities: Vec::new(),
resource_nodes: Vec::new(),
world_width_m: 0.0,
world_height_m: 0.0,
buildings: Vec::new(),
doors: Vec::new(),
npcs: Vec::new(),
inventory: Vec::new(),
blueprints: Vec::new(),
world_clock: WorldClock::default(),
terrain_zones: Vec::new(),
z_platforms: Vec::new(),
z_transitions: Vec::new(),
ground_drops: Vec::new(),
placed_containers: Vec::new(),
combat: None,
interior_map: None,
quest_log: Vec::new(),
hired_workers: Vec::new(),
interactables: Vec::new(),
ledger: None,
career: None,
}
}
fn empty_tick_delta() -> TickDelta {
TickDelta {
tick: 1,
entities: Vec::new(),
resource_nodes: Vec::new(),
buildings: Vec::new(),
doors: Vec::new(),
npcs: Vec::new(),
inventory: Vec::new(),
blueprints: Vec::new(),
world_clock: WorldClock::default(),
ground_drops: Vec::new(),
placed_containers: Vec::new(),
combat: None,
interior_map: None,
quest_log: Vec::new(),
hired_workers: Vec::new(),
interactables: Vec::new(),
ledger: None,
career: None,
}
}
fn bare_entity_state() -> EntityState {
EntityState {
id: 1,
transform: Transform {
position: WorldCoord::surface(0.0, 0.0),
yaw: 0.0,
velocity: Velocity2D { vx: 0.0, vy: 0.0 },
},
label: String::new(),
vitals: None,
attributes: None,
skills: None,
inside_building: None,
tile_id: None,
presentation_state: None,
sprite_mode: None,
progression_xp: None,
}
}
#[tokio::test]
async fn server_welcome_roundtrips() {
assert_server_roundtrip(ServerMessage::Welcome(Welcome {
session_id: 1,
entity_id: 1,
snapshot: empty_snapshot(),
}))
.await;
}
#[tokio::test]
async fn server_content_updated_roundtrips() {
assert_server_roundtrip(ServerMessage::ContentUpdated(empty_snapshot())).await;
}
#[tokio::test]
async fn server_tick_roundtrips() {
assert_server_roundtrip(ServerMessage::Tick(empty_tick_delta())).await;
}
#[tokio::test]
async fn server_tick_with_ordered_worker_route_roundtrips() {
let worker = HiredWorkerView {
instance_id: "worker-1".into(),
entity_id: 7,
def_id: "worker_laborer".into(),
label: "Laborer".into(),
x: 1.0,
y: 2.0,
z: 0.0,
mode: WorkerModeView::JobLoop,
state: WorkerStateView::Working,
step_label: "craft oak_to_lumber".into(),
vitals: WorkerVitalsSummary { health_pct: 100.0, stamina_pct: 80.0 },
carry_pct: 40.0,
last_error: None,
wage_copper_per_interval: 5,
effective_wage_copper: 5,
wage_meters_walked: 0.0,
lodging_container_id: Some("chest-bed-1".into()),
route: Some(WorkerRouteView {
kind: WorkerRouteKindView::Ordered,
lodging_container_id: Some("chest-bed-1".into()),
outbound_waypoints: Vec::new(),
harvest_nodes: Vec::new(),
carry_return_ratio: 0.9,
stops: vec![
WorkerRouteStopView::Waypoint { x: 1.0, y: 2.0, z: 0.0 },
WorkerRouteStopView::HarvestNode { node_id: "oak-1".into() },
WorkerRouteStopView::DepositAt { container_id: "chest-a".into(), filter: None },
WorkerRouteStopView::DepositAt {
container_id: "chest-b".into(),
filter: Some(vec!["lumber".into()]),
},
WorkerRouteStopView::TradeWith {
npc_id: Some("ada_broker".into()),
template: "lumber".into(),
sell_all: true,
},
WorkerRouteStopView::TradeWith {
npc_id: None,
template: "oak_log".into(),
sell_all: false,
},
WorkerRouteStopView::WithdrawFrom {
container_id: "chest-a".into(),
items: vec![
WorkerWithdrawItemView { template: "oak_log".into(), qty: 10, all: false },
WorkerWithdrawItemView { template: "lumber".into(), qty: 0, all: true },
],
},
WorkerRouteStopView::CraftAt {
device: "hand".into(),
blueprint: "oak_to_lumber".into(),
qty: None,
},
WorkerRouteStopView::RestIfNeeded,
WorkerRouteStopView::Wait { wait_ticks: 5 },
],
}),
known_blueprint_ids: vec!["oak_to_lumber".into()],
level: 1,
worker_xp: 0.0,
inventory: Vec::new(),
};
let mut delta = empty_tick_delta();
delta.hired_workers.push(worker);
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn server_tick_with_worker_route_none_roundtrips() {
let worker = HiredWorkerView {
instance_id: "worker-2".into(),
entity_id: 8,
def_id: "worker_laborer".into(),
label: "Laborer".into(),
x: 0.0,
y: 0.0,
z: 0.0,
mode: WorkerModeView::Companion,
state: WorkerStateView::Idle,
step_label: String::new(),
vitals: WorkerVitalsSummary { health_pct: 100.0, stamina_pct: 100.0 },
carry_pct: 0.0,
last_error: None,
wage_copper_per_interval: 5,
effective_wage_copper: 5,
wage_meters_walked: 0.0,
lodging_container_id: None,
route: None,
known_blueprint_ids: Vec::new(),
level: 1,
worker_xp: 0.0,
inventory: Vec::new(),
};
let mut delta = empty_tick_delta();
delta.hired_workers.push(worker);
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn server_intent_ack_roundtrips() {
assert_server_roundtrip(ServerMessage::IntentAck {
entity_id: 1,
seq: 1,
tick: 1,
})
.await;
}
#[tokio::test]
async fn server_chat_roundtrips() {
assert_server_roundtrip(ServerMessage::Chat(ChatMessage {
channel: ChatChannel::Nearby,
from_entity: 1,
from_name: "traveler".into(),
text: "hi".into(),
tick: 1,
}))
.await;
}
#[tokio::test]
async fn server_harvest_result_with_none_instance_id_roundtrips() {
assert_server_roundtrip(ServerMessage::HarvestResult(HarvestResult {
node_id: "crop-carrot-1".into(),
quantity: 1,
item_template: "carrot".into(),
item_instance_id: None,
}))
.await;
}
#[tokio::test]
async fn server_harvest_result_with_some_instance_id_roundtrips() {
assert_server_roundtrip(ServerMessage::HarvestResult(HarvestResult {
node_id: "crop-carrot-1".into(),
quantity: 1,
item_template: "carrot".into(),
item_instance_id: Some(Uuid::nil()),
}))
.await;
}
#[tokio::test]
async fn server_use_result_roundtrips() {
assert_server_roundtrip(ServerMessage::UseResult(UseResult {
template_id: "waterskin".into(),
hunger_restored: 0.0,
thirst_restored: 10.0,
health_restored: 0.0,
mana_restored: 0.0,
cleared_dot_ids: vec!["frost_damage".into()],
}))
.await;
}
#[tokio::test]
async fn server_craft_result_roundtrips_with_empty_vecs() {
assert_server_roundtrip(ServerMessage::CraftResult(CraftResult {
blueprint_id: "wooden_stool".into(),
outputs: Vec::new(),
consumed: Vec::new(),
batch_index: 1,
batch_total: 1,
}))
.await;
}
#[tokio::test]
async fn server_craft_result_roundtrips_with_populated_vecs() {
assert_server_roundtrip(ServerMessage::CraftResult(CraftResult {
blueprint_id: "wooden_stool".into(),
outputs: vec![ItemStack::simple("wooden_stool", 1)],
consumed: vec![ItemStack::simple("lumber", 2)],
batch_index: 1,
batch_total: 1,
}))
.await;
}
#[tokio::test]
async fn server_craft_result_vegetable_soup_roundtrips() {
assert_server_roundtrip(ServerMessage::CraftResult(CraftResult {
blueprint_id: "vegetable_soup".into(),
outputs: vec![ItemStack::simple("vegetable_soup", 1)],
consumed: vec![
ItemStack::simple("carrot", 1),
ItemStack::simple("potato", 1),
ItemStack::simple("bottle_of_water", 1),
],
batch_index: 1,
batch_total: 1,
}))
.await;
}
#[tokio::test]
async fn craft_result_followed_by_interior_tick_roundtrips() {
use flatland_protocol::types::{DoorView, NpcView};
let craft = ServerMessage::CraftResult(CraftResult {
blueprint_id: "vegetable_soup".into(),
outputs: vec![ItemStack::simple("vegetable_soup", 1)],
consumed: vec![
ItemStack::simple("carrot", 1),
ItemStack::simple("potato", 1),
ItemStack::simple("bottle_of_water", 1),
],
batch_index: 1,
batch_total: 1,
});
let mut delta = empty_tick_delta();
delta.doors.push(DoorView {
id: "cookhouse_exit".into(),
building_id: "cookhouse".into(),
x: 524.0,
y: 520.0,
open: false,
portal: Some("exit".into()),
});
delta.npcs.push(NpcView {
id: "maris_cook".into(),
label: "Maris".into(),
role: "cook".into(),
x: 526.0,
y: 523.0,
building_id: Some("cookhouse".into()),
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: true,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
});
delta.inventory = vec![
ItemStack::simple("vegetable_soup", 1),
ItemStack::simple("cooking_pot", 1),
];
let tick = ServerMessage::Tick(delta);
let mut buf = Vec::new();
write_server_message(&mut buf, &craft).await.unwrap();
write_server_message(&mut buf, &tick).await.unwrap();
let mut cursor = buf.as_slice();
let decoded_craft = read_server_message(&mut cursor).await.unwrap();
assert!(matches!(decoded_craft, ServerMessage::CraftResult(_)));
let decoded_tick = read_server_message(&mut cursor).await.unwrap();
assert!(matches!(decoded_tick, ServerMessage::Tick(_)));
}
#[tokio::test]
async fn server_death_roundtrips() {
assert_server_roundtrip(ServerMessage::Death(DeathNotice {
entity_id: 1,
respawn_x: 128.0,
respawn_y: 128.0,
message: "you died".into(),
}))
.await;
}
#[tokio::test]
async fn server_interaction_roundtrips_with_empty_inventory_delta() {
assert_server_roundtrip(ServerMessage::Interaction(InteractionNotice {
target_id: "door-1".into(),
message: "opened".into(),
coins_delta: 0,
inventory_delta: Vec::new(),
}))
.await;
}
#[tokio::test]
async fn server_interaction_roundtrips_with_populated_inventory_delta() {
assert_server_roundtrip(ServerMessage::Interaction(InteractionNotice {
target_id: "ada-broker".into(),
message: "sold".into(),
coins_delta: -5,
inventory_delta: vec![ItemStack::simple("oak_log", 3)],
}))
.await;
}
#[tokio::test]
async fn server_shop_opened_roundtrips() {
assert_server_roundtrip(ServerMessage::ShopOpened(ShopCatalog {
npc_id: "ada_broker".into(),
npc_label: "Ada".into(),
sells: vec![ShopOffer {
offer_id: "blueprint:oak_to_lumber".into(),
kind: ShopOfferKind::Blueprint,
label: "Recipe: Saw Planks".into(),
template_id: None,
blueprint_id: Some("oak_to_lumber".into()),
price_copper: 8,
affordable: true,
already_owned: false,
}],
buys: vec![],
}))
.await;
}
#[tokio::test]
async fn tick_delta_with_npc_view_none_building_id_roundtrips() {
let mut delta = empty_tick_delta();
delta.npcs.push(NpcView {
id: "eli_farmer".into(),
label: "Eli".into(),
role: "farmer".into(),
x: 10.0,
y: 10.0,
building_id: None,
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: true,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
});
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn tick_delta_with_npc_view_some_building_id_roundtrips() {
let mut delta = empty_tick_delta();
delta.npcs.push(NpcView {
id: "ada_broker".into(),
label: "Ada".into(),
role: "broker".into(),
x: 148.0,
y: 118.0,
building_id: Some("broker-hut".into()),
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: true,
tile_id: None,
behavior_state: None,
presentation_state: None,
sprite_mode: None,
});
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn tick_delta_with_wildlife_npc_view_roundtrips() {
use flatland_protocol::LifeState;
let mut delta = empty_tick_delta();
delta.npcs.push(NpcView {
id: "north-meadow-rabbits-0".into(),
label: "Rabbit".into(),
role: "critter".into(),
x: 95.0,
y: 195.0,
building_id: None,
entity_id: Some(42),
life_state: Some(LifeState::Alive),
hp_pct: Some(1.0),
can_trade: false,
tile_id: Some("npc.goblin".into()),
behavior_state: Some("chase".into()),
presentation_state: Some("pursue".into()),
sprite_mode: Some("chase".into()),
});
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn tick_delta_with_resource_sprite_mode_roundtrips() {
let mut delta = empty_tick_delta();
delta.resource_nodes.push(ResourceNodeView {
id: "oak-1".into(),
label: "Oak".into(),
x: 10.5,
y: 10.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Harvesting,
blocking: true,
blocking_radius_m: 0.8,
tile_id: Some("resource.oak_log".into()),
sprite_mode: Some("harvest".into()),
presentation_state: Some("harvesting".into()),
});
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn tick_delta_with_bare_entity_state_roundtrips() {
let mut delta = empty_tick_delta();
delta.entities.push(bare_entity_state());
assert_server_roundtrip(ServerMessage::Tick(delta)).await;
}
#[tokio::test]
async fn client_hello_dev_local_roundtrips() {
assert_client_roundtrip(ClientMessage::Hello(Hello {
client_name: "traveler".into(),
protocol_version: PROTOCOL_VERSION,
auth: AuthCredential::DevLocal,
character_id: None,
}))
.await;
}
#[tokio::test]
async fn client_disconnect_roundtrips() {
assert_client_roundtrip(ClientMessage::Disconnect).await;
}
#[tokio::test]
async fn client_intent_move_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Move {
entity_id: 1,
forward: 1.0,
strafe: 0.0,
vertical: 0.0,
sprint: false,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_stop_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Stop {
entity_id: 1,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_harvest_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Harvest {
entity_id: 1,
node_id: "crop-carrot-1".into(),
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_use_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Use {
entity_id: 1,
template_id: "waterskin".into(),
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_say_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Say {
entity_id: 1,
channel: ChatChannel::WhisperStone,
text: "hello".into(),
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_craft_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Craft {
entity_id: 1,
blueprint_id: "wooden_stool".into(),
count: Some(5),
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_interact_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Interact {
entity_id: 1,
target_id: "door-1".into(),
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_test_damage_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::TestDamage {
entity_id: 1,
amount: 25.0,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_set_target_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::SetTarget {
entity_id: 1,
target_id: 42,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_clear_target_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::ClearTarget {
entity_id: 1,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_attack_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Attack {
entity_id: 1,
target_id: Some(42),
weapon_slot: None,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_pickup_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Pickup {
entity_id: 1,
drop_id: Some("drop-1-rabbit_pelt".into()),
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_cast_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Cast {
entity_id: 1,
ability_id: "fireball".into(),
target_id: 42,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_bind_action_slot_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::BindActionSlot {
entity_id: 1,
slot_index: 2,
ability_id: "fireball".into(),
auto_enabled: false,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_use_action_slot_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::UseActionSlot {
entity_id: 1,
slot_index: 2,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_dodge_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Dodge {
entity_id: 1,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_lunge_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Lunge {
entity_id: 1,
forward: -1.0,
strafe: 0.5,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_directional_jump_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::DirectionalJump {
entity_id: 1,
forward: 1.0,
strafe: 0.0,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_intent_block_roundtrips() {
assert_client_roundtrip(ClientMessage::Intent(Intent::Block {
entity_id: 1,
enabled: true,
seq: 1,
}))
.await;
}
#[tokio::test]
async fn client_hello_api_token_roundtrips() {
assert_client_roundtrip(ClientMessage::Hello(Hello {
client_name: "bot-000".into(),
protocol_version: PROTOCOL_VERSION,
auth: AuthCredential::ApiToken {
token: "flat_tok_test".into(),
character_id: Uuid::nil(),
},
character_id: Some(Uuid::nil()),
}))
.await;
}