Compare commits

...

3 commits

Author SHA1 Message Date
WiseDev
00f68e804b drop the python ignores 2026-08-28 23:26:44 +03:00
WiseDev
849c090e28 give the shop a typed error instead of string literals
ShopError variants are matchable; the message is the Display, rendered
at the log site, not baked into the error type.
2026-08-28 23:26:44 +03:00
WiseDev
a509529aef carry rpc over a binary frame, not json+base64
the inter-service payload is already raw bytes; postcard sends it as
length-prefixed binary instead of base64 stuffed into a json envelope.
drops the base64 module and the per-field serde shims.
2026-08-28 23:26:38 +03:00
14 changed files with 196 additions and 88 deletions

2
.gitignore vendored
View file

@ -2,5 +2,3 @@
/data
*.json.tmp
docs/protocol.md
__pycache__/
*.pyc

90
Cargo.lock generated
View file

@ -37,6 +37,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "auth-service"
version = "0.1.0"
@ -117,6 +126,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -147,6 +165,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-queue"
version = "0.3.13"
@ -221,6 +245,18 @@ dependencies = [
"serde",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -386,7 +422,6 @@ dependencies = [
"async-trait",
"logic",
"serde",
"serde_json",
"service-rpc",
"thiserror",
"titan",
@ -416,6 +451,15 @@ dependencies = [
"wasi",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@ -442,6 +486,20 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -872,6 +930,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@ -1007,6 +1078,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustls"
version = "0.23.43"
@ -1076,6 +1156,12 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@ -1136,8 +1222,8 @@ name = "service-rpc"
version = "0.1.0"
dependencies = [
"async-trait",
"postcard",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing",

View file

@ -22,6 +22,28 @@ fn gold() -> LogicDataRef {
fn diamonds() -> LogicDataRef {
LogicDataRef::by_name(table::RESOURCES, RESOURCE_DIAMONDS)
}
#[derive(Debug, thiserror::Error)]
pub enum ShopError {
#[error("not a card")]
NotACard,
#[error("not on sale")]
NotOnSale,
#[error("buy limit reached")]
BuyLimitReached,
#[error("nothing sells that")]
NotSold,
#[error("chest not sold")]
ChestNotSold,
#[error("no market price")]
NoMarketPrice,
#[error("price is not a resource")]
PriceNotResource,
#[error("not enough funds")]
CannotAfford,
#[error("unsupported currency")]
UnsupportedCurrency,
}
#[derive(Debug, Default)]
pub struct TurnResult {
pub claims: Vec<LogicClaimRewardCommand>,
@ -92,11 +114,11 @@ impl HomeMode {
_ => LogicDataRef::treasure_chest_for_arena(CHEST_FREE, arena),
}
}
fn market_cost(&self, give: &ShopEntry) -> Result<ShopEntry, &'static str> {
fn market_cost(&self, give: &ShopEntry) -> Result<ShopEntry, ShopError> {
if let Some(chest) = give.data.as_treasure_chest() {
let price = chest.shop_price();
if price < 1 {
return Err("that chest is not sold in the shop");
return Err(ShopError::ChestNotSold);
}
return Ok(ShopEntry::new(diamonds(), price));
}
@ -106,27 +128,27 @@ impl HomeMode {
LogicGlobals::resource_diamond_cost(give.count),
));
}
Err("that item has no market price")
Err(ShopError::NoMarketPrice)
}
fn pay(&mut self, cost: &ShopEntry) -> Result<(), &'static str> {
fn pay(&mut self, cost: &ShopEntry) -> Result<(), ShopError> {
if !cost.is_resource() {
return Err("the price must be a resource");
return Err(ShopError::PriceNotResource);
}
match cost.data.name() {
RESOURCE_GOLD if self.logic.spend_gold(cost.count) => Ok(()),
RESOURCE_DIAMONDS if self.logic.spend_diamonds(cost.count) => Ok(()),
RESOURCE_GOLD | RESOURCE_DIAMONDS => Err("the player cannot afford it"),
_ => Err("only gold and diamonds are accepted"),
RESOURCE_GOLD | RESOURCE_DIAMONDS => Err(ShopError::CannotAfford),
_ => Err(ShopError::UnsupportedCurrency),
}
}
fn buy_card(&mut self, card: &LogicDataRef) -> Result<Purchase, &'static str> {
let spell = card.as_spell().ok_or("that is not a card")?;
fn buy_card(&mut self, card: &LogicDataRef) -> Result<Purchase, ShopError> {
let spell = card.as_spell().ok_or(ShopError::NotACard)?;
let buy_times = self
.logic
.shop_buy_times(card)
.ok_or("that card is not on sale right now")?;
.ok_or(ShopError::NotOnSale)?;
if buy_times >= spell.shop_buy_limit() {
return Err("the buy limit for that card is reached");
return Err(ShopError::BuyLimitReached);
}
let cost = ShopEntry::new(gold(), spell.cost_in_shop(buy_times));
self.pay(&cost)?;
@ -143,14 +165,14 @@ impl HomeMode {
data: &LogicDataRef,
count: i32,
shop: &ShopCatalog,
) -> Result<Purchase, &'static str> {
) -> Result<Purchase, ShopError> {
if data.as_spell().is_some() {
return self.buy_card(data);
}
let offer = shop
.offer_for(data)
.filter(|offer| offer.give.count == count)
.ok_or("nothing in the shop sells that")?
.ok_or(ShopError::NotSold)?
.clone();
let cost = match &offer.cost {
Some(cost) => cost.clone(),
@ -161,7 +183,7 @@ impl HomeMode {
match offer.give.data.name() {
RESOURCE_GOLD => self.logic.add_gold(offer.give.count),
RESOURCE_DIAMONDS => self.logic.add_diamonds(offer.give.count),
_ => return Err("only gold and diamonds can be sold"),
_ => return Err(ShopError::UnsupportedCurrency),
}
}
Ok(Purchase {
@ -221,11 +243,11 @@ impl HomeMode {
result.changed = true;
result.purchases.push(purchase);
}
Err(reason) => {
Err(error) => {
tracing::warn!(
command = command.command_type(),
item = %data,
reason,
reason = %error,
"shop purchase rejected"
);
}

View file

@ -14,7 +14,6 @@ thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
[[bin]]

View file

@ -9,5 +9,5 @@ tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
postcard = { version = "1.1.3", features = ["alloc"] }

View file

@ -35,7 +35,6 @@ pub struct Session {
pub server_time: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum LoginOutcome {
Accepted(Session),
Rejected {
@ -44,7 +43,6 @@ pub enum LoginOutcome {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum AuthRequest {
Login {
account: AccountRef,
@ -56,7 +54,6 @@ pub enum AuthRequest {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum AuthResponse {
Login(LoginOutcome),
Resolved { known: bool },

View file

@ -1,49 +0,0 @@
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn encode(input: &[u8]) -> String {
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let triple = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(triple >> 18) as usize & 0x3F] as char);
out.push(ALPHABET[(triple >> 12) as usize & 0x3F] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(triple >> 6) as usize & 0x3F] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[triple as usize & 0x3F] as char
} else {
'='
});
}
out
}
pub fn decode(input: &str) -> Option<Vec<u8>> {
let bytes: Vec<u8> = input.bytes().filter(|byte| *byte != b'=').collect();
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
let mut accumulator: u32 = 0;
let mut bits: u32 = 0;
for byte in bytes {
let value = ALPHABET.iter().position(|candidate| *candidate == byte)? as u32;
accumulator = (accumulator << 6) | value;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((accumulator >> bits) as u8);
}
}
Some(out)
}
pub mod serde_bytes {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(value: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&super::encode(value))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let text = String::deserialize(deserializer)?;
super::decode(&text).ok_or_else(|| serde::de::Error::custom("invalid base64 payload"))
}
}

View file

@ -33,7 +33,7 @@ where
&self.endpoint
}
pub async fn call(&self, request: &Req) -> RpcResult<Res> {
let encoded = serde_json::to_vec(request)?;
let encoded = postcard::to_allocvec(request)?;
let slot = self.cursor.fetch_add(1, Ordering::Relaxed) % self.pool.len();
let mut guard = self.pool[slot].lock().await;
let mut last_error: Option<RpcError> = None;
@ -56,7 +56,7 @@ where
read_frame(stream).await
};
match exchange.await {
Ok(payload) => return Ok(serde_json::from_slice(&payload)?),
Ok(payload) => return Ok(postcard::from_bytes(&payload)?),
Err(error) => {
*guard = None;
last_error = Some(error);

View file

@ -4,7 +4,7 @@ pub enum RpcError {
#[error("transport: {0}")]
Transport(#[from] std::io::Error),
#[error("encoding: {0}")]
Encoding(#[from] serde_json::Error),
Encoding(#[from] postcard::Error),
#[error("frame of {actual} byte(s) exceeds the {limit} byte limit")]
FrameTooLarge { actual: usize, limit: usize },
#[error("peer closed the connection")]

View file

@ -28,7 +28,6 @@ pub enum HomeRequestKind {
GoHome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum GameRequest {
LoadHome {
account: AccountRef,
@ -40,17 +39,14 @@ pub enum GameRequest {
},
EndClientTurn {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
StartMission {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
HomeLogicStopped {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
BattleTick {
@ -61,7 +57,6 @@ pub enum GameRequest {
},
SectorCommand {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
CancelMatchmake {
@ -69,7 +64,6 @@ pub enum GameRequest {
},
BattleEvent {
account: AccountRef,
#[serde(with = "crate::base64::serde_bytes")]
payload: Vec<u8>,
},
Disconnect {
@ -77,7 +71,6 @@ pub enum GameRequest {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum GameResponse {
Messages { messages: Vec<WireMessage> },
Empty,

View file

@ -1,5 +1,4 @@
pub mod auth;
pub mod base64;
pub mod client;
pub mod error;
pub mod game;

View file

@ -31,9 +31,9 @@ async fn handle_connection<S: RpcService>(mut stream: TcpStream, service: Arc<S>
Err(RpcError::Closed) => return Ok(()),
Err(error) => return Err(error),
};
let decoded: S::Request = serde_json::from_slice(&request)?;
let decoded: S::Request = postcard::from_bytes(&request)?;
let response = service.call(decoded).await?;
let encoded = serde_json::to_vec(&response)?;
let encoded = postcard::to_allocvec(&response)?;
write_frame(&mut stream, &encoded).await?;
}
}

View file

@ -4,7 +4,6 @@ pub struct WireMessage {
pub message_type: u16,
#[serde(default)]
pub message_version: u16,
#[serde(with = "crate::base64::serde_bytes")]
pub payload: Vec<u8>,
}
impl WireMessage {

View file

@ -0,0 +1,64 @@
use service_rpc::{
AccountRef, AuthRequest, AuthResponse, DeviceInfo, GameRequest, GameResponse, HomeRequestKind,
LoginOutcome, Session, WireMessage,
};
fn roundtrip<T>(value: T)
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
let bytes = postcard::to_allocvec(&value).expect("encode");
let back: T = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(value, back);
}
fn session() -> Session {
Session {
account: AccountRef::new(0, 5),
pass_token: "tok".to_owned(),
session_count: 3,
play_time_seconds: 42,
days_since_started_playing: 1,
account_created_date: "2026-01-01".to_owned(),
server_time: "2026-08-28".to_owned(),
}
}
#[test]
fn game_request_survives_the_wire() {
roundtrip(GameRequest::BattleTick {
account: AccountRef::new(0, 5),
});
roundtrip(GameRequest::SectorCommand {
account: AccountRef::new(0, 5),
payload: vec![0, 1, 2, 253, 254, 255],
});
roundtrip(GameRequest::LoadHome {
account: AccountRef::new(1, 2),
kind: HomeRequestKind::Login,
});
}
#[test]
fn game_response_survives_the_wire() {
roundtrip(GameResponse::Empty);
roundtrip(GameResponse::messages(vec![
WireMessage::new(20104, 0, vec![9, 8, 7, 0, 255]),
WireMessage::new(24101, 1, Vec::new()),
]));
}
#[test]
fn auth_exchange_survives_the_wire() {
roundtrip(AuthRequest::Login {
account: AccountRef::new(0, 5),
pass_token: Some("tok".to_owned()),
device: DeviceInfo::default(),
});
roundtrip(AuthResponse::Login(LoginOutcome::Accepted(session())));
roundtrip(AuthResponse::Login(LoginOutcome::Rejected {
error_code: 11,
message: Some("banned".to_owned()),
}));
roundtrip(AuthResponse::Resolved { known: true });
}