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.
This commit is contained in:
WiseDev 2026-08-23 11:28:17 +03:00
parent 4477b12f0a
commit 27f99ee898
6 changed files with 104 additions and 26 deletions

View file

@ -6,7 +6,7 @@ use logic::battle::{
LogicTime, LogicVector2, BATTLE_TYPE_NPC, CHARACTER_OBJECT_TYPE, DIRECTION_BOTTOM,
DIRECTION_TOP, SUBTILE_UNITS,
};
use logic::model::LogicClientAvatar;
use logic::model::{LogicClientAvatar, LogicSpellDeck};
use logic::{table, LogicDataRef, LogicDataTables, LogicRandom};
pub const LOCATION_FILE_COLUMN: &str = "FileName";
pub const CHARACTER_KING_TOWER: &str = "KingTower";
@ -132,6 +132,7 @@ impl BattleBuilder {
npc: LogicDataRef,
arena: LogicDataRef,
avatars: Vec<LogicClientAvatar>,
decks: [Option<LogicSpellDeck>; 2],
random_seed: i32,
) -> std::io::Result<LogicGameMode> {
let tilemap = self.tilemap_for(&location)?;
@ -203,6 +204,7 @@ impl BattleBuilder {
arena,
account_ids,
battle_type: BATTLE_TYPE_NPC,
decks,
objects,
leaders,
leader_towers: towers,

View file

@ -0,0 +1,54 @@
use logic::model::{LogicClientAvatar, LogicSpell, LogicSpellDeck, DECK_SLOT_COUNT};
use logic::{table, LogicDataRef, LogicDataTables};
use titan::LogicLong;
pub const BOT_NAME: &str = "Bot";
pub const DECK_SPELLS_COLUMN: &str = "Spells";
pub const DECK_LEVEL_COLUMN: &str = "SpellLevel";
pub struct BotPlayer {
pub avatar: LogicClientAvatar,
pub deck: LogicSpellDeck,
}
fn predefined_deck() -> Option<LogicSpellDeck> {
let row = LogicDataTables::instance()
.table(table::PREDEFINED_DECKS)
.and_then(|rows| {
rows.iter()
.filter(|row| row.value_count(DECK_SPELLS_COLUMN) >= DECK_SLOT_COUNT)
.last()
.cloned()
})?;
let cards = (0..DECK_SLOT_COUNT).filter_map(|slot| {
let name = row.string_at(DECK_SPELLS_COLUMN, slot);
let data = LogicDataRef::by_name(table::SPELLS, name);
if data.is_none() {
tracing::warn!(card = name, "predefined deck names a card the tables do not have");
return None;
}
let level = row.int_at(DECK_LEVEL_COLUMN, slot).max(1);
Some(LogicSpell::card(data, level - 1, 1))
});
Some(LogicSpellDeck::from_cards(cards))
}
impl BotPlayer {
pub fn matching(arena: LogicDataRef, score: i32, deck: LogicSpellDeck) -> Self {
let account = LogicLong::new(0, 0);
let mut avatar = LogicClientAvatar {
name: BOT_NAME.to_owned(),
name_set_by_user: true,
arena,
score,
..LogicClientAvatar::default()
};
avatar.avatar_id = account;
avatar.account_id = account;
avatar.home_id = account;
avatar.name_change_state = -1;
Self { avatar, deck }
}
pub fn against(player: &LogicClientAvatar, fallback: &LogicSpellDeck) -> Self {
let deck = predefined_deck()
.filter(|deck| deck.filled_slot_count() == DECK_SLOT_COUNT)
.unwrap_or_else(|| fallback.clone());
Self::matching(player.arena.clone(), player.score, deck)
}
}

View file

@ -48,8 +48,11 @@ fn chest_slots(profile: &PlayerProfile) -> Vec<Option<LogicChest>> {
}
slots
}
pub fn build_deck(profile: &PlayerProfile) -> LogicSpellDeck {
LogicSpellDeck::from_cards(profile.deck.iter().map(spell_of))
}
pub fn build_home(profile: &PlayerProfile) -> LogicClientHome {
let deck = LogicSpellDeck::from_cards(profile.deck.iter().map(spell_of));
let deck = build_deck(profile);
let collection =
LogicSpellCollection::from_spells(profile.collection.iter().map(spell_of).collect());
let mut home = LogicClientHome {

View file

@ -1,4 +1,5 @@
pub mod battle;
pub mod bot;
pub mod catalog;
pub mod config;
pub mod home;
@ -9,9 +10,10 @@ pub mod shop;
pub mod store;
pub mod time;
pub use battle::BattleBuilder;
pub use bot::BotPlayer;
pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE};
pub use config::{CardRef, DataSelector, GameConfig, StarterProfile};
pub use home::{build_avatar, build_home};
pub use home::{build_avatar, build_deck, build_home};
pub use home_mode::{HomeMode, HomeModeRegistry, TurnResult};
pub use rewards::RewardRoller;
pub use service::GameService;

View file

@ -1,5 +1,5 @@
use std::sync::Arc;
use logic::{table, LogicDataTables};
use logic::table;
use logic::{
AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage,
@ -13,12 +13,20 @@ use titan::{Message, MessageMeta, Payload};
use crate::battle::BattleBuilder;
use crate::catalog::Catalog;
use crate::config::GameConfig;
use crate::home::build_avatar;
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,
@ -90,17 +98,27 @@ impl GameService {
.lock()
.await
.stop_home_logic();
let location = npc
.data()
.map(|row| LogicDataRef::by_name(table::LOCATIONS, row.string("Location")))
.unwrap_or_default();
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.clone(), avatar],
vec![avatar, opponent],
[Some(deck), Some(opponent_deck)],
self.config.random_seed,
)
.map_err(|error| RpcError::Rejected(error.to_string()))?;
@ -113,7 +131,7 @@ impl GameService {
tracing::debug!(steps = ?report.steps, "snapshot sections");
tracing::info!(
%account,
npc = %npc,
opponent = %opponent_name,
location = %location,
objects = battle.battle.objects.objects.len(),
bytes = snapshot.len(),
@ -134,13 +152,6 @@ impl GameService {
}
Ok(vec![encode(&SectorStateMessage::new(snapshot))?])
}
fn bot_opponent(&self) -> LogicDataRef {
LogicDataTables::instance()
.table(table::NPCS)
.and_then(|rows| rows.get_at(0).cloned())
.map(LogicDataRef::from)
.unwrap_or_default()
}
async fn roller(&self, account: AccountRef) -> RpcResult<RewardRoller> {
let profile = self.profile(account).await?;
let fallback: Vec<LogicDataRef> = profile
@ -249,14 +260,8 @@ impl GameApi for GameService {
account: AccountRef,
_payload: Vec<u8>,
) -> RpcResult<Vec<WireMessage>> {
let npc = self.bot_opponent();
if npc.is_none() {
return Err(RpcError::Rejected(
"npcs.csv has no bot to match against".into(),
));
}
tracing::info!(%account, bot = %npc, "matchmaking against a bot");
self.sector_state_for(account, npc).await
tracing::info!(%account, "matchmaking against a bot player");
self.sector_state_for(account, LogicDataRef::None).await
}
async fn end_client_turn(
&self,

View file

@ -59,6 +59,18 @@ impl LogicData {
_ => 0,
}
}
pub fn string_at(&self, column: &str, index: usize) -> &str {
match (self.row(), self.column(column)) {
(Some(row), Some(column)) => row.string_at(column, index),
_ => "",
}
}
pub fn value_count(&self, column: &str) -> usize {
match (self.row(), self.column(column)) {
(Some(row), Some(column)) => row.value_count(column),
_ => 0,
}
}
pub fn string(&self, column: &str) -> &str {
match (self.row(), self.column(column)) {
(Some(row), Some(index)) => row.string(index),