diff --git a/crates/game-service/src/battle.rs b/crates/game-service/src/battle.rs index 3dac3ae..57da1ec 100644 --- a/crates/game-service/src/battle.rs +++ b/crates/game-service/src/battle.rs @@ -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, + decks: [Option; 2], random_seed: i32, ) -> std::io::Result { 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, diff --git a/crates/game-service/src/bot.rs b/crates/game-service/src/bot.rs new file mode 100644 index 0000000..62b0409 --- /dev/null +++ b/crates/game-service/src/bot.rs @@ -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 { + 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) + } +} diff --git a/crates/game-service/src/home.rs b/crates/game-service/src/home.rs index a7aad45..830aa67 100644 --- a/crates/game-service/src/home.rs +++ b/crates/game-service/src/home.rs @@ -48,8 +48,11 @@ fn chest_slots(profile: &PlayerProfile) -> Vec> { } 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 { diff --git a/crates/game-service/src/lib.rs b/crates/game-service/src/lib.rs index 4f105b8..febc3cb 100644 --- a/crates/game-service/src/lib.rs +++ b/crates/game-service/src/lib.rs @@ -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; diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index bde1365..7d3bf76 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -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 { let profile = self.profile(account).await?; let fallback: Vec = profile @@ -249,14 +260,8 @@ impl GameApi for GameService { account: AccountRef, _payload: Vec, ) -> RpcResult> { - 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, diff --git a/crates/logic/src/data/logic_data.rs b/crates/logic/src/data/logic_data.rs index 30a8715..66558b9 100644 --- a/crates/logic/src/data/logic_data.rs +++ b/crates/logic/src/data/logic_data.rs @@ -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),