scroll.server/crates/game-service/src/service.rs
WiseDev 27f99ee898 match against a bot player instead of an npc
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.
2026-08-23 11:28:17 +03:00

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