the decks were the visible half: LogicBattle carries one LogicSpellDeck per player and we wrote both as absent, so the client cleared them and the card bar came up empty. both sides get a real deck now - the player from their profile, the bot from the fullest row of predefined_decks. matchmaking no longer picks a row out of npcs.csv. it takes the location from the arena's PvpLocation column and builds an opponent avatar with its own name, arena and trophies, so the battle reads as a player match rather than a trainer one. StartMissionMessage still goes through the npc path unchanged. the battle type stays 1 on purpose. LogicGameMode::isImmediateMessageExecution is (type - 1) < 3, so 1, 2 and 3 let the client simulate locally while 0 makes it wait for the server to drive the sector - which needs the real tick loop we do not have yet.
378 lines
14 KiB
Rust
378 lines
14 KiB
Rust
use std::sync::Arc;
|
|
use logic::table;
|
|
use logic::{
|
|
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
|
|
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage,
|
|
StartMissionMessage, StopHomeLogicMessage,
|
|
};
|
|
use service_rpc::{
|
|
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
|
|
RpcService, WireMessage,
|
|
};
|
|
use titan::{Message, MessageMeta, Payload};
|
|
use crate::battle::BattleBuilder;
|
|
use crate::catalog::Catalog;
|
|
use crate::config::GameConfig;
|
|
use crate::bot::BotPlayer;
|
|
use crate::home::{build_avatar, build_deck};
|
|
use crate::home_mode::{available_server_command, HomeModeRegistry};
|
|
use crate::rewards::RewardRoller;
|
|
use crate::shop::{ShopCatalog, ShopCycle};
|
|
use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore};
|
|
use crate::time::unix_seconds;
|
|
pub const ARENA_PVP_LOCATION_COLUMN: &str = "PvpLocation";
|
|
pub const NPC_LOCATION_COLUMN: &str = "Location";
|
|
fn location_of(data: &LogicDataRef, column: &str) -> LogicDataRef {
|
|
data.data()
|
|
.map(|row| LogicDataRef::by_name(table::LOCATIONS, row.string(column)))
|
|
.unwrap_or_default()
|
|
}
|
|
pub struct GameService {
|
|
config: GameConfig,
|
|
profiles: ProfileStore,
|
|
catalog: Arc<Catalog>,
|
|
shop: Arc<ShopCatalog>,
|
|
battles: Arc<BattleBuilder>,
|
|
sessions: HomeModeRegistry,
|
|
}
|
|
impl GameService {
|
|
pub async fn bootstrap(config: GameConfig) -> std::io::Result<Arc<Self>> {
|
|
let catalog = Arc::new(Catalog::load(config.csv_root.as_deref()));
|
|
let database = config.database.clone().unwrap_or_default();
|
|
let profiles = ProfileStore::open(&database)
|
|
.await
|
|
.map_err(connect_failed)?;
|
|
let profile_count = profiles.count().await?;
|
|
let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref()));
|
|
let battles = Arc::new(BattleBuilder::new(
|
|
config.csv_root.clone().unwrap_or_else(|| "assets".into()),
|
|
));
|
|
for offer in shop.describe() {
|
|
tracing::debug!(offer, "shop offer");
|
|
}
|
|
tracing::info!(
|
|
profiles = profile_count,
|
|
offers = shop.offers.len(),
|
|
catalog = catalog.is_loaded(),
|
|
commands = LogicCommandManager::registry().len(),
|
|
"profile store ready"
|
|
);
|
|
Ok(Arc::new(Self {
|
|
config,
|
|
profiles,
|
|
catalog,
|
|
shop,
|
|
battles,
|
|
sessions: HomeModeRegistry::default(),
|
|
}))
|
|
}
|
|
pub fn profiles(&self) -> &ProfileStore {
|
|
&self.profiles
|
|
}
|
|
async fn profile(&self, account: AccountRef) -> RpcResult<PlayerProfile> {
|
|
if let Some(profile) = self.profiles.load(account).await.map_err(unavailable)? {
|
|
return Ok(profile);
|
|
}
|
|
let starter = PlayerProfile::starter(account, &self.config.starter, &self.catalog);
|
|
self.profiles
|
|
.create_if_absent(starter)
|
|
.await
|
|
.map_err(unavailable)
|
|
}
|
|
pub fn catalog(&self) -> &Arc<Catalog> {
|
|
&self.catalog
|
|
}
|
|
pub fn shop(&self) -> &Arc<ShopCatalog> {
|
|
&self.shop
|
|
}
|
|
async fn sector_state_for(
|
|
&self,
|
|
account: AccountRef,
|
|
npc: LogicDataRef,
|
|
) -> RpcResult<Vec<WireMessage>> {
|
|
let profile = self.profile(account).await?;
|
|
let avatar = build_avatar(&profile);
|
|
self.sessions
|
|
.get_or_open(account, &profile, unix_seconds() as i32)
|
|
.await
|
|
.lock()
|
|
.await
|
|
.stop_home_logic();
|
|
let location = if npc.is_none() {
|
|
location_of(&profile.arena, ARENA_PVP_LOCATION_COLUMN)
|
|
} else {
|
|
location_of(&npc, NPC_LOCATION_COLUMN)
|
|
};
|
|
let deck = build_deck(&profile);
|
|
let (opponent, opponent_deck) = if npc.is_none() {
|
|
let bot = BotPlayer::against(&avatar, &deck);
|
|
(bot.avatar, bot.deck)
|
|
} else {
|
|
(avatar.clone(), deck.clone())
|
|
};
|
|
let opponent_name = opponent.name.clone();
|
|
let battle = self
|
|
.battles
|
|
.build(
|
|
location.clone(),
|
|
npc.clone(),
|
|
profile.arena.clone(),
|
|
vec![avatar, opponent],
|
|
[Some(deck), Some(opponent_deck)],
|
|
self.config.random_seed,
|
|
)
|
|
.map_err(|error| RpcError::Rejected(error.to_string()))?;
|
|
let snapshot = battle
|
|
.snapshot()
|
|
.map_err(|error| RpcError::Rejected(error.to_string()))?;
|
|
tracing::debug!(head = %snapshot.iter().take(48).map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" "), "snapshot head");
|
|
let report = logic::battle::verify_snapshot(&snapshot);
|
|
if report.is_ok() {
|
|
tracing::debug!(steps = ?report.steps, "snapshot sections");
|
|
tracing::info!(
|
|
%account,
|
|
opponent = %opponent_name,
|
|
location = %location,
|
|
objects = battle.battle.objects.objects.len(),
|
|
bytes = snapshot.len(),
|
|
"sending a battle sector state"
|
|
);
|
|
} else {
|
|
tracing::error!(
|
|
%account,
|
|
bytes = snapshot.len(),
|
|
trailing = report.trailing,
|
|
error = report.error.as_deref().unwrap_or("-"),
|
|
steps = ?report.steps,
|
|
"the battle snapshot does not read back, refusing to send it"
|
|
);
|
|
return Err(RpcError::Rejected(report.error.unwrap_or_else(|| {
|
|
format!("{} trailing byte(s)", report.trailing)
|
|
})));
|
|
}
|
|
Ok(vec![encode(&SectorStateMessage::new(snapshot))?])
|
|
}
|
|
async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> {
|
|
let profile = self.profile(account).await?;
|
|
let fallback: Vec<LogicDataRef> = profile
|
|
.deck
|
|
.iter()
|
|
.chain(profile.collection.iter())
|
|
.map(|owned| owned.card.clone())
|
|
.collect();
|
|
Ok(RewardRoller::new(&self.catalog, fallback))
|
|
}
|
|
async fn persist(&self, account: AccountRef) -> RpcResult<()> {
|
|
let Some(session) = self.sessions.close(account).await else {
|
|
return Ok(());
|
|
};
|
|
let mut profile = self.profile(account).await?;
|
|
session.lock().await.write_back(&mut profile);
|
|
self.profiles.save(&profile).await.map_err(unavailable)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
fn encode<M>(message: &M) -> RpcResult<WireMessage>
|
|
where
|
|
M: MessageMeta + Message,
|
|
{
|
|
let payload = message
|
|
.to_bytes()
|
|
.map_err(|error| RpcError::Rejected(error.to_string()))?;
|
|
Ok(WireMessage::new(
|
|
M::MESSAGE_TYPE,
|
|
M::MESSAGE_VERSION,
|
|
payload,
|
|
))
|
|
}
|
|
#[async_trait::async_trait]
|
|
impl GameApi for GameService {
|
|
async fn load_home(
|
|
&self,
|
|
account: AccountRef,
|
|
kind: HomeRequestKind,
|
|
) -> RpcResult<Vec<WireMessage>> {
|
|
let profile = self.profile(account).await?;
|
|
let session = self
|
|
.sessions
|
|
.get_or_open(account, &profile, unix_seconds() as i32)
|
|
.await;
|
|
let (home_data, checksum, cycle, on_sale): (
|
|
OwnHomeDataMessage,
|
|
i32,
|
|
ShopCycle,
|
|
Vec<String>,
|
|
) = {
|
|
let home = session.lock().await;
|
|
(
|
|
home.own_home_data(self.config.random_seed),
|
|
home.logic().checksum(),
|
|
home.shop_cycle(),
|
|
home.logic()
|
|
.shop_spells()
|
|
.iter()
|
|
.map(|slot| slot.data.to_string())
|
|
.collect(),
|
|
)
|
|
};
|
|
tracing::info!(
|
|
%account,
|
|
?kind,
|
|
cards = home_data.home.total_spell_count(),
|
|
trophies = home_data.avatar.score,
|
|
checksum,
|
|
"serving own home data"
|
|
);
|
|
tracing::info!(
|
|
%account,
|
|
seed = cycle.seed,
|
|
weekday = cycle.weekday_index,
|
|
cards = ?on_sale,
|
|
"shop on sale"
|
|
);
|
|
let seed_changed = LogicShopSeedChangedCommand::new(
|
|
cycle.seed,
|
|
cycle.seconds_to_cycle,
|
|
cycle.weekday_index,
|
|
);
|
|
Ok(vec![
|
|
encode(&home_data)?,
|
|
encode(&AvailableServerCommandMessage {
|
|
command: Box::new(seed_changed),
|
|
})?,
|
|
])
|
|
}
|
|
async fn client_capabilities(&self, account: AccountRef, ping_ms: i32) -> RpcResult<()> {
|
|
tracing::debug!(%account, ping_ms, "client capabilities");
|
|
Ok(())
|
|
}
|
|
async fn start_mission(
|
|
&self,
|
|
account: AccountRef,
|
|
payload: Vec<u8>,
|
|
) -> RpcResult<Vec<WireMessage>> {
|
|
let mission = StartMissionMessage::from_bytes(&payload)
|
|
.map_err(|error| RpcError::Rejected(error.to_string()))?;
|
|
self.sector_state_for(account, mission.npc).await
|
|
}
|
|
async fn home_logic_stopped(
|
|
&self,
|
|
account: AccountRef,
|
|
_payload: Vec<u8>,
|
|
) -> RpcResult<Vec<WireMessage>> {
|
|
tracing::info!(%account, "matchmaking against a bot player");
|
|
self.sector_state_for(account, LogicDataRef::None).await
|
|
}
|
|
async fn end_client_turn(
|
|
&self,
|
|
account: AccountRef,
|
|
payload: Vec<u8>,
|
|
) -> RpcResult<Vec<WireMessage>> {
|
|
let turn = match EndClientTurnMessage::from_bytes(&payload) {
|
|
Ok(turn) => turn,
|
|
Err(error) => {
|
|
tracing::warn!(%account, bytes = payload.len(), %error, "undecodable client turn");
|
|
return Ok(Vec::new());
|
|
}
|
|
};
|
|
let profile = self.profile(account).await?;
|
|
let session = self
|
|
.sessions
|
|
.get_or_open(account, &profile, unix_seconds() as i32)
|
|
.await;
|
|
let roller = self.roller(account).await?;
|
|
let result = {
|
|
let mut home = session.lock().await;
|
|
home.end_client_turn(&turn, &roller, &self.shop)
|
|
};
|
|
tracing::debug!(
|
|
%account,
|
|
tick = turn.tick,
|
|
checksum = turn.checksum,
|
|
commands = turn.commands.len(),
|
|
"end client turn"
|
|
);
|
|
let mut replies = Vec::new();
|
|
if result.matchmake {
|
|
tracing::info!(%account, "client started matchmaking, stopping home logic");
|
|
replies.push(encode(&StopHomeLogicMessage::default())?);
|
|
}
|
|
if let Some(out_of_sync) = result.out_of_sync {
|
|
tracing::warn!(
|
|
%account,
|
|
tick = out_of_sync.tick,
|
|
server = out_of_sync.server_checksum,
|
|
client = out_of_sync.client_checksum,
|
|
"client and server home state diverged"
|
|
);
|
|
replies.push(encode::<OutOfSyncMessage>(&out_of_sync)?);
|
|
}
|
|
for purchase in &result.purchases {
|
|
tracing::info!(
|
|
%account,
|
|
offer = purchase.offer_id,
|
|
cost = %purchase.cost,
|
|
give = %purchase.give,
|
|
"shop purchase"
|
|
);
|
|
if let Err(error) = self.profiles.record_purchase(account, purchase).await {
|
|
tracing::warn!(%account, %error, "could not record the purchase");
|
|
}
|
|
}
|
|
for claim in result.claims {
|
|
tracing::info!(
|
|
%account,
|
|
source = claim.chest_source,
|
|
chest_id = claim.chest_id,
|
|
gold = claim.reward.as_ref().map(|reward| reward.gold).unwrap_or(0),
|
|
cards = claim.reward.as_ref().map(|reward| reward.spell_count()).unwrap_or(0),
|
|
"granting a chest reward"
|
|
);
|
|
replies.push(encode(&available_server_command(claim))?);
|
|
}
|
|
if result.changed {
|
|
let mut stored = self.profile(account).await?;
|
|
session.lock().await.write_back(&mut stored);
|
|
self.profiles.save(&stored).await.map_err(unavailable)?;
|
|
}
|
|
Ok(replies)
|
|
}
|
|
async fn disconnect(&self, account: AccountRef) -> RpcResult<()> {
|
|
self.persist(account).await?;
|
|
let sessions = self.sessions.session_count().await;
|
|
tracing::debug!(%account, sessions, "session closed");
|
|
Ok(())
|
|
}
|
|
}
|
|
#[async_trait::async_trait]
|
|
impl RpcService for GameService {
|
|
type Request = GameRequest;
|
|
type Response = GameResponse;
|
|
fn service_name(&self) -> &'static str {
|
|
"game"
|
|
}
|
|
async fn call(&self, request: GameRequest) -> RpcResult<GameResponse> {
|
|
match request {
|
|
GameRequest::LoadHome { account, kind } => {
|
|
Ok(GameResponse::messages(self.load_home(account, kind).await?))
|
|
}
|
|
GameRequest::ClientCapabilities { account, ping_ms } => {
|
|
self.client_capabilities(account, ping_ms).await?;
|
|
Ok(GameResponse::Empty)
|
|
}
|
|
GameRequest::StartMission { account, payload } => Ok(GameResponse::messages(
|
|
self.start_mission(account, payload).await?,
|
|
)),
|
|
GameRequest::HomeLogicStopped { account, payload } => Ok(GameResponse::messages(
|
|
self.home_logic_stopped(account, payload).await?,
|
|
)),
|
|
GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages(
|
|
self.end_client_turn(account, payload).await?,
|
|
)),
|
|
GameRequest::Disconnect { account } => {
|
|
self.disconnect(account).await?;
|
|
Ok(GameResponse::Empty)
|
|
}
|
|
}
|
|
}
|
|
}
|