diff --git a/client/Cargo.lock b/client/Cargo.lock index dd17c9e8..6c6cb4ef 100644 --- a/client/Cargo.lock +++ b/client/Cargo.lock @@ -744,9 +744,9 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] @@ -764,9 +764,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", diff --git a/client/src/action_buttons.rs b/client/src/action_buttons.rs index ad9b57a6..8580b637 100644 --- a/client/src/action_buttons.rs +++ b/client/src/action_buttons.rs @@ -106,7 +106,7 @@ fn global_move(rc: &RenderContext) -> StateUpdate { StateUpdate::move_units( rc, pos, - if pos.is_some_and(|t| rc.game.map.is_water(t)) { + if pos.is_some_and(|t| rc.game.map.is_sea(t)) { MoveIntent::Sea } else { MoveIntent::Land diff --git a/client/src/client.rs b/client/src/client.rs index dcd479f1..81f438b9 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -165,6 +165,7 @@ fn render_active_dialog(rc: &RenderContext) -> StateUpdate { ActiveDialog::Theaters(p) => custom_actions_ui::theaters(rc, p), ActiveDialog::PaymentRequest(c) => custom_phase_ui::custom_phase_payment_dialog(rc, c), + ActiveDialog::PlayerRequest(r) => custom_phase_ui::player_request_dialog(rc, r), ActiveDialog::ResourceRewardRequest(p) => custom_phase_ui::payment_reward_dialog(rc, p), ActiveDialog::AdvanceRewardRequest(r) => { custom_phase_ui::advance_reward_dialog(rc, r, &custom_phase_event_origin(rc).name()) diff --git a/client/src/client_state.rs b/client/src/client_state.rs index 4b5a5b2c..90522e85 100644 --- a/client/src/client_state.rs +++ b/client/src/client_state.rs @@ -17,10 +17,12 @@ use macroquad::prelude::*; use server::action::Action; use server::city::{City, MoodState}; use server::content::custom_phase_actions::{ - AdvanceRewardRequest, CustomPhaseRequest, PositionRequest, UnitTypeRequest, + AdvanceRewardRequest, CustomPhaseRequest, PlayerRequest, PositionRequest, UnitTypeRequest, }; +use server::cultural_influence::CulturalInfluenceResolution; use server::events::EventOrigin; -use server::game::{CulturalInfluenceResolution, CurrentMove, Game, GameState}; +use server::game::{Game, GameState}; +use server::move_units::CurrentMove; use server::position::Position; use server::status_phase::{StatusPhaseAction, StatusPhaseState}; @@ -60,6 +62,7 @@ pub enum ActiveDialog { ResourceRewardRequest(Payment), AdvanceRewardRequest(AdvanceRewardRequest), PaymentRequest(Vec), + PlayerRequest(PlayerRequest), PositionRequest(PositionRequest), UnitTypeRequest(UnitTypeRequest), UnitsRequest(UnitsSelection), @@ -98,6 +101,7 @@ impl ActiveDialog { ActiveDialog::ResourceRewardRequest(_) => "trade route selection", ActiveDialog::AdvanceRewardRequest(_) => "advance selection", ActiveDialog::PaymentRequest(_) => "custom phase payment request", + ActiveDialog::PlayerRequest(_) => "custom phase player request", ActiveDialog::PositionRequest(_) => "custom phase position request", ActiveDialog::UnitTypeRequest(_) => "custom phase unit request", ActiveDialog::UnitsRequest(_) => "custom phase units request", @@ -176,6 +180,7 @@ impl ActiveDialog { ActiveDialog::PositionRequest(r) => custom_phase_event_help(rc, r.description.as_ref()), ActiveDialog::UnitTypeRequest(r) => custom_phase_event_help(rc, r.description.as_ref()), ActiveDialog::UnitsRequest(r) => custom_phase_event_help(rc, r.description.as_ref()), + ActiveDialog::PlayerRequest(r) => custom_phase_event_help(rc, Some(&r.description)), } } @@ -541,9 +546,15 @@ impl State { } CustomPhaseRequest::SelectPosition(r) => ActiveDialog::PositionRequest(r.clone()), CustomPhaseRequest::SelectUnitType(r) => ActiveDialog::UnitTypeRequest(r.clone()), - CustomPhaseRequest::SelectUnits(r) => ActiveDialog::UnitsRequest( - UnitsSelection::new(r.needed, r.choices.clone(), r.description.clone()), - ), + CustomPhaseRequest::SelectUnits(r) => { + ActiveDialog::UnitsRequest(UnitsSelection::new( + r.player, + r.needed, + r.choices.clone(), + r.description.clone(), + )) + } + CustomPhaseRequest::SelectPlayer(r) => ActiveDialog::PlayerRequest(r.clone()), CustomPhaseRequest::BoolRequest => ActiveDialog::BoolRequest, }; } diff --git a/client/src/construct_ui.rs b/client/src/construct_ui.rs index ea569731..7802d656 100644 --- a/client/src/construct_ui.rs +++ b/client/src/construct_ui.rs @@ -9,6 +9,7 @@ use server::content::custom_actions::CustomAction; use server::map::Terrain; use server::playing_actions::{Construct, PlayingAction, Recruit}; use server::position::Position; +use server::recruit::recruit_cost; pub fn new_building_positions( building: Building, @@ -109,16 +110,15 @@ impl ConstructionPayment { city, None, ), - ConstructionProject::Units(sel) => rc - .shown_player - .recruit_cost( - &sel.amount.units, - city.position, - sel.amount.leader_name.as_ref(), - &sel.replaced_units, - None, - ) - .unwrap(), + ConstructionProject::Units(sel) => recruit_cost( + rc.shown_player, + &sel.amount.units, + city.position, + sel.amount.leader_name.as_ref(), + &sel.replaced_units, + None, + ) + .unwrap(), } .cost; diff --git a/client/src/custom_phase_ui.rs b/client/src/custom_phase_ui.rs index 2d3fbf57..0f4837b4 100644 --- a/client/src/custom_phase_ui.rs +++ b/client/src/custom_phase_ui.rs @@ -4,6 +4,7 @@ use crate::dialog_ui::{cancel_button_with_tooltip, ok_button, OkTooltip}; use crate::hex_ui::Point; use crate::layout_ui::{bottom_center_anchor, icon_pos}; use crate::payment_ui::{multi_payment_dialog, payment_dialog, Payment}; +use crate::player_ui::choose_player_dialog; use crate::render_context::RenderContext; use crate::select_ui::ConfirmSelection; use crate::unit_ui; @@ -11,7 +12,7 @@ use crate::unit_ui::{draw_unit_type, UnitHighlightType, UnitSelection}; use macroquad::math::vec2; use server::action::Action; use server::content::custom_phase_actions::{ - AdvanceRewardRequest, CustomPhaseEventAction, UnitTypeRequest, + AdvanceRewardRequest, CustomPhaseEventAction, PlayerRequest, UnitTypeRequest, }; use server::game::Game; use server::unit::Unit; @@ -77,7 +78,7 @@ pub fn unit_request_dialog(rc: &RenderContext, r: &UnitTypeRequest) -> StateUpda if draw_unit_type( rc, - &UnitHighlightType::None, + UnitHighlightType::None, Point::from_vec2(p), *u, r.player_index, @@ -96,14 +97,21 @@ pub fn unit_request_dialog(rc: &RenderContext, r: &UnitTypeRequest) -> StateUpda #[derive(Clone)] pub struct UnitsSelection { pub needed: u8, + pub player: usize, pub selectable: Vec, pub units: Vec, pub description: Option, } impl UnitsSelection { - pub fn new(needed: u8, selectable: Vec, description: Option) -> Self { + pub fn new( + player: usize, + needed: u8, + selectable: Vec, + description: Option, + ) -> Self { UnitsSelection { + player, needed, units: Vec::new(), selectable, @@ -120,6 +128,10 @@ impl UnitSelection for UnitsSelection { fn can_select(&self, _game: &Game, unit: &Unit) -> bool { self.selectable.contains(&unit.id) } + + fn player_index(&self) -> usize { + self.player + } } impl ConfirmSelection for UnitsSelection { @@ -133,7 +145,7 @@ impl ConfirmSelection for UnitsSelection { } else { OkTooltip::Invalid(format!( "Need to select {} units", - self.needed - self.units.len() as u8 + self.needed as i8 - self.units.len() as i8 )) } } @@ -147,7 +159,7 @@ pub fn select_units_dialog(rc: &RenderContext, sel: &UnitsSelection) -> StateUpd }) } -pub(crate) fn bool_request_dialog(rc: &RenderContext) -> StateUpdate { +pub fn bool_request_dialog(rc: &RenderContext) -> StateUpdate { if ok_button(rc, OkTooltip::Valid("OK".to_string())) { return bool_answer(true); } @@ -162,3 +174,9 @@ fn bool_answer(answer: bool) -> StateUpdate { answer, ))) } + +pub fn player_request_dialog(rc: &RenderContext, r: &PlayerRequest) -> StateUpdate { + choose_player_dialog(rc, &r.choices, |p| { + Action::CustomPhaseEvent(CustomPhaseEventAction::SelectPlayer(p)) + }) +} diff --git a/client/src/event_ui.rs b/client/src/event_ui.rs index 0b1e1cb6..73ec5111 100644 --- a/client/src/event_ui.rs +++ b/client/src/event_ui.rs @@ -16,7 +16,7 @@ pub fn event_help(rc: &RenderContext, origin: &EventOrigin, do_break: bool) -> V EventOrigin::Incident(id) => get_incident(*id).description(), EventOrigin::Leader(l) => { let l = rc.shown_player.get_leader(l).unwrap(); - // todo: leader should have a 2 event sources + // todo: leader should have a 2 event sources - no each event source should have a description format!( "{}, {}", l.first_ability_description, l.second_ability_description diff --git a/client/src/influence_ui.rs b/client/src/influence_ui.rs index 02e14b6e..b42de4b3 100644 --- a/client/src/influence_ui.rs +++ b/client/src/influence_ui.rs @@ -14,7 +14,7 @@ use macroquad::prelude::{draw_circle_lines, WHITE}; use server::action::Action; use server::city::City; use server::content::custom_actions::CustomAction; -use server::game::CulturalInfluenceResolution; +use server::cultural_influence::{influence_culture_boost_cost, CulturalInfluenceResolution}; use server::player::Player; use server::player_events::InfluenceCulturePossible; use server::playing_actions::{InfluenceCultureAttempt, PlayingAction}; @@ -78,7 +78,8 @@ fn show_city( closest_city_pos }; - let info = rc.game.influence_culture_boost_cost( + let info = influence_culture_boost_cost( + rc.game, player.index, start_position, city.player_index, diff --git a/client/src/local_client/bin/main.rs b/client/src/local_client/bin/main.rs index 70eaa68d..ef597497 100644 --- a/client/src/local_client/bin/main.rs +++ b/client/src/local_client/bin/main.rs @@ -4,12 +4,15 @@ use client::client::{init, render_and_update, Features, GameSyncRequest, GameSyn use macroquad::miniquad::window::set_window_size; use macroquad::prelude::{next_frame, screen_width, vec2}; use macroquad::window::screen_height; +use server::action::execute_action; +use server::advance::do_advance; use server::city::City; +use server::content::advances::get_advance; use server::game::{Game, GameData}; use server::map::Terrain; use server::position::Position; use server::resource_pile::ResourcePile; -use server::unit::{UnitType, Units}; +use server::unit::UnitType; use std::env; use std::fs::File; use std::io::BufReader; @@ -52,8 +55,9 @@ pub async fn run(mut game: Game, features: &Features) { match message { GameSyncRequest::None => {} GameSyncRequest::ExecuteAction(a) => { - game.execute_action(a, game.active_player()); - state.show_player = game.active_player(); + let player_index = game.active_player(); + execute_action(&mut game, a, player_index); + state.show_player = player_index; sync_result = GameSyncResult::Update; } GameSyncRequest::Import => { @@ -75,9 +79,8 @@ pub fn setup_local_game() -> Game { game.round = 6; game.dice_roll_outcomes = vec![1, 1, 10, 10, 10, 10, 10, 10, 10, 10]; let add_unit = |game: &mut Game, pos: &str, player_index: usize, unit_type: UnitType| { - let mut units = Units::empty(); - units += &unit_type; - game.recruit(player_index, units, Position::from_offset(pos), None, &[]); + game.get_player_mut(player_index) + .add_unit(Position::from_offset(pos), unit_type); }; let player_index1 = 0; @@ -240,9 +243,9 @@ pub fn setup_local_game() -> Game { .pieces .market = Some(1); - game.advance("Voting", player_index1); - game.advance("Free Economy", player_index1); - game.advance("Storage", player_index1); + do_advance(&mut game, &get_advance("Voting"), player_index1); + do_advance(&mut game, &get_advance("Free Economy"), player_index1); + do_advance(&mut game, &get_advance("Storage"), player_index1); game.players[player_index1].gain_resources(ResourcePile::food(5)); game diff --git a/client/src/move_ui.rs b/client/src/move_ui.rs index ec06eb7b..40432499 100644 --- a/client/src/move_ui.rs +++ b/client/src/move_ui.rs @@ -1,8 +1,14 @@ +use crate::client_state::{ActiveDialog, StateUpdate}; +use crate::dialog_ui::cancel_button_with_tooltip; +use crate::payment_ui::{payment_dialog, Payment}; +use crate::render_context::RenderContext; +use crate::unit_ui::{click_unit, unit_selection_clicked}; use macroquad::math::{u32, Vec2}; use macroquad::prelude::Texture2D; use server::action::Action; use server::events::EventOrigin; -use server::game::{CurrentMove, Game, GameState}; +use server::game::{Game, GameState}; +use server::move_units::{move_units_destinations, CurrentMove}; use server::payment::PaymentOptions; use server::player::Player; use server::position::Position; @@ -10,12 +16,6 @@ use server::resource_pile::ResourcePile; use server::unit::{MoveUnits, MovementAction, Unit, UnitType}; use std::collections::HashSet; -use crate::client_state::{ActiveDialog, StateUpdate}; -use crate::dialog_ui::cancel_button_with_tooltip; -use crate::payment_ui::{payment_dialog, Payment}; -use crate::render_context::RenderContext; -use crate::unit_ui::{click_unit, unit_selection_clicked}; - #[derive(Clone, Copy)] pub enum MoveIntent { Land, @@ -64,8 +64,7 @@ pub fn possible_destinations( let player = game.get_player(player_index); let mut modifiers = HashSet::new(); - let mut res = player - .move_units_destinations(game, units, start, None) + let mut res = move_units_destinations(player, game, units, start, None) .unwrap_or_default() .into_iter() .map(|route| { @@ -78,8 +77,7 @@ pub fn possible_destinations( player.units.iter().for_each(|u| { if u.unit_type.is_ship() - && player - .move_units_destinations(game, units, start, Some(u.id)) + && move_units_destinations(player, game, units, start, Some(u.id)) .is_ok_and(|v| v.iter().any(|route| route.destination == u.position)) { res.push(MoveDestination::Carrier(u.id)); diff --git a/client/src/player_ui.rs b/client/src/player_ui.rs index ff9a959b..a4f06f1b 100644 --- a/client/src/player_ui.rs +++ b/client/src/player_ui.rs @@ -2,9 +2,10 @@ use crate::action_buttons::action_buttons; use crate::city_ui::city_labels; use crate::client::Features; use crate::client_state::StateUpdate; +use crate::dialog_ui::{ok_button, OkTooltip}; use crate::layout_ui::{ - bottom_center_texture, bottom_right_texture, icon_pos, left_mouse_button_pressed_in_rect, - top_center_texture, ICON_SIZE, + bottom_center_texture, bottom_centered_text, bottom_right_texture, icon_pos, + left_mouse_button_pressed_in_rect, top_center_texture, ICON_SIZE, }; use crate::map_ui::terrain_name; use crate::render_context::RenderContext; @@ -15,7 +16,8 @@ use macroquad::math::vec2; use macroquad::prelude::*; use server::action::Action; use server::consts::ARMY_MOVEMENT_REQUIRED_ADVANCE; -use server::game::{CurrentMove, Game, GameState, MoveState}; +use server::game::{Game, GameState}; +use server::move_units::{CurrentMove, MoveState}; use server::playing_actions::PlayingAction; use server::resource::ResourceType; use server::unit::MovementAction; @@ -326,3 +328,18 @@ fn end_move(game: &Game) -> StateUpdate { }, ) } + +pub fn choose_player_dialog( + rc: &RenderContext, + choices: &[usize], + execute: impl Fn(usize) -> Action, +) -> StateUpdate { + let player = rc.shown_player.index; + if rc.can_control_active_player() && choices.contains(&player) { + bottom_centered_text(rc, &format!("Select {}", rc.shown_player.get_name())); + if ok_button(rc, OkTooltip::Valid("Select".to_string())) { + return StateUpdate::execute(execute(player)); + } + } + StateUpdate::None +} diff --git a/client/src/recruit_unit_ui.rs b/client/src/recruit_unit_ui.rs index 90d1dc8f..5cb3c8bb 100644 --- a/client/src/recruit_unit_ui.rs +++ b/client/src/recruit_unit_ui.rs @@ -3,6 +3,7 @@ use macroquad::prelude::*; use server::game::Game; use server::player::Player; use server::position::Position; +use server::recruit::{recruit_cost, recruit_cost_without_replaced}; use server::unit::{Unit, UnitType, Units}; use crate::client_state::{ActiveDialog, StateUpdate}; @@ -88,14 +89,14 @@ fn selectable_unit( units.get(&unit.unit_type) }; - let max = if player - .recruit_cost_without_replaced( - &all, - city_position, - unit.leader_name.as_ref().or(leader_name), - None, - ) - .is_some() + let max = if recruit_cost_without_replaced( + player, + &all, + city_position, + unit.leader_name.as_ref().or(leader_name), + None, + ) + .is_some() { u32::from(current + 1) } else { @@ -152,6 +153,7 @@ fn new_units(player: &Player) -> Vec { #[derive(Clone)] pub struct RecruitSelection { + pub player: usize, pub amount: RecruitAmount, pub available_units: Units, pub need_replacement: Units, @@ -159,11 +161,17 @@ pub struct RecruitSelection { } impl RecruitSelection { - pub fn new(game: &Game, amount: RecruitAmount, replaced_units: Vec) -> RecruitSelection { + pub fn new( + game: &Game, + player: usize, + amount: RecruitAmount, + replaced_units: Vec, + ) -> RecruitSelection { let available_units = game.get_player(amount.player_index).available_units(); let need_replacement = available_units.get_units_to_replace(&amount.units); RecruitSelection { + player, amount, available_units, need_replacement, @@ -184,20 +192,23 @@ impl UnitSelection for RecruitSelection { fn can_select(&self, _game: &Game, unit: &Unit) -> bool { self.need_replacement.has_unit(&unit.unit_type) } + + fn player_index(&self) -> usize { + self.player + } } impl ConfirmSelection for RecruitSelection { fn confirm(&self, game: &Game) -> OkTooltip { - if game - .get_player(self.amount.player_index) - .recruit_cost( - &self.amount.units, - self.amount.city_position, - self.amount.leader_name.as_ref(), - self.replaced_units.as_slice(), - None, - ) - .is_some() + if recruit_cost( + game.get_player(self.amount.player_index), + &self.amount.units, + self.amount.city_position, + self.amount.leader_name.as_ref(), + self.replaced_units.as_slice(), + None, + ) + .is_some() { OkTooltip::Valid("Recruit units".to_string()) } else { @@ -215,7 +226,7 @@ pub fn select_dialog(rc: &RenderContext, a: &RecruitAmount) -> StateUpdate { |s, p| { draw_unit_type( rc, - &UnitHighlightType::None, + UnitHighlightType::None, Point::from_vec2(p), s.unit_type, rc.shown_player.index, @@ -228,7 +239,7 @@ pub fn select_dialog(rc: &RenderContext, a: &RecruitAmount) -> StateUpdate { }, || OkTooltip::Valid("Recruit units".to_string()), || { - let sel = RecruitSelection::new(game, a.clone(), vec![]); + let sel = RecruitSelection::new(game, rc.shown_player.index, a.clone(), vec![]); if sel.is_finished() { open_dialog(rc, a.city_position, sel) diff --git a/client/src/status_phase_ui.rs b/client/src/status_phase_ui.rs index b0d466ef..d144c5b0 100644 --- a/client/src/status_phase_ui.rs +++ b/client/src/status_phase_ui.rs @@ -1,7 +1,7 @@ use crate::advance_ui::{show_advance_menu, AdvanceState}; use crate::client_state::{ActiveDialog, StateUpdate}; use crate::dialog_ui::{cancel_button, cancel_button_with_tooltip, ok_button, OkTooltip}; -use crate::layout_ui::bottom_centered_text; +use crate::player_ui::choose_player_dialog; use crate::render_context::RenderContext; use server::action::Action; use server::content::advances::{get_government, get_governments}; @@ -155,16 +155,7 @@ pub fn complete_objectives_dialog(rc: &RenderContext) -> StateUpdate { } pub fn determine_first_player_dialog(rc: &RenderContext) -> StateUpdate { - if rc.can_control_active_player() { - bottom_centered_text( - rc, - &format!("Select {} as first player", rc.shown_player.get_name()), - ); - if ok_button(rc, OkTooltip::Valid("Select".to_string())) { - return StateUpdate::status_phase(StatusPhaseAction::DetermineFirstPlayer( - rc.shown_player.index, - )); - } - } - StateUpdate::None + choose_player_dialog(rc, &rc.game.human_players(), |p| { + Action::StatusPhase(StatusPhaseAction::DetermineFirstPlayer(p)) + }) } diff --git a/client/src/unit_ui.rs b/client/src/unit_ui.rs index 753ff56d..77f51193 100644 --- a/client/src/unit_ui.rs +++ b/client/src/unit_ui.rs @@ -32,7 +32,7 @@ impl UnitPlace { } } -#[derive(Clone)] +#[derive(Clone, Copy)] pub enum UnitHighlightType { None, Primary, @@ -40,7 +40,7 @@ pub enum UnitHighlightType { } impl UnitHighlightType { - fn color(&self) -> Color { + fn color(self) -> Color { match self { UnitHighlightType::None => BLACK, UnitHighlightType::Primary => WHITE, @@ -50,13 +50,14 @@ impl UnitHighlightType { } struct UnitHighlight { + player: usize, unit: u32, highlight_type: UnitHighlightType, } pub fn draw_unit_type( rc: &RenderContext, - unit_highlight_type: &UnitHighlightType, + unit_highlight_type: UnitHighlightType, center: Point, unit_type: UnitType, player_index: usize, @@ -141,12 +142,14 @@ pub fn non_leader_names() -> [(UnitType, &'static str); 5] { } pub fn draw_units(rc: &RenderContext, tooltip: bool) { + let player = rc.shown_player.index; let highlighted_units = match rc.state.active_dialog { ActiveDialog::MoveUnits(ref s) => { - let mut h = highlight_primary(&s.units); + let mut h = highlight_units(player, &s.units, UnitHighlightType::Primary); for d in &s.destinations.list { if let MoveDestination::Carrier(id) = d { h.push(UnitHighlight { + player, unit: *id, highlight_type: UnitHighlightType::Secondary, }); @@ -154,8 +157,21 @@ pub fn draw_units(rc: &RenderContext, tooltip: bool) { } h } - ActiveDialog::ReplaceUnits(ref s) => highlight_primary(&s.replaced_units), - ActiveDialog::UnitsRequest(ref s) => highlight_primary(&s.units), + ActiveDialog::ReplaceUnits(ref s) => highlight_units( + rc.shown_player.index, + &s.replaced_units, + UnitHighlightType::Primary, + ), + ActiveDialog::UnitsRequest(ref s) => { + highlight_units(s.player, &s.units, UnitHighlightType::Primary) + .into_iter() + .chain(highlight_units( + s.player, + &s.selectable, + UnitHighlightType::Secondary, + )) + .collect_vec() + } _ => vec![], }; @@ -200,12 +216,17 @@ pub fn draw_units(rc: &RenderContext, tooltip: bool) { } } -fn highlight_primary(units: &[u32]) -> Vec { +fn highlight_units( + player: usize, + units: &[u32], + highlight_type: UnitHighlightType, +) -> Vec { units .iter() - .map(|unit| UnitHighlight { + .map(move |unit| UnitHighlight { + player, unit: *unit, - highlight_type: UnitHighlightType::Primary, + highlight_type, }) .collect() } @@ -227,17 +248,14 @@ fn draw_unit( .has_advance(ARMY_MOVEMENT_REQUIRED_ADVANCE); show_tooltip_for_circle(rc, &unit_label(unit, army_move), center.to_vec2(), radius); } else { - let highlight = if player_index == game.active_player() { - selected_units - .iter() - .find(|u| u.unit == unit.id) - .map_or(UnitHighlightType::None, |u| u.highlight_type.clone()) - } else { - UnitHighlightType::None - }; + let highlight = selected_units + .iter() + .find(|u| u.unit == unit.id && u.player == player_index) + .map_or(UnitHighlightType::None, |u| u.highlight_type); + draw_unit_type( rc, - &highlight, + highlight, center, unit.unit_type, unit.player_index, @@ -250,6 +268,7 @@ fn draw_unit( pub trait UnitSelection: ConfirmSelection { fn selected_units_mut(&mut self) -> &mut Vec; fn can_select(&self, game: &Game, unit: &Unit) -> bool; + fn player_index(&self) -> usize; } pub fn unit_selection_click( @@ -259,8 +278,9 @@ pub fn unit_selection_click( sel: &T, on_change: impl Fn(T) -> StateUpdate, ) -> StateUpdate { - if let Some(unit_id) = click_unit(rc, pos, mouse_pos, rc.shown_player, true) { - if sel.can_select(rc.game, rc.shown_player.get_unit(unit_id).unwrap()) { + let p = rc.game.get_player(sel.player_index()); + if let Some(unit_id) = click_unit(rc, pos, mouse_pos, p, true) { + if sel.can_select(rc.game, p.get_unit(unit_id).unwrap()) { let mut new = sel.clone(); unit_selection_clicked(unit_id, new.selected_units_mut()); return on_change(new); diff --git a/server/src/ability_initializer.rs b/server/src/ability_initializer.rs index 41171396..b2f2324f 100644 --- a/server/src/ability_initializer.rs +++ b/server/src/ability_initializer.rs @@ -1,19 +1,47 @@ use crate::content::custom_phase_actions::{ AdvanceRewardRequest, CurrentCustomPhaseEvent, CustomPhaseEventAction, CustomPhaseRequest, - CustomPhaseUnitsRequest, PaymentRequest, PositionRequest, ResourceRewardRequest, - UnitTypeRequest, + PaymentRequest, PlayerRequest, PositionRequest, ResourceRewardRequest, UnitTypeRequest, + UnitsRequest, }; use crate::events::{Event, EventOrigin}; -use crate::game::UndoContext; use crate::player_events::{CustomPhaseEvent, PlayerCommands}; use crate::position::Position; use crate::resource_pile::ResourcePile; +use crate::undo::UndoContext; use crate::unit::UnitType; use crate::{content::custom_actions::CustomActionType, game::Game, player_events::PlayerEvents}; use std::collections::HashMap; pub(crate) type AbilityInitializer = Box; +pub struct SelectedChoice { + pub player_index: usize, + pub player_name: String, + pub actively_selected: bool, + pub choice: C, +} + +impl SelectedChoice { + pub fn new(player_index: usize, player_name: &str, actively_selected: bool, choice: C) -> Self { + Self { + player_index, + player_name: player_name.to_string(), + actively_selected, + choice, + } + } + + pub(crate) fn to_commands( + &self, + game: &mut Game, + gain: impl Fn(&mut PlayerCommands, &Game, &C) + 'static + Clone, + ) { + game.with_commands(self.player_index, |commands, game| { + gain(commands, game, &self.choice); + }); + } +} + pub struct AbilityListeners { pub initializer: AbilityInitializer, pub deinitializer: AbilityInitializer, @@ -171,7 +199,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { priority: i32, start_custom_phase: impl Fn(&mut Game, usize, &str, &V) -> Option + 'static - + Clone, //return option + + Clone, end_custom_phase: impl Fn(&mut Game, usize, &str, CustomPhaseEventAction, CustomPhaseRequest) + 'static + Clone, @@ -193,7 +221,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { let mut ctx = phase.clone(); ctx.current.as_mut().expect("current missing").response = None; game.undo_context_stack - .push(UndoContext::CustomPhaseEvent(ctx)); + .push(UndoContext::CustomPhaseEvent(Box::new(ctx))); let r = c.request.clone(); let a = action.clone(); phase.current = None; @@ -236,34 +264,12 @@ pub(crate) trait AbilityInitializerSetup: Sized { ) } - fn add_payment_request_with_commands_listener( - self, - event: E, - priority: i32, - request: impl Fn(&mut Game, usize, &V) -> Option> + 'static + Clone, - gain_reward: impl Fn(&mut PlayerCommands, &Game, &Vec) + 'static + Clone, - ) -> Self - where - E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, - { - self.add_payment_request_listener( - event, - priority, - request, - move |game, player_index, _player_name, payments| { - game.with_commands(player_index, |commands, game| { - gain_reward(commands, game, payments); - }); - }, - ) - } - fn add_payment_request_listener( self, event: E, priority: i32, request: impl Fn(&mut Game, usize, &V) -> Option> + 'static + Clone, - gain_reward: impl Fn(&mut Game, usize, &str, &Vec) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice>) + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, @@ -284,7 +290,10 @@ pub(crate) trait AbilityInitializerSetup: Sized { game.players[player_index].pay_cost(&request.cost, payment); } } - gain_reward(game, player_index, player_name, &payments); + gain_reward( + game, + &SelectedChoice::new(player_index, player_name, true, payments), + ); return; } } @@ -298,7 +307,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { event: E, priority: i32, request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, - gain_reward_log: impl Fn(&Game, usize, &str, &ResourcePile, bool) -> String + 'static + Clone, + gain_reward_log: impl Fn(&Game, &SelectedChoice) -> Vec + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, @@ -313,7 +322,12 @@ pub(crate) trait AbilityInitializerSetup: Sized { if r.reward.possible_resource_types().len() == 1 { let player_name = game.players[player_index].get_name(); let r = r.reward.default_payment(); - game.add_info_log_item(&g(game, player_index, &player_name, &r, false)); + for log in g( + game, + &SelectedChoice::new(player_index, &player_name, false, r.clone()), + ) { + game.add_info_log_item(&log); + } game.players[player_index].gain_resources(r); return None; } @@ -323,14 +337,13 @@ pub(crate) trait AbilityInitializerSetup: Sized { move |game, player_index, player_name, action, request| { if let CustomPhaseRequest::ResourceReward(request) = &request { if let CustomPhaseEventAction::ResourceReward(reward) = action { - assert!(request.reward.is_valid_payment(&reward), "Invalid payment"); - game.add_info_log_item(&gain_reward_log( + assert!(request.reward.is_valid_payment(&reward), "Invalid reward"); + for log in &gain_reward_log( game, - player_index, - player_name, - &reward, - true, - )); + &SelectedChoice::new(player_index, player_name, true, reward.clone()), + ) { + game.add_info_log_item(log); + } game.players[player_index].gain_resources(reward); return; } @@ -345,7 +358,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { event: E, priority: i32, request: impl Fn(&mut Game, usize, &V) -> bool + 'static + Clone, - gain_reward: impl Fn(&mut Game, usize, &str, bool) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, @@ -359,7 +372,10 @@ pub(crate) trait AbilityInitializerSetup: Sized { move |game, player_index, player_name, action, request| { if let CustomPhaseRequest::BoolRequest = &request { if let CustomPhaseEventAction::Bool(reward) = action { - gain_reward(game, player_index, player_name, reward); + gain_reward( + game, + &SelectedChoice::new(player_index, player_name, true, reward), + ); return; } } @@ -373,7 +389,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { event: E, priority: i32, request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut Game, usize, &str, &String, bool) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, @@ -401,7 +417,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { event: E, priority: i32, request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut PlayerCommands, &Game, &Position) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, @@ -420,11 +436,35 @@ pub(crate) trait AbilityInitializerSetup: Sized { panic!("Invalid state"); }, request, - move |game, player_index, _player_name, choice, _selected| { - game.with_commands(player_index, |commands, game| { - gain_reward(commands, game, choice); - }); + gain_reward, + ) + } + + fn add_player_request( + self, + event: E, + priority: i32, + request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, + ) -> Self + where + E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, + { + self.add_choice_reward_request_listener::( + event, + priority, + |r| &r.choices, + CustomPhaseRequest::SelectPlayer, + |request, action| { + if let CustomPhaseRequest::SelectPlayer(request) = &request { + if let CustomPhaseEventAction::SelectPlayer(reward) = action { + return (request.choices.clone(), reward); + } + } + panic!("Invalid state"); }, + request, + gain_reward, ) } @@ -433,7 +473,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { event: E, priority: i32, request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut PlayerCommands, &Game, &UnitType) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, @@ -452,11 +492,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { panic!("Invalid state"); }, request, - move |game, player_index, _player_name, choice, _selected| { - game.with_commands(player_index, |commands, game| { - gain_reward(commands, game, choice); - }); - }, + gain_reward, ) } @@ -464,13 +500,13 @@ pub(crate) trait AbilityInitializerSetup: Sized { self, event: E, priority: i32, - request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, + request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, units_selected: impl Fn(&mut Game, usize, &Vec) + 'static + Clone, ) -> Self where E: Fn(&mut PlayerEvents) -> &mut CustomPhaseEvent + 'static + Clone, { - self.add_multi_choice_reward_request_listener::( + self.add_multi_choice_reward_request_listener::( event, priority, |r| &r.choices, @@ -484,8 +520,8 @@ pub(crate) trait AbilityInitializerSetup: Sized { panic!("Invalid state"); }, request, - move |game, player_index, _player_name, choice| { - units_selected(game, player_index, choice); + move |game, c| { + units_selected(game, c.player_index, &c.choice); }, ) } @@ -500,7 +536,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { + 'static + Clone, request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut Game, usize, &str, &C, bool) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self where C: Clone + PartialEq, @@ -517,7 +553,15 @@ pub(crate) trait AbilityInitializerSetup: Sized { return None; } if choices.len() == 1 { - g(game, player_index, player_name, &choices[0], false); + g( + game, + &SelectedChoice::new( + player_index, + player_name, + false, + choices[0].clone(), + ), + ); return None; } return Some(to_request(r)); @@ -527,7 +571,10 @@ pub(crate) trait AbilityInitializerSetup: Sized { move |game, player_index, player_name, action, request| { let (choices, selected) = from_request(&request, action); assert!(choices.contains(&selected), "Invalid choice"); - gain_reward(game, player_index, player_name, &selected, true); + gain_reward( + game, + &SelectedChoice::new(player_index, player_name, true, selected), + ); }, ) } @@ -542,7 +589,7 @@ pub(crate) trait AbilityInitializerSetup: Sized { + 'static + Clone, request: impl Fn(&mut Game, usize, &V) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut Game, usize, &str, &Vec) + 'static + Clone, + gain_reward: impl Fn(&mut Game, SelectedChoice>) + 'static + Clone, ) -> Self where C: Clone + PartialEq, @@ -568,7 +615,10 @@ pub(crate) trait AbilityInitializerSetup: Sized { "Invalid choice" ); assert_eq!(selected.len() as u8, needed, "Invalid choice count"); - gain_reward(game, player_index, player_name, &selected); + gain_reward( + game, + SelectedChoice::new(player_index, player_name, true, selected), + ); }, ) } diff --git a/server/src/action.rs b/server/src/action.rs index 2c6d9a50..8c9c9a2e 100644 --- a/server/src/action.rs +++ b/server/src/action.rs @@ -1,8 +1,30 @@ +use crate::advance::gain_advance; +use crate::city_pieces::Building::Temple; +use crate::combat::{ + combat_loop, combat_round_end, end_combat, move_with_possible_combat, start_combat, take_combat, +}; use crate::content::custom_phase_actions::CustomPhaseEventAction; +use crate::cultural_influence::execute_cultural_influence_resolution_action; +use crate::explore::{explore_resolution, move_to_unexplored_tile}; +use crate::game::GameState::{ + Combat, CulturalInfluenceResolution, ExploreResolution, Finished, Movement, Playing, + StatusPhase, +}; +use crate::game::{ActionLogItem, Game}; +use crate::incident::trigger_incident; +use crate::log; use crate::map::Rotation; +use crate::map::Terrain::Unexplored; +use crate::move_units::{back_to_move, move_units_destinations, CurrentMove, MoveState}; use crate::playing_actions::PlayingAction; +use crate::recruit::on_recruit; +use crate::resource::check_for_waste; +use crate::resource_pile::ResourcePile; use crate::status_phase::StatusPhaseAction; -use crate::unit::MovementAction; +use crate::undo::{redo, undo, DisembarkUndoContext, UndoContext}; +use crate::unit::MovementAction::{Move, Stop}; +use crate::unit::{get_current_move, MovementAction}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, PartialEq)] @@ -81,3 +103,249 @@ impl Action { } } } + +/// +/// +/// # Panics +/// +/// Panics if the action is illegal +pub fn execute_action(game: &mut Game, action: Action, player_index: usize) { + assert!(player_index == game.active_player(), "Illegal action"); + if let Action::Undo = action { + assert!( + game.can_undo(), + "actions revealing new information can't be undone" + ); + undo(game, player_index); + return; + } + + if matches!(action, Action::Redo) { + assert!(game.can_redo(), "no action can be redone"); + redo(game, player_index); + return; + } + + add_log_item_from_action(game, &action); + add_action_log_item(game, action.clone()); + + if let Some(s) = game.current_custom_phase_event_mut() { + s.response = action.custom_phase_event(); + let event_type = game.current_custom_phase().event_type.clone(); + execute_custom_phase_action(game, player_index, &event_type); + } else { + execute_regular_action(game, action, player_index); + } + check_for_waste(game); + + game.action_log[game.action_log_index - 1].undo = std::mem::take(&mut game.undo_context_stack); +} + +fn add_action_log_item(game: &mut Game, item: Action) { + if game.action_log_index < game.action_log.len() { + game.action_log.drain(game.action_log_index..); + } + game.action_log.push(ActionLogItem::new(item)); + game.action_log_index += 1; +} + +pub(crate) fn execute_custom_phase_action(game: &mut Game, player_index: usize, event_type: &str) { + match event_type { + "on_combat_start" => { + start_combat(game); + } + "on_combat_round_end" => { + game.lock_undo(); + if let Some(c) = combat_round_end(game) { + combat_loop(game, c); + } + } + "on_combat_end" => { + let c = take_combat(game); + end_combat(game, c); + } + "on_turn_start" => game.start_turn(), + // name and payment is ignored here + "on_advance_custom_phase" => { + gain_advance(game, player_index, ResourcePile::empty(), ""); + } + "on_construct" => { + // building is ignored here + PlayingAction::on_construct(game, player_index, Temple); + } + "on_recruit" => { + on_recruit(game, player_index); + } + "on_incident" => { + trigger_incident(game, player_index); + } + _ => panic!("unknown custom phase event {event_type}"), + } +} + +pub(crate) fn add_log_item_from_action(game: &mut Game, action: &Action) { + game.log.push(log::format_action_log_item(action, game)); +} + +fn execute_regular_action(game: &mut Game, action: Action, player_index: usize) { + match game.state.clone() { + Playing => { + if let Some(m) = action.clone().movement() { + execute_movement_action(game, m, player_index, MoveState::new()); + } else { + let action = action.playing().expect("action should be a playing action"); + action.execute(game, player_index); + } + } + StatusPhase(phase) => { + let action = action + .status_phase() + .expect("action should be a status phase action"); + assert!(phase == action.phase(), "Illegal action: Same phase again"); + action.execute(game, player_index); + } + Movement(m) => { + let action = action + .movement() + .expect("action should be a movement action"); + execute_movement_action(game, action, player_index, m); + } + CulturalInfluenceResolution(c) => { + let action = action + .cultural_influence_resolution() + .expect("action should be a cultural influence resolution action"); + execute_cultural_influence_resolution_action( + game, + action, + c.roll_boost_cost, + c.target_player_index, + c.target_city_position, + c.city_piece, + player_index, + ); + } + Combat(_) => { + panic!("actions can't be executed when the game is in a combat state"); + } + ExploreResolution(r) => { + let rotation = action + .explore_resolution() + .expect("action should be an explore resolution action"); + explore_resolution(game, &r, rotation); + } + Finished => panic!("actions can't be executed when the game is finished"), + } +} + +pub(crate) fn execute_movement_action( + game: &mut Game, + action: MovementAction, + player_index: usize, + mut move_state: MoveState, +) { + let saved_state = move_state.clone(); + let (starting_position, disembarked_units) = match action { + Move(m) => { + if let Playing = game.state { + assert_ne!(game.actions_left, 0, "Illegal action"); + game.actions_left -= 1; + } + let player = &game.players[player_index]; + let starting_position = player + .get_unit(*m.units.first().expect( + "instead of providing no units to move a stop movement actions should be done", + )) + .expect("the player should have all units to move") + .position; + let disembarked_units = m + .units + .iter() + .filter_map(|unit| { + let unit = player.get_unit(*unit).expect("unit should exist"); + unit.carrier_id.map(|carrier_id| DisembarkUndoContext { + unit_id: unit.id, + carrier_id, + }) + }) + .collect(); + match move_units_destinations( + player, + game, + &m.units, + starting_position, + m.embark_carrier_id, + ) { + Ok(destinations) => { + let c = &destinations + .iter() + .find(|route| route.destination == m.destination) + .expect("destination should be a valid destination") + .cost; + if c.is_free() { + assert_eq!(m.payment, ResourcePile::empty(), "payment should be empty"); + } else { + game.players[player_index].pay_cost(c, &m.payment); + } + } + Err(e) => { + panic!("cannot move units to destination: {e}"); + } + } + + move_state.moved_units.extend(m.units.iter()); + move_state.moved_units = move_state.moved_units.iter().unique().copied().collect(); + let current_move = get_current_move( + game, + &m.units, + starting_position, + m.destination, + m.embark_carrier_id, + ); + if matches!(current_move, CurrentMove::None) || move_state.current_move != current_move + { + move_state.movement_actions_left -= 1; + move_state.current_move = current_move; + } + + let dest_terrain = game + .map + .get(m.destination) + .expect("destination should be a valid tile"); + + if dest_terrain == &Unexplored { + if move_to_unexplored_tile( + game, + player_index, + &m.units, + starting_position, + m.destination, + &move_state, + ) { + back_to_move(game, &move_state, true); + } + return; + } + + if move_with_possible_combat( + game, + player_index, + Some(&mut move_state), + starting_position, + &m, + ) { + return; + } + + (Some(starting_position), disembarked_units) + } + Stop => { + game.state = Playing; + (None, Vec::new()) + } + }; + game.push_undo_context(UndoContext::Movement { + starting_position, + move_state: saved_state, + disembarked_units, + }); +} diff --git a/server/src/advance.rs b/server/src/advance.rs index a4ba247b..ba5d5d7a 100644 --- a/server/src/advance.rs +++ b/server/src/advance.rs @@ -1,8 +1,14 @@ -use crate::{ability_initializer::AbilityInitializerSetup, resource_pile::ResourcePile}; +use crate::{ability_initializer::AbilityInitializerSetup, resource_pile::ResourcePile, utils}; use crate::ability_initializer::{AbilityInitializerBuilder, AbilityListeners}; use crate::city_pieces::Building; +use crate::content::advances; +use crate::content::advances::get_advance; use crate::events::EventOrigin; +use crate::game::Game; +use crate::incident::trigger_incident; +use crate::player_events::AdvanceInfo; +use crate::special_advance::SpecialAdvance; use Bonus::*; pub struct Advance { @@ -126,3 +132,136 @@ impl Bonus { } } } + +/// +/// +/// # Panics +/// +/// Panics if advance does not exist +pub fn do_advance(game: &mut Game, advance: &Advance, player_index: usize) { + game.trigger_command_event(player_index, |e| &mut e.on_advance, &advance.name); + (advance.listeners.initializer)(game, player_index); + (advance.listeners.one_time_initializer)(game, player_index); + let name = advance.name.clone(); + for i in 0..game.players[player_index] + .civilization + .special_advances + .len() + { + if game.players[player_index].civilization.special_advances[i].required_advance == name { + let special_advance = game.players[player_index] + .civilization + .special_advances + .remove(i); + unlock_special_advance(game, &special_advance, player_index); + game.players[player_index] + .civilization + .special_advances + .insert(i, special_advance); + break; + } + } + if let Some(advance_bonus) = &advance.bonus { + let pile = advance_bonus.resources(); + game.add_info_log_item(&format!("Player gained {pile} as advance bonus")); + game.players[player_index].gain_resources(pile); + } + let player = &mut game.players[player_index]; + player.advances.push(get_advance(&advance.name)); +} + +pub(crate) fn advance_with_incident_token( + game: &mut Game, + name: &str, + player_index: usize, + payment: ResourcePile, +) { + do_advance(game, &advances::get_advance(name), player_index); + gain_advance(game, player_index, payment, name); +} + +pub(crate) fn gain_advance( + game: &mut Game, + player_index: usize, + payment: ResourcePile, + advance: &str, +) { + if game.trigger_custom_phase_event( + &[player_index], + |e| &mut e.on_advance_custom_phase, + &AdvanceInfo { + name: advance.to_string(), + payment, + }, + None, + ) { + return; + } + let player = &mut game.players[player_index]; + player.incident_tokens -= 1; + if player.incident_tokens == 0 { + player.incident_tokens = 3; + trigger_incident(game, player_index); + } +} + +pub(crate) fn undo_advance( + game: &mut Game, + advance: &Advance, + player_index: usize, + was_custom_phase: bool, +) { + remove_advance(game, advance, player_index); + if !was_custom_phase { + game.players[player_index].incident_tokens += 1; + } +} + +pub(crate) fn remove_advance(game: &mut Game, advance: &Advance, player_index: usize) { + (advance.listeners.deinitializer)(game, player_index); + (advance.listeners.undo_deinitializer)(game, player_index); + + for i in 0..game.players[player_index] + .civilization + .special_advances + .len() + { + if game.players[player_index].civilization.special_advances[i].required_advance + == advance.name + { + let special_advance = game.players[player_index] + .civilization + .special_advances + .remove(i); + undo_unlock_special_advance(game, &special_advance, player_index); + game.players[player_index] + .civilization + .special_advances + .insert(i, special_advance); + break; + } + } + let player = &mut game.players[player_index]; + if let Some(advance_bonus) = &advance.bonus { + player.lose_resources(advance_bonus.resources()); + } + utils::remove_element(&mut game.players[player_index].advances, advance); +} + +fn unlock_special_advance(game: &mut Game, special_advance: &SpecialAdvance, player_index: usize) { + (special_advance.listeners.initializer)(game, player_index); + (special_advance.listeners.one_time_initializer)(game, player_index); + game.players[player_index] + .unlocked_special_advances + .push(special_advance.name.clone()); +} + +fn undo_unlock_special_advance( + game: &mut Game, + special_advance: &SpecialAdvance, + player_index: usize, +) { + (special_advance.listeners.deinitializer)(game, player_index); + (special_advance.listeners.undo_deinitializer)(game, player_index); + game.players[player_index].unlocked_special_advances.pop(); +} diff --git a/server/src/barbarians.rs b/server/src/barbarians.rs index 7e6103bb..6a01b9ee 100644 --- a/server/src/barbarians.rs +++ b/server/src/barbarians.rs @@ -1,4 +1,6 @@ use crate::ability_initializer::AbilityInitializerSetup; +use crate::city::City; +use crate::combat::move_with_possible_combat; use crate::consts::STACK_LIMIT; use crate::content::builtin::Builtin; use crate::content::custom_phase_actions::{ @@ -12,7 +14,8 @@ use crate::player::Player; use crate::player_events::IncidentTarget; use crate::position::Position; use crate::resource::ResourceType; -use crate::unit::{UnitType, Units}; +use crate::resource_pile::ResourcePile; +use crate::unit::{MoveUnits, UnitType, Units}; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -34,8 +37,8 @@ pub struct BarbariansEventState { #[serde(skip_serializing_if = "Option::is_none")] pub selected_position: Option, #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub move_request: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub must_reduce_mood: Vec, } impl Default for BarbariansEventState { @@ -49,8 +52,8 @@ impl BarbariansEventState { BarbariansEventState { moved_units: Vec::new(), selected_position: None, - move_request: None, move_units: false, + must_reduce_mood: Vec::new(), } } } @@ -62,25 +65,29 @@ pub(crate) fn barbarians_bonus() -> Builtin { 5, |game, player_index, i| { if i.is_winner(player_index) - && !game.get_player(i.opponent(player_index)).is_human() + && game + .get_player(i.opponent(player_index)) + .civilization + .is_barbarian() { let sum = if i.captured_city(player_index, game) { 2 } else { 1 }; - Some(ResourceRewardRequest { - reward: PaymentOptions::sum(sum, &[ResourceType::Gold]), - name: "-".to_string(), - }) + Some(ResourceRewardRequest::new( + PaymentOptions::sum(sum, &[ResourceType::Gold]), + "-".to_string(), + )) } else { None } }, - |_game, _player_index, player_name, resource, _selected| { - format!( - "{player_name} gained {resource} for winning a combat against the Barbarians" - ) + |_game, s| { + vec![format!( + "{} gained {} for winning a combat against the Barbarians", + s.player_name, s.choice + )] }, ) .build() @@ -99,10 +106,10 @@ pub(crate) fn barbarians_spawn(mut builder: IncidentBuilder) -> IncidentBuilder Some("Select a position for the additional Barbarian unit".to_string()), )) }, - |c, _game, pos| { + |game, s| { let mut state = BarbariansEventState::new(); - state.selected_position = Some(*pos); - c.update_barbarian_info(state); + state.selected_position = Some(s.choice); + game.current_custom_phase_mut().barbarians = Some(state); }, ) .add_incident_unit_request( @@ -112,17 +119,20 @@ pub(crate) fn barbarians_spawn(mut builder: IncidentBuilder) -> IncidentBuilder let choices = get_barbarian_reinforcement_choices(game); Some(UnitTypeRequest::new( choices, - get_barbarians_player(game), + get_barbarians_player(game).index, Some("Select a unit to reinforce the barbarians".to_string()), )) }, - |c, game, unit| { + |game, s| { let position = get_barbarian_state(game) .selected_position .expect("selected position should exist"); - let units = Units::from_iter(vec![*unit]); - c.add_info_log_item(&format!("Barbarians reinforced with {units} at {position}",)); - c.gain_unit(get_barbarians_player(game), *unit, position); + let units = Units::from_iter(vec![s.choice]); + game.add_info_log_item(&format!( + "Barbarians reinforced with {units} at {position}", + )); + game.get_player_mut(get_barbarians_player(game).index) + .add_unit(position, s.choice); }, ) } @@ -146,10 +156,8 @@ pub(crate) fn barbarians_move(mut builder: IncidentBuilder) -> IncidentBuilder { Some("Select a Barbarian Army to move next".to_string()), )) }, - |c, game, pos| { - let mut state = get_barbarian_state(game); - state.selected_position = Some(*pos); - c.update_barbarian_info(state); + |game, s| { + get_barbarian_state_mut(game).selected_position = Some(s.choice); }, ) .add_incident_position_request( @@ -177,18 +185,37 @@ pub(crate) fn barbarians_move(mut builder: IncidentBuilder) -> IncidentBuilder { None } }, - |c, game, pos| { - let mut state = get_barbarian_state(game); - let src = state + |game, s| { + let mut state = game + .current_custom_phase_mut() + .barbarians + .take() + .expect("barbarians should exist"); + let from = state .selected_position .take() .expect("selected position should exist"); - state.move_request = Some(BarbariansMoveRequest { - from: src, - to: *pos, - player: get_barbarians_player(game), - }); - c.update_barbarian_info(state); + let to = s.choice; + let ids = get_barbarians_player(game).get_units(from); + let units: Vec = ids.iter().map(|u| u.id).collect(); + state.moved_units.extend(units.iter()); + let unit_types = ids.iter().map(|u| u.unit_type).collect::(); + game.add_info_log_item(&format!( + "Barbarians move from {from} to {to}: {unit_types}" + )); + move_with_possible_combat( + game, + get_barbarians_player(game).index, + None, + from, + &MoveUnits { + units, + destination: to, + embark_carrier_id: None, + payment: ResourcePile::empty(), + }, + ); + game.current_custom_phase_mut().barbarians = Some(state); }, ); } @@ -201,7 +228,8 @@ pub(crate) fn barbarians_move(mut builder: IncidentBuilder) -> IncidentBuilder { // after all moves are done reinforce_after_move(game, p.player); // clear movement restrictions - game.get_player_mut(get_barbarians_player(game)).end_turn(); + game.get_player_mut(get_barbarians_player(game).index) + .end_turn(); } }, ) @@ -209,7 +237,7 @@ pub(crate) fn barbarians_move(mut builder: IncidentBuilder) -> IncidentBuilder { fn reinforce_after_move(game: &mut Game, player_index: usize) { let player = game.get_player(player_index); - let barbarian = get_barbarians_player(game); + let barbarian = get_barbarians_player(game).index; let available = player.available_units().get(&UnitType::Infantry) as usize; let cities: Vec = player @@ -217,7 +245,7 @@ fn reinforce_after_move(game: &mut Game, player_index: usize) { .iter() .flat_map(|c| cities_in_range(game, |p| p.index == barbarian, c.position, 2)) .unique() - .filter(|&p| game.get_player(barbarian).get_units(p).len() < STACK_LIMIT) + .filter(|&p| get_barbarians_player(game).get_units(p).len() < STACK_LIMIT) .take(available) .collect(); for pos in cities { @@ -229,7 +257,7 @@ fn reinforce_after_move(game: &mut Game, player_index: usize) { fn get_movable_units(game: &Game, human: usize, state: &BarbariansEventState) -> Vec { let human = game.get_player(human); - let barbarian = game.get_player(get_barbarians_player(game)); + let barbarian = get_barbarians_player(game); game.map .tiles @@ -258,7 +286,7 @@ fn barbarian_march_steps( return primary; } - let barbarian = game.get_player(get_barbarians_player(game)); + let barbarian = get_barbarians_player(game); steps_towards_land_range2_cites(game, human, from) .into_iter() .filter(|&p| { @@ -268,7 +296,7 @@ fn barbarian_march_steps( .collect() } -fn set_info( +pub(crate) fn set_info( builder: IncidentBuilder, event_name: &str, init: impl Fn(&mut BarbariansEventState, &Game, usize) + 'static + Clone, @@ -298,18 +326,20 @@ fn add_barbarians_city(builder: IncidentBuilder) -> IncidentBuilder { Some("Select a position for the new city and infantry unit".to_string()), )) }, - move |c, game, pos| { - c.add_info_log_item(&format!( - "Barbarians spawned a new city and a new Infantry unit at {pos}", + move |game, s| { + game.add_info_log_item(&format!( + "Barbarians spawned a new city and a new Infantry unit at {}", + s.choice )); - let b = get_barbarians_player(game); - c.gain_city(b, *pos); - c.gain_unit(b, UnitType::Infantry, *pos); + let b = get_barbarians_player(game).index; + let p = game.get_player_mut(b); + p.cities.push(City::new(b, s.choice)); + p.add_unit(s.choice, UnitType::Infantry); }, ) } -fn get_barbarian_state(game: &Game) -> BarbariansEventState { +pub(crate) fn get_barbarian_state(game: &Game) -> BarbariansEventState { game.current_custom_phase() .barbarians .as_ref() @@ -317,6 +347,13 @@ fn get_barbarian_state(game: &Game) -> BarbariansEventState { .clone() } +pub(crate) fn get_barbarian_state_mut(game: &mut Game) -> &mut BarbariansEventState { + game.current_custom_phase_mut() + .barbarians + .as_mut() + .expect("barbarians should exist") +} + fn possible_barbarians_spawns(game: &Game, player: &Player) -> Vec { let primary: Vec = game .map @@ -348,7 +385,7 @@ fn possible_barbarians_spawns(game: &Game, player: &Player) -> Vec { } fn possible_barbarians_reinforcements(game: &Game) -> Vec { - let barbarian = game.get_player(get_barbarians_player(game)); + let barbarian = get_barbarians_player(game); let avail = barbarian.available_units(); if !barbarian_fighters().iter().any(|u| avail.has_unit(u)) { return vec![]; @@ -362,7 +399,7 @@ fn possible_barbarians_reinforcements(game: &Game) -> Vec { } fn get_barbarian_reinforcement_choices(game: &Game) -> Vec { - let barbarian = game.get_player(get_barbarians_player(game)); + let barbarian = get_barbarians_player(game); let pos = get_barbarian_state(game) .selected_position .expect("selected position should exist"); @@ -441,10 +478,9 @@ fn cities_in_range( } #[must_use] -fn get_barbarians_player(game: &Game) -> usize { +fn get_barbarians_player(game: &Game) -> &Player { game.players .iter() .find(|p| p.civilization.is_barbarian()) .expect("barbarians should exist") - .index } diff --git a/server/src/civilization.rs b/server/src/civilization.rs index a9091fd8..301fe083 100644 --- a/server/src/civilization.rs +++ b/server/src/civilization.rs @@ -1,4 +1,4 @@ -use crate::content::civilizations::BARBARIANS; +use crate::content::civilizations::{BARBARIANS, PIRATES}; use crate::{leader::Leader, special_advance::SpecialAdvance}; //todo add optional special starting tile @@ -17,12 +17,15 @@ impl Civilization { } } - // Barbarians have the highest player index pub fn is_barbarian(&self) -> bool { self.name == BARBARIANS } + pub fn is_pirates(&self) -> bool { + self.name == PIRATES + } + pub fn is_human(&self) -> bool { - !self.is_barbarian() + !self.is_barbarian() && !self.is_pirates() } } diff --git a/server/src/combat.rs b/server/src/combat.rs index a8e61bfa..fb04322d 100644 --- a/server/src/combat.rs +++ b/server/src/combat.rs @@ -1,175 +1,20 @@ -use crate::ability_initializer::AbilityInitializerSetup; +use crate::city::MoodState::Angry; +use crate::city_pieces::Building; +use crate::combat_listeners::{ + Casualties, CombatResult, CombatResultInfo, CombatRoundResult, CombatStrength, +}; use crate::consts::SHIP_CAPACITY; -use crate::content::builtin::{Builtin, BuiltinBuilder}; -use crate::content::custom_phase_actions::{CustomPhaseUnitsRequest, PositionRequest}; use crate::game::GameState::Playing; use crate::game::{Game, GameState}; +use crate::move_units::{back_to_move, move_units, MoveState}; use crate::position::Position; +use crate::resource_pile::ResourcePile; use crate::unit::UnitType::{Cavalry, Elephant, Infantry, Leader}; -use crate::unit::{UnitType, Units}; +use crate::unit::{MoveUnits, MovementRestriction, UnitType, Units}; use itertools::Itertools; -use num::Zero; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::mem; -#[derive(Clone, PartialEq)] -pub struct CombatStrength { - pub attacker: bool, - pub player_index: usize, - pub extra_dies: u8, - pub extra_combat_value: u8, - pub hit_cancels: u8, - pub roll_log: Vec, -} - -impl CombatStrength { - #[must_use] - pub fn new(player_index: usize, attacker: bool) -> Self { - Self { - player_index, - attacker, - extra_dies: 0, - extra_combat_value: 0, - hit_cancels: 0, - roll_log: vec![], - } - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub enum CombatResult { - AttackerWins, - DefenderWins, - Draw, - Retreat, -} - -#[derive(Clone, PartialEq)] -pub struct CombatResultInfo { - pub result: CombatResult, - pub defender_position: Position, - pub attacker: usize, - pub defender: usize, -} - -impl CombatResultInfo { - #[must_use] - pub fn new( - result: CombatResult, - attacker: usize, - defender: usize, - defender_position: Position, - ) -> Self { - Self { - result, - defender_position, - attacker, - defender, - } - } - - #[must_use] - pub fn is_attacker(&self, player: usize) -> bool { - self.attacker == player - } - - #[must_use] - pub fn is_defender(&self, player: usize) -> bool { - self.attacker != player - } - - #[must_use] - pub fn is_loser(&self, player: usize) -> bool { - if self.is_attacker(player) { - self.result == CombatResult::DefenderWins - } else { - self.result == CombatResult::AttackerWins - } - } - - #[must_use] - pub fn is_winner(&self, player: usize) -> bool { - if self.is_attacker(player) { - self.result == CombatResult::AttackerWins - } else { - self.result == CombatResult::DefenderWins - } - } - - #[must_use] - pub fn opponent(&self, player: usize) -> usize { - if self.is_attacker(player) { - self.defender - } else { - self.attacker - } - } - - #[must_use] - pub fn captured_city(&self, player: usize, game: &Game) -> bool { - self.is_attacker(player) - && self.is_winner(player) - && game.get_any_city(self.defender_position).is_some() - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub struct Casualties { - #[serde(default)] - #[serde(skip_serializing_if = "u8::is_zero")] - pub fighters: u8, - #[serde(default)] - #[serde(skip_serializing_if = "u8::is_zero")] - pub carried_units: u8, -} - -impl Casualties { - #[must_use] - pub fn new(fighters: u8, game: &Game, c: &Combat, player: usize) -> Self { - Self { - fighters, - carried_units: c.carried_units_casualties(game, player, fighters), - } - } -} -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub struct CombatRoundResult { - pub attacker_casualties: Casualties, - pub defender_casualties: Casualties, - #[serde(default)] - pub can_retreat: bool, - #[serde(default)] - pub retreated: bool, -} - -impl CombatRoundResult { - #[must_use] - pub fn new( - attacker_casualties: Casualties, - defender_casualties: Casualties, - can_retreat: bool, - ) -> Self { - Self { - attacker_casualties, - defender_casualties, - can_retreat, - retreated: false, - } - } -} - -impl CombatRoundResult { - #[must_use] - pub fn casualties(&self, attacker: bool) -> &Casualties { - if attacker { - &self.attacker_casualties - } else { - &self.defender_casualties - } - } -} - #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Copy)] pub enum CombatModifier { CancelFortressExtraDie, @@ -242,7 +87,7 @@ impl Combat { let defender_position = self.defender_position; let player = &game.players[attacker]; - let on_water = game.map.is_water(defender_position); + let on_water = game.map.is_sea(defender_position); attackers .iter() .copied() @@ -263,7 +108,7 @@ impl Combat { let defender = self.defender; let defender_position = self.defender_position; let p = &game.players[defender]; - let on_water = game.map.is_water(defender_position); + let on_water = game.map.is_sea(defender_position); p.get_units(defender_position) .into_iter() .filter(|u| can_remove_after_combat(on_water, &u.unit_type)) @@ -287,7 +132,7 @@ impl Combat { #[must_use] pub fn is_sea_battle(&self, game: &Game) -> bool { - game.map.is_water(self.defender_position) + game.map.is_sea(self.defender_position) } #[must_use] @@ -313,7 +158,7 @@ impl Combat { } #[must_use] - pub fn enemy(&self, player: usize) -> usize { + pub fn opponent(&self, player: usize) -> usize { if player == self.attacker { self.defender } else { @@ -374,139 +219,6 @@ pub(crate) fn start_combat(game: &mut Game) { combat_loop(game, c); } -pub(crate) fn choose_fighter_casualties() -> Builtin { - choose_casualties( - Builtin::builder("Choose Casualties", "Choose which carried units to remove."), - 1, - |c| c.fighters, - |game, player| get_combat(game).fighting_units(game, player), - kill_units, - ) -} - -pub(crate) fn choose_carried_units_casualties() -> Builtin { - choose_casualties( - Builtin::builder( - "Choose Casualties (carried units)", - "Choose which carried units to remove.", - ), - 2, - |c| c.carried_units, - |game, player| { - let c = get_combat(game); - let pos = c.position(player); - let carried: Vec = game - .get_player(player) - .get_units(pos) - .into_iter() - .filter(|u| u.carrier_id.is_some()) - .map(|u| u.id) - .collect(); - carried - }, - |game, player, units| { - kill_units(game, player, units); - save_carried_units(units, game, player, get_combat(game).position(player)); - }, - ) -} - -pub(crate) fn offer_retreat() -> Builtin { - Builtin::builder("Offer Retreat", "Do you want to retreat?") - .add_bool_request( - |event| &mut event.on_combat_round_end, - 0, - |game, player, ()| { - let c = get_combat(game); - let r = c.round_result.as_ref().expect("no round result"); - if c.attacker == player && r.can_retreat { - let p = game.get_player(player); - let name = p.get_name(); - game.add_info_log_item(&format!("{name} can retreat",)); - true - } else { - false - } - }, - |game, _player, player_name, retreat| { - if retreat { - game.add_info_log_item(&format!("{player_name} retreats",)); - } else { - game.add_info_log_item(&format!("{player_name} does not retreat",)); - } - let mut c = take_combat(game); - c.round_result.as_mut().expect("no round result").retreated = retreat; - game.state = GameState::Combat(c); - }, - ) - .build() -} - -pub(crate) fn choose_casualties( - builder: BuiltinBuilder, - priority: i32, - get_casualties: impl Fn(&Casualties) -> u8 + 'static + Clone, - get_choices: impl Fn(&Game, usize) -> Vec + 'static + Clone, - kill_units: impl Fn(&mut Game, usize, &[u32]) + 'static + Copy, -) -> Builtin { - builder - .add_units_request( - |event| &mut event.on_combat_round_end, - priority, - move |game, player, ()| { - let c = get_combat(game); - let r = c.round_result.as_ref().expect("no round result"); - - let choices = get_choices(game, player).clone(); - - let attacker = player == c.attacker; - let role = if attacker { "attacking" } else { "defending" }; - let casualties = get_casualties(r.casualties(attacker)); - if casualties == 0 { - return None; - } - let p = game.get_player(player); - let name = p.get_name(); - if casualties == choices.len() as u8 { - game.add_info_log_item(&format!( - "{name} has to remove all of their {role} units", - )); - kill_units(game, player, &choices); - return None; - } - - let first_type = p - .get_unit(*choices.first().expect("no units")) - .expect("unit should exist") - .unit_type; - if choices - .iter() - .all(|u| p.get_unit(*u).expect("unit should exist").unit_type == first_type) - || !p.is_human() - { - game.add_info_log_item(&format!( - "{name} has to remove {casualties} of their {role} units", - )); - kill_units(game, player, &choices[..casualties as usize]); - return None; - } - - game.add_info_log_item(&format!( - "{name} has to remove {casualties} of their {role} units", - )); - Some(CustomPhaseUnitsRequest::new( - choices, - casualties, - Some(format!("Remove {casualties} {role} units")), - )) - }, - move |game, player, r| { - kill_units(game, player, r); - }, - ) - .build() -} - #[must_use] pub(crate) fn get_combat(game: &Game) -> &Combat { let GameState::Combat(c) = &game.state else { @@ -515,65 +227,6 @@ pub(crate) fn get_combat(game: &Game) -> &Combat { c } -fn kill_units(game: &mut Game, player: usize, killed_unit_ids: &[u32]) { - let p = game.get_player(player); - game.add_info_log_item(&format!( - "{} removed {}", - p.get_name(), - killed_unit_ids - .iter() - .map(|id| p.get_unit(*id).expect("unit not found").unit_type) - .collect::() - )); - - let mut c = take_combat(game); - let killer = c.enemy(player); - - for unit in killed_unit_ids { - game.kill_unit(*unit, player, killer); - if player == c.attacker { - c.attackers.retain(|id| id != unit); - } - } - game.state = GameState::Combat(c); -} - -fn save_carried_units(killed_unit_ids: &[u32], game: &mut Game, player: usize, pos: Position) { - let mut carried_units: HashMap = HashMap::new(); - - for unit in game.get_player(player).clone().get_units(pos) { - if killed_unit_ids.contains(&unit.id) { - continue; - } - if let Some(carrier) = unit.carrier_id { - carried_units - .entry(carrier) - .and_modify(|e| *e += 1) - .or_insert(1); - } else { - carried_units.entry(unit.id).or_insert(0); - } - } - - // embark to surviving ships - for unit in game.get_player(player).clone().get_units(pos) { - let unit = game.players[player] - .get_unit_mut(unit.id) - .expect("unit not found"); - if unit - .carrier_id - .is_some_and(|id| killed_unit_ids.contains(&id)) - { - let (&carrier_id, &carried) = carried_units - .iter() - .find(|(_carrier_id, carried)| **carried < SHIP_CAPACITY) - .expect("no carrier found to save carried units"); - carried_units.insert(carrier_id, carried + 1); - unit.carrier_id = Some(carrier_id); - } - } -} - pub(crate) fn combat_loop(game: &mut Game, mut c: Combat) { loop { game.add_info_log_group(format!("Combat round {}", c.round)); @@ -679,10 +332,11 @@ pub(crate) fn combat_round_end(game: &mut Game) -> Option { let c = get_combat(game); let attacker = c.attacker; let defender = c.defender; + let r = &c.round_result.clone().expect("no round result"); if game.trigger_custom_phase_event( &[attacker, defender], |events| &mut events.on_combat_round_end, - &(), + r, None, ) { return None; @@ -707,8 +361,8 @@ pub(crate) fn combat_round_end(game: &mut Game) -> Option { fn attacker_wins(game: &mut Game, mut c: Combat) -> Option { game.add_info_log_item("Attacker wins"); - game.move_units(c.attacker, &c.attackers, c.defender_position, None); - game.capture_position(c.defender, c.defender_position, c.attacker); + move_units(game, c.attacker, &c.attackers, c.defender_position, None); + capture_position(game, c.defender, c.defender_position, c.attacker); c.result = Some(CombatResult::AttackerWins); end_combat(game, c) } @@ -897,36 +551,271 @@ pub fn can_remove_after_combat(on_water: bool, unit_type: &UnitType) -> bool { } } -pub(crate) fn place_settler() -> Builtin { - Builtin::builder( - "Place Settler", - "After losing a city, place a settler in another city.", - ) - .add_position_request( - |event| &mut event.on_combat_end, - 0, - |game, player_index, i| { - let p = game.get_player(player_index); - if i.is_defender(player_index) - && i.is_loser(player_index) - && game.get_any_city(i.defender_position).is_some() - && !p.cities.is_empty() - && p.available_units().settlers > 0 - && p.is_human() +pub(crate) fn conquer_city( + game: &mut Game, + position: Position, + new_player_index: usize, + old_player_index: usize, +) { + let Some(mut city) = game.players[old_player_index].take_city(position) else { + panic!("player should have this city") + }; + // undo would only be possible if the old owner can't spawn a settler + // and this would be hard to understand + game.lock_undo(); + game.add_to_last_log_item(&format!( + " and captured {}'s city at {position}", + game.players[old_player_index].get_name() + )); + let attacker_is_human = game.get_player(new_player_index).is_human(); + let size = city.mood_modified_size(&game.players[new_player_index]); + if attacker_is_human { + game.players[new_player_index].gain_resources(ResourcePile::gold(size as u32)); + } + let take_over = game.get_player(new_player_index).is_city_available(); + + if take_over { + city.player_index = new_player_index; + city.mood_state = Angry; + if attacker_is_human { + for wonder in &city.pieces.wonders { + (wonder.listeners.deinitializer)(game, old_player_index); + (wonder.listeners.initializer)(game, new_player_index); + } + + for (building, owner) in city.pieces.building_owners() { + if matches!(building, Building::Obelisk) { + continue; + } + let Some(owner) = owner else { + continue; + }; + if owner != old_player_index { + continue; + } + if game.players[new_player_index].is_building_available(building, game) { + city.pieces.set_building(building, new_player_index); + } else { + city.pieces.remove_building(building); + game.players[new_player_index].gain_resources(ResourcePile::gold(1)); + } + } + } + game.players[new_player_index].cities.push(city); + } else { + game.players[new_player_index].gain_resources(ResourcePile::gold(city.size() as u32)); + city.raze(game, old_player_index); + } +} + +pub fn capture_position(game: &mut Game, old_player: usize, position: Position, new_player: usize) { + let captured_settlers = game.players[old_player] + .get_units(position) + .iter() + .map(|unit| unit.id) + .collect_vec(); + if !captured_settlers.is_empty() { + game.add_to_last_log_item(&format!( + " and killed {} settlers of {}", + captured_settlers.len(), + game.players[old_player].get_name() + )); + } + for id in captured_settlers { + game.players[old_player].remove_unit(id); + } + if game.get_player(old_player).get_city(position).is_some() { + conquer_city(game, position, new_player, old_player); + } +} + +fn move_to_defended_tile( + game: &mut Game, + player_index: usize, + move_state: Option<&mut MoveState>, + units: &Vec, + destination: Position, + starting_position: Position, + defender: usize, +) -> bool { + let has_defending_units = game.players[defender] + .get_units(destination) + .iter() + .any(|unit| !unit.unit_type.is_settler()); + let has_fortress = game.players[defender] + .get_city(destination) + .is_some_and(|city| city.pieces.fortress.is_some()); + + let mut military = false; + for unit_id in units { + let unit = game.players[player_index] + .get_unit_mut(*unit_id) + .expect("the player should have all units to move"); + if !unit.unit_type.is_settler() { + if unit + .movement_restrictions + .contains(&MovementRestriction::Battle) { - let choices: Vec = p.cities.iter().map(|c| c.position).collect(); - Some(PositionRequest::new(choices, None)) - } else { - None + panic!("unit can't attack"); } - }, - |c, _game, pos| { - c.add_info_log_item(&format!( - "{} gained 1 free Settler Unit at {pos} for losing a city", - c.name, - )); - c.gain_unit(c.index, UnitType::Settler, *pos); - }, - ) - .build() + unit.movement_restrictions.push(MovementRestriction::Battle); + military = true; + } + } + assert!(military, "Need military units to attack"); + if let Some(move_state) = move_state { + back_to_move(game, move_state, true); + } + + if has_defending_units || has_fortress { + initiate_combat( + game, + defender, + destination, + player_index, + starting_position, + units.clone(), + game.get_player(player_index).is_human(), + None, + ); + return true; + } + false +} + +pub(crate) fn move_with_possible_combat( + game: &mut Game, + player_index: usize, + move_state: Option<&mut MoveState>, + starting_position: Position, + m: &MoveUnits, +) -> bool { + let enemy = game.enemy_player(player_index, m.destination); + if let Some(defender) = enemy { + if move_to_defended_tile( + game, + player_index, + move_state, + &m.units, + m.destination, + starting_position, + defender, + ) { + return true; + } + } else { + move_units( + game, + player_index, + &m.units, + m.destination, + m.embark_carrier_id, + ); + if let Some(move_state) = move_state { + back_to_move( + game, + move_state, + !starting_position.is_neighbor(m.destination), + ); + } + } + + if let Some(enemy) = enemy { + capture_position(game, enemy, m.destination, player_index); + } + false +} + +#[cfg(test)] +pub mod tests { + use std::collections::HashMap; + + use super::{conquer_city, Game, GameState::Playing}; + + use crate::payment::PaymentOptions; + use crate::utils::tests::FloatEq; + use crate::{ + city::{City, MoodState::*}, + city_pieces::Building::*, + content::civilizations, + map::Map, + player::Player, + position::Position, + utils::Rng, + wonder::Wonder, + }; + + #[must_use] + pub fn test_game() -> Game { + Game { + state: Playing, + custom_phase_state: Vec::new(), + players: Vec::new(), + map: Map::new(HashMap::new()), + starting_player_index: 0, + current_player_index: 0, + action_log: Vec::new(), + action_log_index: 0, + log: [ + String::from("The game has started"), + String::from("Age 1 has started"), + String::from("Round 1/3"), + ] + .iter() + .map(|s| vec![s.to_string()]) + .collect(), + undo_limit: 0, + actions_left: 3, + successful_cultural_influence: false, + round: 1, + age: 1, + messages: vec![String::from("Game has started")], + rng: Rng::from_seed(1_234_567_890), + dice_roll_outcomes: Vec::new(), + dice_roll_log: Vec::new(), + dropped_players: Vec::new(), + wonders_left: Vec::new(), + wonder_amount_left: 0, + incidents_left: Vec::new(), + undo_context_stack: Vec::new(), + } + } + + #[test] + fn conquer_test() { + let old = Player::new(civilizations::tests::get_test_civilization(), 0); + let new = Player::new(civilizations::tests::get_test_civilization(), 1); + + let wonder = Wonder::builder("wonder", "test", PaymentOptions::free(), vec![]).build(); + let mut game = test_game(); + game.players.push(old); + game.players.push(new); + let old = 0; + let new = 1; + + let position = Position::new(0, 0); + game.players[old].cities.push(City::new(old, position)); + game.build_wonder(wonder, position, old); + game.players[old].construct(Academy, position, None); + game.players[old].construct(Obelisk, position, None); + + game.players[old].victory_points(&game).assert_eq(8.0); + + conquer_city(&mut game, position, new, old); + + let c = game.players[new] + .get_city_mut(position) + .expect("player new should the city"); + assert_eq!(1, c.player_index); + assert_eq!(Angry, c.mood_state); + + let old = &game.players[old]; + let new = &game.players[new]; + old.victory_points(&game).assert_eq(4.0); + new.victory_points(&game).assert_eq(5.0); + assert_eq!(0, old.wonders_owned()); + assert_eq!(1, new.wonders_owned()); + assert_eq!(1, old.owned_buildings(&game)); + assert_eq!(1, new.owned_buildings(&game)); + } } diff --git a/server/src/combat_listeners.rs b/server/src/combat_listeners.rs new file mode 100644 index 00000000..0d61fc88 --- /dev/null +++ b/server/src/combat_listeners.rs @@ -0,0 +1,395 @@ +use crate::ability_initializer::AbilityInitializerSetup; +use crate::combat::{get_combat, take_combat, Combat}; +use crate::consts::SHIP_CAPACITY; +use crate::content::builtin::{Builtin, BuiltinBuilder}; +use crate::content::custom_phase_actions::{PositionRequest, UnitsRequest}; +use crate::game::{Game, GameState}; +use crate::position::Position; +use crate::unit::{UnitType, Units}; +use num::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone, PartialEq)] +pub struct CombatStrength { + pub attacker: bool, + pub player_index: usize, + pub extra_dies: u8, + pub extra_combat_value: u8, + pub hit_cancels: u8, + pub roll_log: Vec, +} + +impl CombatStrength { + #[must_use] + pub fn new(player_index: usize, attacker: bool) -> Self { + Self { + player_index, + attacker, + extra_dies: 0, + extra_combat_value: 0, + hit_cancels: 0, + roll_log: vec![], + } + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub enum CombatResult { + AttackerWins, + DefenderWins, + Draw, + Retreat, +} + +#[derive(Clone, PartialEq)] +pub struct CombatResultInfo { + pub result: CombatResult, + pub defender_position: Position, + pub attacker: usize, + pub defender: usize, +} + +impl CombatResultInfo { + #[must_use] + pub fn new( + result: CombatResult, + attacker: usize, + defender: usize, + defender_position: Position, + ) -> Self { + Self { + result, + defender_position, + attacker, + defender, + } + } + + #[must_use] + pub fn is_attacker(&self, player: usize) -> bool { + self.attacker == player + } + + #[must_use] + pub fn is_defender(&self, player: usize) -> bool { + self.attacker != player + } + + #[must_use] + pub fn is_loser(&self, player: usize) -> bool { + if self.is_attacker(player) { + self.result == CombatResult::DefenderWins + } else { + self.result == CombatResult::AttackerWins + } + } + + #[must_use] + pub fn is_winner(&self, player: usize) -> bool { + if self.is_attacker(player) { + self.result == CombatResult::AttackerWins + } else { + self.result == CombatResult::DefenderWins + } + } + + #[must_use] + pub fn opponent(&self, player: usize) -> usize { + if self.is_attacker(player) { + self.defender + } else { + self.attacker + } + } + + #[must_use] + pub fn captured_city(&self, player: usize, game: &Game) -> bool { + self.is_attacker(player) + && self.is_winner(player) + && game.get_any_city(self.defender_position).is_some() + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct Casualties { + #[serde(default)] + #[serde(skip_serializing_if = "u8::is_zero")] + pub fighters: u8, + #[serde(default)] + #[serde(skip_serializing_if = "u8::is_zero")] + pub carried_units: u8, +} + +impl Casualties { + #[must_use] + pub fn new(fighters: u8, game: &Game, c: &Combat, player: usize) -> Self { + Self { + fighters, + carried_units: c.carried_units_casualties(game, player, fighters), + } + } +} +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct CombatRoundResult { + pub attacker_casualties: Casualties, + pub defender_casualties: Casualties, + #[serde(default)] + pub can_retreat: bool, + #[serde(default)] + pub retreated: bool, +} + +impl CombatRoundResult { + #[must_use] + pub fn new( + attacker_casualties: Casualties, + defender_casualties: Casualties, + can_retreat: bool, + ) -> Self { + Self { + attacker_casualties, + defender_casualties, + can_retreat, + retreated: false, + } + } +} + +impl CombatRoundResult { + #[must_use] + pub fn casualties(&self, attacker: bool) -> &Casualties { + if attacker { + &self.attacker_casualties + } else { + &self.defender_casualties + } + } +} + +pub(crate) fn choose_fighter_casualties() -> Builtin { + choose_casualties( + Builtin::builder("Choose Casualties", "Choose which carried units to remove."), + 1, + |c| c.fighters, + |game, player| get_combat(game).fighting_units(game, player), + kill_units, + ) +} + +pub(crate) fn choose_carried_units_casualties() -> Builtin { + choose_casualties( + Builtin::builder( + "Choose Casualties (carried units)", + "Choose which carried units to remove.", + ), + 2, + |c| c.carried_units, + |game, player| { + let c = get_combat(game); + let pos = c.position(player); + let carried: Vec = game + .get_player(player) + .get_units(pos) + .into_iter() + .filter(|u| u.carrier_id.is_some()) + .map(|u| u.id) + .collect(); + carried + }, + |game, player, units| { + kill_units(game, player, units); + save_carried_units(units, game, player, get_combat(game).position(player)); + }, + ) +} + +pub(crate) fn offer_retreat() -> Builtin { + Builtin::builder("Offer Retreat", "Do you want to retreat?") + .add_bool_request( + |event| &mut event.on_combat_round_end, + 0, + |game, player, r| { + let c = get_combat(game); + if c.attacker == player && r.can_retreat { + let p = game.get_player(player); + let name = p.get_name(); + game.add_info_log_item(&format!("{name} can retreat",)); + true + } else { + false + } + }, + |game, retreat| { + let player_name = &retreat.player_name; + if retreat.choice { + game.add_info_log_item(&format!("{player_name} retreats",)); + } else { + game.add_info_log_item(&format!("{player_name} does not retreat",)); + } + let mut c = take_combat(game); + c.round_result.as_mut().expect("no round result").retreated = retreat.choice; + game.state = GameState::Combat(c); + }, + ) + .build() +} + +pub(crate) fn choose_casualties( + builder: BuiltinBuilder, + priority: i32, + get_casualties: impl Fn(&Casualties) -> u8 + 'static + Clone, + get_choices: impl Fn(&Game, usize) -> Vec + 'static + Clone, + kill_units: impl Fn(&mut Game, usize, &[u32]) + 'static + Copy, +) -> Builtin { + builder + .add_units_request( + |event| &mut event.on_combat_round_end, + priority, + move |game, player, r| { + let c = get_combat(game); + + let choices = get_choices(game, player).clone(); + + let attacker = player == c.attacker; + let role = if attacker { "attacking" } else { "defending" }; + let casualties = get_casualties(r.casualties(attacker)); + if casualties == 0 { + return None; + } + let p = game.get_player(player); + let name = p.get_name(); + if casualties == choices.len() as u8 { + game.add_info_log_item(&format!( + "{name} has to remove all of their {role} units", + )); + kill_units(game, player, &choices); + return None; + } + + let first_type = p + .get_unit(*choices.first().expect("no units")) + .expect("unit should exist") + .unit_type; + if choices + .iter() + .all(|u| p.get_unit(*u).expect("unit should exist").unit_type == first_type) + || !p.is_human() + { + game.add_info_log_item(&format!( + "{name} has to remove {casualties} of their {role} units", + )); + kill_units(game, player, &choices[..casualties as usize]); + return None; + } + + game.add_info_log_item(&format!( + "{name} has to remove {casualties} of their {role} units", + )); + Some(UnitsRequest::new( + player, + choices, + casualties, + Some(format!("Remove {casualties} {role} units")), + )) + }, + move |game, player, r| { + kill_units(game, player, r); + }, + ) + .build() +} + +pub(crate) fn place_settler() -> Builtin { + Builtin::builder( + "Place Settler", + "After losing a city, place a settler in another city.", + ) + .add_position_request( + |event| &mut event.on_combat_end, + 0, + |game, player_index, i| { + let p = game.get_player(player_index); + if i.is_defender(player_index) + && i.is_loser(player_index) + && game.get_any_city(i.defender_position).is_some() + && !p.cities.is_empty() + && p.available_units().settlers > 0 + && p.is_human() + { + let choices: Vec = p.cities.iter().map(|c| c.position).collect(); + Some(PositionRequest::new(choices, None)) + } else { + None + } + }, + |game, s| { + game.add_info_log_item(&format!( + "{} gained 1 free Settler Unit at {} for losing a city", + s.player_name, s.choice + )); + game.get_player_mut(s.player_index) + .add_unit(s.choice, UnitType::Settler); + }, + ) + .build() +} + +fn kill_units(game: &mut Game, player: usize, killed_unit_ids: &[u32]) { + let p = game.get_player(player); + game.add_info_log_item(&format!( + "{} removed {}", + p.get_name(), + killed_unit_ids + .iter() + .map(|id| p.get_unit(*id).expect("unit not found").unit_type) + .collect::() + )); + + let mut c = take_combat(game); + let killer = c.opponent(player); + + for unit in killed_unit_ids { + game.kill_unit(*unit, player, killer); + if player == c.attacker { + c.attackers.retain(|id| id != unit); + } + } + game.state = GameState::Combat(c); +} + +fn save_carried_units(killed_unit_ids: &[u32], game: &mut Game, player: usize, pos: Position) { + let mut carried_units: HashMap = HashMap::new(); + + for unit in game.get_player(player).clone().get_units(pos) { + if killed_unit_ids.contains(&unit.id) { + continue; + } + if let Some(carrier) = unit.carrier_id { + carried_units + .entry(carrier) + .and_modify(|e| *e += 1) + .or_insert(1); + } else { + carried_units.entry(unit.id).or_insert(0); + } + } + + // embark to surviving ships + for unit in game.get_player(player).clone().get_units(pos) { + let unit = game.players[player] + .get_unit_mut(unit.id) + .expect("unit not found"); + if unit + .carrier_id + .is_some_and(|id| killed_unit_ids.contains(&id)) + { + let (&carrier_id, &carried) = carried_units + .iter() + .find(|(_carrier_id, carried)| **carried < SHIP_CAPACITY) + .expect("no carrier found to save carried units"); + carried_units.insert(carrier_id, carried + 1); + unit.carrier_id = Some(carrier_id); + } + } +} diff --git a/server/src/consts.rs b/server/src/consts.rs index 4f94beaa..9c14d590 100644 --- a/server/src/consts.rs +++ b/server/src/consts.rs @@ -16,6 +16,7 @@ pub const MOVEMENT_ACTIONS: u32 = 3; pub const ARMY_MOVEMENT_REQUIRED_ADVANCE: &str = TACTICS; pub const CITY_PIECE_LIMIT: usize = 5; pub const ACTIONS: u32 = 3; +pub const NON_HUMAN_PLAYERS: usize = 2; // pirates, barbarians pub const UNIT_LIMIT: Units = Units { settlers: 4, @@ -25,6 +26,22 @@ pub const UNIT_LIMIT: Units = Units { elephants: 4, leaders: 1, }; +pub const UNIT_LIMIT_BARBARIANS: Units = Units { + settlers: 0, + infantry: 20, + ships: 0, + cavalry: 4, + elephants: 4, + leaders: 0, +}; +pub const UNIT_LIMIT_PIRATES: Units = Units { + settlers: 0, + infantry: 0, + ships: 4, + cavalry: 0, + elephants: 0, + leaders: 0, +}; pub const CONSTRUCT_COST: ResourcePile = ResourcePile { food: 1, wood: 1, diff --git a/server/src/content/advances_autocracy.rs b/server/src/content/advances_autocracy.rs index 36c5508f..f90ff4d2 100644 --- a/server/src/content/advances_autocracy.rs +++ b/server/src/content/advances_autocracy.rs @@ -34,19 +34,22 @@ fn nationalism() -> AdvanceBuilder { .iter() .any(|u| u.is_army_unit() || u.is_ship()) { - Some(ResourceRewardRequest { - reward: PaymentOptions::sum( + Some(ResourceRewardRequest::new( + PaymentOptions::sum( 1, &[ResourceType::MoodTokens, ResourceType::CultureTokens], ), - name: "Select token to gain".to_string(), - }) + "Select token to gain".to_string(), + )) } else { None } }, - |_game, _player_index, player_name, resource, _selected| { - format!("{player_name} selected {resource} for Nationalism Advance") + |_game, resource| { + vec![format!( + "{} selected {} for Nationalism Advance", + resource.player_name, resource.choice + )] }, ) } diff --git a/server/src/content/advances_democracy.rs b/server/src/content/advances_democracy.rs index 33f52967..67a5fea9 100644 --- a/server/src/content/advances_democracy.rs +++ b/server/src/content/advances_democracy.rs @@ -39,7 +39,7 @@ fn separation_of_power() -> AdvanceBuilder { info.set_no_boost(); } }, - 0, + 2, ) } diff --git a/server/src/content/advances_economy.rs b/server/src/content/advances_economy.rs index 636ec6fe..d9d335f9 100644 --- a/server/src/content/advances_economy.rs +++ b/server/src/content/advances_economy.rs @@ -5,12 +5,13 @@ use crate::city_pieces::Building::Market; use crate::content::advances::{advance_group_builder, AdvanceGroup, CURRENCY}; use crate::content::custom_actions::CustomActionType::Taxes; use crate::content::custom_phase_actions::ResourceRewardRequest; -use crate::content::trade_routes::{trade_route_log, trade_route_reward}; +use crate::content::trade_routes::{trade_route_log, trade_route_reward, TradeRoute}; use crate::game::Game; use crate::payment::PaymentOptions; use crate::player::Player; use crate::resource::ResourceType; use crate::resource_pile::ResourcePile; +use itertools::Itertools; pub(crate) fn economy() -> AdvanceGroup { advance_group_builder( @@ -52,7 +53,7 @@ pub fn tax_options(player: &Player) -> PaymentOptions { pub(crate) fn collect_taxes(game: &mut Game, player_index: usize, gain: ResourcePile) { assert!( tax_options(game.get_player(player_index)).is_valid_payment(&gain), - "Invalid payment for Taxes" + "Invalid gain for Taxes" ); game.players[player_index].gain_resources(gain); } @@ -65,17 +66,41 @@ fn trade_routes() -> AdvanceBuilder { |event| &mut event.on_turn_start, 0, |game, _player_index, ()| { - trade_route_reward(game).map(|(reward, _routes)| { - ResourceRewardRequest { + trade_route_reward(game).map(|(reward, routes)| { + gain_market_bonus(game, &routes); + ResourceRewardRequest::new( reward, - name: "Collect trade routes reward".to_string(), - } + "Collect trade routes reward".to_string(), + ) }) }, - |game, player_index, _player_name, p, selected| { + |game, p| { let (_, routes) = trade_route_reward(game).expect("No trade route reward"); - trade_route_log(game, player_index, &routes, p, selected) + trade_route_log(game, p.player_index, &routes, &p.choice, p.actively_selected) }, ) } + +fn gain_market_bonus(game: &mut Game, routes: &[TradeRoute]) { + let players = routes + .iter() + .filter_map(|r| { + game.get_any_city(r.to).and_then(|c| { + if c.pieces.market.is_some() { + Some(c.player_index) + } else { + None + } + }) + }) + .unique() + .collect_vec(); + for p in players { + let name = game.get_player(p).get_name(); + game.add_info_log_item(&format!( + "{name} gains 1 gold for using a Market in a trade route", + )); + game.get_player_mut(p).gain_resources(ResourcePile::gold(1)); + } +} diff --git a/server/src/content/advances_education.rs b/server/src/content/advances_education.rs index 9bebbbc0..4376d04b 100644 --- a/server/src/content/advances_education.rs +++ b/server/src/content/advances_education.rs @@ -51,7 +51,7 @@ fn free_education() -> AdvanceBuilder { an extra 1 idea to gain 1 mood token", ) .with_advance_bonus(MoodToken) - .add_payment_request_with_commands_listener( + .add_payment_request_listener( |e| &mut e.on_advance_custom_phase, 1, |_game, _player_index, i| { @@ -69,12 +69,14 @@ fn free_education() -> AdvanceBuilder { None } }, - |c, _game, payment| { - c.add_info_log_item(&format!( - "{} paid {} for free education to gain 1 mood token", - c.name, payment[0] - )); - c.gain_resources(ResourcePile::mood_tokens(1)); + |game, payment| { + payment.to_commands(game, |c, _game, payment| { + c.add_info_log_item(&format!( + "{} paid {} for free education to gain 1 mood token", + c.name, payment[0] + )); + c.gain_resources(ResourcePile::mood_tokens(1)); + }); }, ) } diff --git a/server/src/content/advances_science.rs b/server/src/content/advances_science.rs index c3e548af..c95a2d4f 100644 --- a/server/src/content/advances_science.rs +++ b/server/src/content/advances_science.rs @@ -72,14 +72,21 @@ fn medicine() -> AdvanceBuilder { return None; } - Some(ResourceRewardRequest { - reward: PaymentOptions::sum(1, &types), - name: "Select resource to gain back".to_string(), - }) + Some(ResourceRewardRequest::new( + PaymentOptions::sum(1, &types), + "Select resource to gain back".to_string(), + )) }, - |_game, _player_index, player_name, resource, selected| { - let verb = if selected { "selected" } else { "gained" }; - format!("{player_name} {verb} {resource} for Medicine Advance") + |_game, s| { + let verb = if s.actively_selected { + "selected" + } else { + "gained" + }; + vec![format!( + "{} {verb} {} for Medicine Advance", + s.player_name, s.choice + )] }, ) } diff --git a/server/src/content/advances_seafearing.rs b/server/src/content/advances_seafearing.rs index 0e487619..8cb58a58 100644 --- a/server/src/content/advances_seafearing.rs +++ b/server/src/content/advances_seafearing.rs @@ -5,6 +5,7 @@ use crate::city_pieces::Building::Port; use crate::collect::{CollectContext, CollectInfo}; use crate::content::advances::{advance_group_builder, AdvanceGroup, NAVIGATION}; use crate::game::Game; +use crate::position::Position; use crate::resource_pile::ResourcePile; use std::collections::HashSet; @@ -17,7 +18,7 @@ pub(crate) fn seafaring() -> AdvanceGroup { fn fishing() -> AdvanceBuilder { Advance::builder("Fishing", "Your cities may Collect food from one Sea space") - .add_player_event_listener(|event| &mut event.collect_options, fishing_collect, 0) + .add_player_event_listener(|event| &mut event.collect_options, fishing_collect, 1) .with_advance_bonus(MoodToken) .with_unlocked_building(Port) } @@ -37,8 +38,8 @@ fn war_ships() -> AdvanceBuilder { .add_player_event_listener( |event| &mut event.on_combat_round, |s, c, g| { - let attacker = s.attacker && g.map.is_water(c.attacker_position); - let defender = !s.attacker && g.map.is_water(c.defender_position); + let attacker = s.attacker && g.map.is_sea(c.attacker_position); + let defender = !s.attacker && g.map.is_sea(c.defender_position); if c.round == 1 && (attacker || defender) { s.hit_cancels += 1; s.roll_log.push("War Ships ignore the first hit in the first round of combat".to_string()); @@ -86,18 +87,23 @@ fn cartography() -> AdvanceBuilder { ) } +#[must_use] +fn is_enemy_player_or_pirate_zone(game: &Game, player_index: usize, position: Position) -> bool { + game.enemy_player(player_index, position).is_some() || game.is_pirate_zone(position) +} + fn fishing_collect(i: &mut CollectInfo, c: &CollectContext, game: &Game) { let city = game .get_any_city(c.city_position) .expect("city should exist"); let port = city.port_position; - if let Some(position) = - port.filter(|p| game.enemy_player(c.player_index, *p).is_none()) - .or_else(|| { - c.city_position.neighbors().into_iter().find(|p| { - game.map.is_water(*p) && game.enemy_player(c.player_index, *p).is_none() - }) + if let Some(position) = port + .filter(|p| !is_enemy_player_or_pirate_zone(game, c.player_index, *p)) + .or_else(|| { + c.city_position.neighbors().into_iter().find(|p| { + game.map.is_sea(*p) && !is_enemy_player_or_pirate_zone(game, c.player_index, *p) }) + }) { i.choices.insert( position, diff --git a/server/src/content/advances_spirituality.rs b/server/src/content/advances_spirituality.rs index b3ad24bf..98e366b8 100644 --- a/server/src/content/advances_spirituality.rs +++ b/server/src/content/advances_spirituality.rs @@ -25,15 +25,18 @@ fn myths() -> AdvanceBuilder { 1, |_game, _player_index, building| { if matches!(building, Temple) { - return Some(ResourceRewardRequest { - reward: PaymentOptions::sum(1, &[MoodTokens, CultureTokens]), - name: "Select Temple bonus".to_string(), - }); + return Some(ResourceRewardRequest::new( + PaymentOptions::sum(1, &[MoodTokens, CultureTokens]), + "Select Temple bonus".to_string(), + )); } None }, - |_game, _player_index, player_name, p, _selected| { - format!("{player_name} selected {p} as a reward for constructing a Temple") + |_game, p| { + vec![format!( + "{} selected {} as a reward for constructing a Temple", + p.player_name, p.choice + )] }, ) } diff --git a/server/src/content/advances_theocracy.rs b/server/src/content/advances_theocracy.rs index 844ac415..636caab5 100644 --- a/server/src/content/advances_theocracy.rs +++ b/server/src/content/advances_theocracy.rs @@ -1,5 +1,5 @@ use crate::ability_initializer::AbilityInitializerSetup; -use crate::advance::{Advance, AdvanceBuilder}; +use crate::advance::{advance_with_incident_token, Advance, AdvanceBuilder}; use crate::city_pieces::Building::Temple; use crate::consts::STACK_LIMIT; use crate::content::advances::{advance_group_builder, get_group, AdvanceGroup}; @@ -48,16 +48,16 @@ fn dogma() -> AdvanceBuilder { } None }, - |game, player_index, player_name, name, selected| { - let verb = if selected { + |game, c| { + let verb = if c.actively_selected { "selected" } else { "got" }; game.add_info_log_item(&format!( - "{player_name} {verb} {name} as a reward for constructing a Temple", + "{} {verb} {} as a reward for constructing a Temple", c.player_name, c.choice )); - game.advance_with_incident_token(name, player_index, ResourcePile::empty()); + advance_with_incident_token(game, &c.choice, c.player_index, ResourcePile::empty()); }, ) } @@ -74,7 +74,7 @@ fn devotion() -> AdvanceBuilder { info.set_no_boost(); } }, - 0, + 4, ) } @@ -88,7 +88,7 @@ fn conversion() -> AdvanceBuilder { info.info.log.push("Player gets +1 to Influence Culture roll for Conversion Advance".to_string()); } }, - 0, + 3, ) .add_player_event_listener( |event| &mut event.on_influence_culture_success, @@ -129,12 +129,11 @@ fn fanaticism() -> AdvanceBuilder { None } }, - |c, _game, pos| { - c.add_info_log_item(&format!( - "{} gained 1 free Infantry Unit at {pos} for Fanaticism Advance", - c.name, + |game, s| { + game.add_info_log_item(&format!( + "{} gained 1 free Infantry Unit at {} for Fanaticism Advance", s.player_name, s.choice )); - c.gain_unit(c.index, UnitType::Infantry, *pos); + game.get_player_mut(s.player_index).add_unit(s.choice, UnitType::Infantry); }, ) } diff --git a/server/src/content/advances_warfare.rs b/server/src/content/advances_warfare.rs index fcbed234..51e3e28a 100644 --- a/server/src/content/advances_warfare.rs +++ b/server/src/content/advances_warfare.rs @@ -5,7 +5,8 @@ use crate::city_pieces::Building::Fortress; use crate::combat::CombatModifier::{ CancelFortressExtraDie, CancelFortressIgnoreHit, SteelWeaponsAttacker, SteelWeaponsDefender, }; -use crate::combat::{get_combat, Combat, CombatModifier, CombatStrength}; +use crate::combat::{get_combat, Combat, CombatModifier}; +use crate::combat_listeners::CombatStrength; use crate::content::advances::{ advance_group_builder, AdvanceGroup, METALLURGY, STEEL_WEAPONS, TACTICS, }; @@ -69,11 +70,12 @@ fn siegecraft() -> AdvanceBuilder { None } }, - |game, _player_index, player_name, payment| { + |game, s| { game.add_info_log_item( - &format!("{player_name} paid for siegecraft: ")); + &format!("{} paid for siegecraft: ", s.player_name)); let mut paid = false; let mut modifiers: Vec = Vec::new(); + let payment = &s.choice; if !payment[0].is_empty() { modifiers.push(CancelFortressExtraDie); game.add_to_last_log_item(&format!("{} to cancel the fortress ability to add an extra die", payment[0])); @@ -125,11 +127,11 @@ fn steel_weapons() -> AdvanceBuilder { None } }, - |game, player_index, player_name, payment| { + |game, s| { let GameState::Combat(c) = &mut game.state else { panic!("Invalid state") }; - add_steel_weapons(player_index, c); + add_steel_weapons(s.player_index, c); game.add_info_log_item( - &format!("{player_name} paid for steel weapons: {}", payment[0])); + &format!("{} paid for steel weapons: {}", s.player_name, s.choice[0])); }, ) .add_player_event_listener( diff --git a/server/src/content/builtin.rs b/server/src/content/builtin.rs index b51fe1dc..608bd07c 100644 --- a/server/src/content/builtin.rs +++ b/server/src/content/builtin.rs @@ -1,10 +1,11 @@ use crate::ability_initializer::AbilityInitializerSetup; use crate::ability_initializer::{AbilityInitializerBuilder, AbilityListeners}; use crate::barbarians::barbarians_bonus; -use crate::combat::{ +use crate::combat_listeners::{ choose_carried_units_casualties, choose_fighter_casualties, offer_retreat, place_settler, }; use crate::events::EventOrigin; +use crate::pirates::{pirates_bonus, pirates_round_bonus}; pub struct Builtin { pub name: String, @@ -62,6 +63,8 @@ pub fn get_all() -> Vec { choose_carried_units_casualties(), offer_retreat(), barbarians_bonus(), + pirates_bonus(), + pirates_round_bonus(), ] } diff --git a/server/src/content/civilizations.rs b/server/src/content/civilizations.rs index 59b41ce3..e356eedf 100644 --- a/server/src/content/civilizations.rs +++ b/server/src/content/civilizations.rs @@ -6,11 +6,13 @@ use crate::special_advance::SpecialAdvance; use crate::{civilization::Civilization, leader::Leader}; pub const BARBARIANS: &str = "Barbarians"; +pub const PIRATES: &str = "Pirates"; #[must_use] pub fn get_all() -> Vec { vec![ Civilization::new(BARBARIANS, vec![], vec![]), + Civilization::new(PIRATES, vec![], vec![]), Civilization::new( "test0", vec![], diff --git a/server/src/content/custom_actions.rs b/server/src/content/custom_actions.rs index 71a2f9b4..b776f8bf 100644 --- a/server/src/content/custom_actions.rs +++ b/server/src/content/custom_actions.rs @@ -7,14 +7,15 @@ use crate::content::advances_culture::{ }; use crate::content::advances_economy::collect_taxes; use crate::content::wonders::construct_wonder; +use crate::cultural_influence::influence_culture_attempt; use crate::log::{ current_turn_log, format_city_happiness_increase, format_collect_log_item, format_cultural_influence_attempt_log_item, format_happiness_increase, }; use crate::player::Player; use crate::playing_actions::{ - increase_happiness, influence_culture_attempt, undo_increase_happiness, Collect, - IncreaseHappiness, InfluenceCultureAttempt, PlayingAction, + increase_happiness, undo_increase_happiness, Collect, IncreaseHappiness, + InfluenceCultureAttempt, PlayingAction, }; use crate::{ game::Game, playing_actions::ActionType, position::Position, resource_pile::ResourcePile, diff --git a/server/src/content/custom_phase_actions.rs b/server/src/content/custom_phase_actions.rs index e7ac0bc7..1e02295c 100644 --- a/server/src/content/custom_phase_actions.rs +++ b/server/src/content/custom_phase_actions.rs @@ -1,12 +1,14 @@ -use crate::action::Action; +use crate::action::{execute_custom_phase_action, Action}; +use crate::advance::undo_advance; use crate::barbarians::BarbariansEventState; use crate::content::advances::get_advance; use crate::events::EventOrigin; -use crate::game::{Game, UndoContext}; +use crate::game::Game; use crate::payment::PaymentOptions; use crate::playing_actions::PlayingAction; use crate::position::Position; use crate::resource_pile::ResourcePile; +use crate::undo::UndoContext; use crate::unit::UnitType; use serde::{Deserialize, Serialize}; @@ -17,12 +19,30 @@ pub struct PaymentRequest { pub optional: bool, } +impl PaymentRequest { + #[must_use] + pub fn new(cost: PaymentOptions, name: String, optional: bool) -> Self { + Self { + cost, + name, + optional, + } + } +} + #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct ResourceRewardRequest { pub reward: PaymentOptions, pub name: String, } +impl ResourceRewardRequest { + #[must_use] + pub fn new(reward: PaymentOptions, name: String) -> Self { + Self { reward, name } + } +} + #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub struct AdvanceRewardRequest { pub choices: Vec, @@ -33,9 +53,10 @@ pub enum CustomPhaseRequest { Payment(Vec), ResourceReward(ResourceRewardRequest), AdvanceReward(AdvanceRewardRequest), + SelectPlayer(PlayerRequest), SelectPosition(PositionRequest), SelectUnitType(UnitTypeRequest), - SelectUnits(CustomPhaseUnitsRequest), + SelectUnits(UnitsRequest), BoolRequest, } @@ -44,6 +65,7 @@ pub enum CustomPhaseEventAction { Payment(Vec), ResourceReward(ResourcePile), AdvanceReward(String), + SelectPlayer(usize), SelectPosition(Position), SelectUnitType(UnitType), SelectUnits(Vec), @@ -70,10 +92,15 @@ pub struct CustomPhaseEventState { pub last_priority_used: Option, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] + pub current: Option, + + // saved state for other handlers + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] pub barbarians: Option, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] - pub current: Option, + pub selected_player: Option, } impl CustomPhaseEventState { @@ -84,6 +111,7 @@ impl CustomPhaseEventState { last_priority_used: None, current: None, barbarians: None, + selected_player: None, event_type, } } @@ -92,6 +120,11 @@ impl CustomPhaseEventState { pub fn is_empty(&self) -> bool { self.current.is_none() && self.players_used.is_empty() && self.last_priority_used.is_none() } + + #[must_use] + pub fn is_active_player(&self) -> bool { + self.players_used.is_empty() + } } #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] @@ -134,7 +167,8 @@ impl UnitTypeRequest { } #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub struct CustomPhaseUnitsRequest { +pub struct UnitsRequest { + pub player: usize, pub choices: Vec, pub needed: u8, #[serde(default)] @@ -142,10 +176,11 @@ pub struct CustomPhaseUnitsRequest { pub description: Option, } -impl CustomPhaseUnitsRequest { +impl UnitsRequest { #[must_use] - pub fn new(choices: Vec, needed: u8, description: Option) -> Self { + pub fn new(player: usize, choices: Vec, needed: u8, description: Option) -> Self { Self { + player, choices, needed, description, @@ -153,6 +188,22 @@ impl CustomPhaseUnitsRequest { } } +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct PlayerRequest { + pub choices: Vec, + pub description: String, +} + +impl PlayerRequest { + #[must_use] + pub fn new(choices: Vec, description: &str) -> Self { + Self { + choices, + description: description.to_string(), + } + } +} + impl CustomPhaseEventAction { pub(crate) fn undo(self, game: &mut Game, player_index: usize) { match self { @@ -166,10 +217,11 @@ impl CustomPhaseEventAction { game.players[player_index].lose_resources(r); } CustomPhaseEventAction::AdvanceReward(n) => { - game.undo_advance(&get_advance(&n), player_index, false); + undo_advance(game, &get_advance(&n), player_index, false); } CustomPhaseEventAction::Bool(_) | CustomPhaseEventAction::SelectUnits(_) + | CustomPhaseEventAction::SelectPlayer(_) | CustomPhaseEventAction::SelectPosition(_) | CustomPhaseEventAction::SelectUnitType(_) => { // done with payer commands - or can't undo @@ -178,7 +230,7 @@ impl CustomPhaseEventAction { let Some(UndoContext::CustomPhaseEvent(e)) = game.pop_undo_context() else { panic!("when undoing custom phase event, the undo context stack should have a custom phase event") }; - game.custom_phase_state.push(e); + game.custom_phase_state.push(*e); if let Some(action) = game.action_log.get(game.action_log_index - 1) { // is there a better way to do this? if let Action::Playing(PlayingAction::Advance { .. }) = action.action { @@ -193,6 +245,6 @@ impl CustomPhaseEventAction { }; s.response = Some(self.clone()); let event_type = game.current_custom_phase().event_type.clone(); - game.execute_custom_phase_action(player_index, &event_type); + execute_custom_phase_action(game, player_index, &event_type); } } diff --git a/server/src/content/incidents.rs b/server/src/content/incidents.rs index 62d29db0..be72273b 100644 --- a/server/src/content/incidents.rs +++ b/server/src/content/incidents.rs @@ -1,66 +1,190 @@ -use crate::content::custom_phase_actions::ResourceRewardRequest; -use crate::incident::{Incident, IncidentBaseEffect}; +use crate::content::custom_phase_actions::{PlayerRequest, PositionRequest, ResourceRewardRequest}; +use crate::game::Game; +use crate::incident::{Incident, IncidentBaseEffect, IncidentBuilder}; use crate::payment::PaymentOptions; use crate::player_events::IncidentTarget; use crate::resource::ResourceType; +use crate::unit::UnitType; +use itertools::Itertools; use std::vec; #[must_use] -pub fn get_all() -> Vec { - vec![good_year(), population_boom()] +pub(crate) fn get_all() -> Vec { + let all = vec![good_year(), population_boom(), successful_year()] .into_iter() .flatten() - .collect() + .collect_vec(); + assert_eq!( + all.iter().unique_by(|i| i.id).count(), + all.len(), + "Incident ids are not unique" + ); + all } fn population_boom() -> Vec { - //todo add real effect - vec![Incident::builder(100, "test", "test", IncidentBaseEffect::BarbariansMove).build()] + let mut b = Incident::builder( + 28, + "Population Boom", + "-", + IncidentBaseEffect::BarbariansMove, + ); + b = select_settler(b, 2, |game, _| { + game.current_custom_phase().is_active_player() + }); + b = b.add_incident_player_request( + IncidentTarget::ActivePlayer, + 1, + |game, player_index, _incident| { + let choices = game + .players + .iter() + .filter(|p| p.available_units().settlers > 0 && p.index != player_index) + .map(|p| p.index) + .collect_vec(); + + if choices.is_empty() { + None + } else { + Some(PlayerRequest::new( + choices, + "Select a unit to gain 1 settler", + )) + } + }, + |game, c| { + game.add_info_log_item(&format!( + "{} was selected to gain 1 settler from Population Boom", + game.get_player(c.choice).get_name() + )); + game.current_custom_phase_mut().selected_player = Some(c.choice); + }, + ); + b = select_settler(b, 0, |game, player| { + let c = game.current_custom_phase(); + c.selected_player == Some(player) + }); + vec![b.build()] +} + +fn select_settler( + b: IncidentBuilder, + priority: i32, + pred: impl Fn(&Game, usize) -> bool + 'static + Clone, +) -> IncidentBuilder { + b.add_incident_position_request( + IncidentTarget::AllPlayers, + priority, + move |game, player_index, _incident| { + let p = game.get_player(player_index); + if pred(game, player_index) && p.available_units().settlers > 0 { + Some(PositionRequest::new( + p.cities.iter().map(|c| c.position).collect(), + Some("Select a city to gain 1 settler".to_string()), + )) + } else { + None + } + }, + |game, s| { + game.add_info_log_item(&format!( + "{} gained 1 settler in {}", + s.player_name, s.choice + )); + game.get_player_mut(s.player_index) + .add_unit(s.choice, UnitType::Settler); + }, + ) +} + +fn successful_year() -> Vec { + vec![Incident::builder( + 54, + "A successful year", + "-", + IncidentBaseEffect::PiratesSpawnAndRaid, + ) + .add_incident_resource_request( + IncidentTarget::AllPlayers, + 0, + |game, player_index, _incident| { + let player_to_city_num = game + .players + .iter() + .map(|p| p.cities.len()) + .collect::>(); + + let min_cities = player_to_city_num.iter().min().unwrap_or(&0); + let max_cities = player_to_city_num.iter().max().unwrap_or(&0); + if min_cities == max_cities { + return Some(ResourceRewardRequest::new( + PaymentOptions::sum(1, &[ResourceType::Food]), + "-".to_string(), + )); + } + + let cities = game.players[player_index].cities.len(); + if cities == *min_cities { + Some(ResourceRewardRequest::new( + PaymentOptions::sum((max_cities - min_cities) as u32, &[ResourceType::Food]), + "-".to_string(), + )) + } else { + None + } + }, + |_game, s| { + vec![format!( + "{} gained {} from A successful year", + s.player_name, s.choice + )] + }, + ) + .build()] } fn good_year() -> Vec { vec![ - Incident::builder( - 9, - "A good year", - "Every player gains 1 food", - IncidentBaseEffect::BarbariansSpawn, - ) - .add_incident_resource_request( + add_good_year( IncidentTarget::AllPlayers, - 1, - |_game, _player_index, _incident| { - Some(ResourceRewardRequest { - reward: PaymentOptions::sum(1, &[ResourceType::Food]), - name: "Gain 1 food".to_string(), - }) - }, - |_game, _player_index, player_name, resource, _selected| { - format!("{player_name} gained {resource} from A good year") - }, - ) - .build(), - Incident::builder( - 10, - "A good year", - "You gain 1 food", - IncidentBaseEffect::BarbariansSpawn, - ) - .add_incident_resource_request( + Incident::builder( + 9, + "A good year", + "Every player gains 1 food", + IncidentBaseEffect::BarbariansSpawn, + ), + ), + add_good_year( IncidentTarget::ActivePlayer, - 1, + Incident::builder( + 10, + "A good year", + "You gain 1 food", + IncidentBaseEffect::BarbariansSpawn, + ), + ), + ] +} + +fn add_good_year(target: IncidentTarget, builder: IncidentBuilder) -> Incident { + builder + .add_incident_resource_request( + target, + 0, |_game, _player_index, _incident| { - Some(ResourceRewardRequest { - reward: PaymentOptions::sum(1, &[ResourceType::Food]), - name: "Gain 1 food".to_string(), - }) + Some(ResourceRewardRequest::new( + PaymentOptions::sum(1, &[ResourceType::Food]), + "Gain 1 food".to_string(), + )) }, - |_game, _player_index, player_name, resource, _selected| { - format!("{player_name} gained {resource} from A good year") + |_game, s| { + vec![format!( + "{} gained {} from A good year", + s.player_name, s.choice + )] }, ) - .build(), - ] + .build() } /// diff --git a/server/src/content/trade_routes.rs b/server/src/content/trade_routes.rs index 54e209db..10997d13 100644 --- a/server/src/content/trade_routes.rs +++ b/server/src/content/trade_routes.rs @@ -12,7 +12,7 @@ use crate::unit::Unit; pub struct TradeRoute { unit_id: u32, from: Position, - to: Position, + pub to: Position, } #[must_use] @@ -42,16 +42,16 @@ pub(crate) fn trade_route_log( trade_routes: &[TradeRoute], reward: &ResourcePile, selected: bool, -) -> String { - let mut log = String::new(); +) -> Vec { + let mut log = Vec::new(); if selected { - log += &format!( + log.push(format!( "{} selected trade routes", game.players[player_index].get_name(), - ); + )); } for t in trade_routes { - log += &format!( + log.push(format!( "{:?} at {:?} traded with city at {:?}", game.players[player_index] .get_unit(t.unit_id) @@ -59,9 +59,9 @@ pub(crate) fn trade_route_log( .unit_type, t.from, t.to, - ); + )); } - log += &format!(" - Total reward is {reward}"); + log.push(format!("Total reward is {reward}")); log } @@ -73,7 +73,9 @@ pub fn find_trade_routes(game: &Game, player: &Player) -> Vec { .map(|u| find_trade_route_for_unit(game, player, u)) .filter(|r| !r.is_empty()) .collect(); - find_most_trade_routes(&all, 0, &[]) + let mut routes = find_most_trade_routes(&all, 0, &[]); + routes.truncate(4); + routes } fn find_most_trade_routes( @@ -132,15 +134,21 @@ fn find_trade_route_to_city( return None; } - let distance = unit.position.distance(to.position); + let from = unit.position; + let distance = from.distance(to.position); if distance > 2 { return None; } - let safe_passage = unit.position.neighbors().iter().any(|&pos| { + if game.is_pirate_zone(from) { + return None; + } + + let safe_passage = from.neighbors().iter().any(|&pos| { pos.neighbors().contains(&to.position) && game.map.is_inside(pos) && !game.map.is_unexplored(pos) + && !game.is_pirate_zone(pos) }); if !safe_passage { @@ -149,7 +157,7 @@ fn find_trade_route_to_city( Some(TradeRoute { unit_id: unit.id, - from: unit.position, + from, to: to.position, }) } diff --git a/server/src/content/wonders.rs b/server/src/content/wonders.rs index 26f189c3..ca0b05c1 100644 --- a/server/src/content/wonders.rs +++ b/server/src/content/wonders.rs @@ -36,7 +36,7 @@ pub fn get_all() -> Vec { ResourcePile::gold(1), ])); }, - 0 + 1 ) .build(), ] diff --git a/server/src/cultural_influence.rs b/server/src/cultural_influence.rs new file mode 100644 index 00000000..1c044ca6 --- /dev/null +++ b/server/src/cultural_influence.rs @@ -0,0 +1,262 @@ +use crate::city_pieces::Building; +use crate::game::GameState::Playing; +use crate::game::{Game, GameState}; +use crate::payment::PaymentOptions; +use crate::player_events::{ActionInfo, InfluenceCultureInfo, InfluenceCulturePossible}; +use crate::playing_actions::{roll_boost_cost, InfluenceCultureAttempt, PlayingAction}; +use crate::position::Position; +use crate::resource_pile::ResourcePile; +use crate::undo::UndoContext; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct CulturalInfluenceResolution { + pub roll_boost_cost: ResourcePile, + pub target_player_index: usize, + pub target_city_position: Position, + pub city_piece: Building, +} + +pub(crate) fn influence_culture_attempt( + game: &mut Game, + player_index: usize, + c: &InfluenceCultureAttempt, +) { + let starting_city_position = c.starting_city_position; + let target_player_index = c.target_player_index; + let target_city_position = c.target_city_position; + let city_piece = c.city_piece; + let info = influence_culture_boost_cost( + game, + player_index, + starting_city_position, + target_player_index, + target_city_position, + city_piece, + ); + if matches!(info.possible, InfluenceCulturePossible::Impossible) { + panic!("Impossible to influence culture"); + } + + let self_influence = starting_city_position == target_city_position; + + // currectly, there is no way to have different costs for this + game.players[player_index].lose_resources(info.range_boost_cost.default); + let roll = game.get_next_dice_roll().value + info.roll_boost; + let success = roll >= 5; + if success { + game.add_to_last_log_item(&format!(" and succeeded (rolled {roll})")); + info.info.execute(game); + influence_culture( + game, + player_index, + target_player_index, + target_city_position, + city_piece, + ); + return; + } + + if self_influence || matches!(info.possible, InfluenceCulturePossible::NoBoost) { + game.add_to_last_log_item(&format!(" and failed (rolled {roll})")); + info.info.execute(game); + return; + } + if let Some(roll_boost_cost) = PaymentOptions::resources(roll_boost_cost(roll)) + .first_valid_payment(&game.players[player_index].resources) + { + game.add_to_last_log_item(&format!(" and rolled a {roll}")); + info.info.execute(game); + game.add_info_log_item(&format!("{} now has the option to pay {roll_boost_cost} to increase the dice roll and proceed with the cultural influence", game.players[player_index].get_name())); + game.state = GameState::CulturalInfluenceResolution(CulturalInfluenceResolution { + roll_boost_cost, + target_player_index, + target_city_position, + city_piece, + }); + } else { + game.add_to_last_log_item(&format!( + " but rolled a {roll} and has not enough culture tokens to increase the roll " + )); + info.info.execute(game); + } +} + +pub(crate) fn execute_cultural_influence_resolution_action( + game: &mut Game, + action: bool, + roll_boost_cost: ResourcePile, + target_player_index: usize, + target_city_position: Position, + city_piece: Building, + player_index: usize, +) { + game.state = Playing; + if !action { + return; + } + game.players[player_index].lose_resources(roll_boost_cost.clone()); + game.push_undo_context(UndoContext::InfluenceCultureResolution { roll_boost_cost }); + influence_culture( + game, + player_index, + target_player_index, + target_city_position, + city_piece, + ); +} + +pub(crate) fn undo_cultural_influence_resolution_action(game: &mut Game, action: bool) { + let cultural_influence_attempt_action = game.action_log[game.action_log_index - 1].action.playing_ref().expect("any log item previous to a cultural influence resolution action log item should a cultural influence attempt action log item"); + let PlayingAction::InfluenceCultureAttempt(c) = cultural_influence_attempt_action else { + panic!("any log item previous to a cultural influence resolution action log item should a cultural influence attempt action log item"); + }; + + let city_piece = c.city_piece; + let target_player_index = c.target_player_index; + let target_city_position = c.target_city_position; + + let Some(UndoContext::InfluenceCultureResolution { roll_boost_cost }) = game.pop_undo_context() + else { + panic!("when undoing a cultural influence resolution action, the game should have stored influence culture resolution context") + }; + + game.state = GameState::CulturalInfluenceResolution(CulturalInfluenceResolution { + roll_boost_cost: roll_boost_cost.clone(), + target_player_index, + target_city_position, + city_piece, + }); + if !action { + return; + } + game.players[game.current_player_index].gain_resources_in_undo(roll_boost_cost); + undo_influence_culture(game, target_player_index, target_city_position, city_piece); +} + +fn influence_distance( + game: &Game, + src: Position, + dst: Position, + visited: &[Position], + len: u32, +) -> u32 { + if visited.contains(&src) { + return u32::MAX; + } + let mut visited = visited.to_vec(); + visited.push(src); + + if src == dst { + return len; + } + src.neighbors() + .into_iter() + .filter(|&p| game.map.is_sea(p) || game.map.is_land(p)) + .map(|n| influence_distance(game, n, dst, &visited, len + 1)) + .min() + .expect("there should be a path") +} + +#[must_use] +pub fn influence_culture_boost_cost( + game: &Game, + player_index: usize, + starting_city_position: Position, + target_player_index: usize, + target_city_position: Position, + city_piece: Building, +) -> InfluenceCultureInfo { + let starting_city = game.get_city(player_index, starting_city_position); + + let range_boost = + influence_distance(game, starting_city_position, target_city_position, &[], 0) + .saturating_sub(starting_city.size() as u32); + + let self_influence = starting_city_position == target_city_position; + let target_city = game.get_city(target_player_index, target_city_position); + let target_city_owner = target_city.player_index; + let target_building_owner = target_city.pieces.building_owner(city_piece); + let attacker = game.get_player(player_index); + let defender = game.get_player(target_player_index); + let start_city_is_eligible = !starting_city.influenced() || self_influence; + + let mut info = InfluenceCultureInfo::new( + PaymentOptions::resources(ResourcePile::culture_tokens(range_boost)), + ActionInfo::new(attacker), + ); + let _ = + attacker + .events + .on_influence_culture_attempt + .get() + .trigger(&mut info, target_city, game); + info.is_defender = true; + let _ = + defender + .events + .on_influence_culture_attempt + .get() + .trigger(&mut info, target_city, game); + + if !matches!(city_piece, Building::Obelisk) + && starting_city.player_index == player_index + && info.is_possible(range_boost) + && attacker.can_afford(&info.range_boost_cost) + && start_city_is_eligible + && !game.successful_cultural_influence + && attacker.is_building_available(city_piece, game) + && target_city_owner == target_player_index + && target_building_owner.is_some_and(|o| o != player_index) + { + return info; + } + info.set_impossible(); + info +} + +/// +/// +/// # Panics +/// +/// Panics if the influenced player does not have the influenced city +/// This function assumes the action is legal +pub fn influence_culture( + game: &mut Game, + influencer_index: usize, + influenced_player_index: usize, + city_position: Position, + building: Building, +) { + game.players[influenced_player_index] + .get_city_mut(city_position) + .expect("influenced player should have influenced city") + .pieces + .set_building(building, influencer_index); + game.successful_cultural_influence = true; + + game.trigger_command_event( + influencer_index, + |e| &mut e.on_influence_culture_success, + &(), + ); +} + +/// +/// +/// # Panics +/// +/// Panics if the influenced player does not have the influenced city +pub fn undo_influence_culture( + game: &mut Game, + influenced_player_index: usize, + city_position: Position, + building: Building, +) { + game.players[influenced_player_index] + .get_city_mut(city_position) + .expect("influenced player should have influenced city") + .pieces + .set_building(building, influenced_player_index); + game.successful_cultural_influence = false; +} diff --git a/server/src/explore.rs b/server/src/explore.rs index ff1a411e..ce3be6da 100644 --- a/server/src/explore.rs +++ b/server/src/explore.rs @@ -1,8 +1,22 @@ use crate::content::advances::NAVIGATION; -use crate::game::{CurrentMove, ExploreResolutionState, Game, GameState, MoveState, UndoContext}; +use crate::game::{Game, GameState}; use crate::map::{Block, BlockPosition, Map, Rotation, Terrain, UnexploredBlock}; +use crate::move_units::{back_to_move, move_units, undo_move_units, CurrentMove, MoveState}; use crate::position::Position; +use crate::undo::UndoContext; use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct ExploreResolutionState { + #[serde(flatten)] + pub move_state: MoveState, + pub block: UnexploredBlock, + pub units: Vec, + pub start: Position, + pub destination: Position, + pub ship_can_teleport: bool, +} pub(crate) fn move_to_unexplored_tile( game: &mut Game, @@ -160,7 +174,7 @@ fn move_to_explored_tile( for (p, t) in block.block.tiles(&block.position, rotation) { if t.is_water() { game.add_info_log_item(&format!("Teleported ship from {destination} to {p}")); - game.move_units(player_index, units, p, None); + move_units(game, player_index, units, p, None); return; } } @@ -168,7 +182,7 @@ fn move_to_explored_tile( game.add_info_log_item("Ship can't move to the explored tile"); return; } - game.move_units(player_index, units, destination, None); + move_units(game, player_index, units, destination, None); } pub fn is_any_ship(game: &Game, player_index: usize, units: &[u32]) -> bool { @@ -187,7 +201,7 @@ fn water_has_water_neighbors( unexplored_block: &UnexploredBlock, rotation: Rotation, ) -> bool { - water_has_neighbors(unexplored_block, rotation, |p| map.is_water(*p)) + water_has_neighbors(unexplored_block, rotation, |p| map.is_sea(*p)) } #[must_use] @@ -211,7 +225,7 @@ fn grow_ocean(map: &Map, ocean: &mut Vec) { while i < ocean.len() { let pos = ocean[i]; for n in pos.neighbors() { - if map.is_water(n) && !ocean.contains(&n) { + if map.is_sea(n) && !ocean.contains(&n) { ocean.push(n); } } @@ -277,7 +291,7 @@ pub(crate) fn explore_resolution(game: &mut Game, r: &ExploreResolutionState, ro r.destination, r.ship_can_teleport, ); - game.back_to_move(&r.move_state, true); + back_to_move(game, &r.move_state, true); game.push_undo_context(UndoContext::ExploreResolution(r.clone())); } @@ -302,6 +316,6 @@ pub(crate) fn undo_explore_resolution(game: &mut Game, player_index: usize) { game.map .add_unexplored_blocks(vec![unexplored_block.clone()]); - game.undo_move_units(player_index, s.units.clone(), s.start); + undo_move_units(game, player_index, s.units.clone(), s.start); game.state = GameState::ExploreResolution(s); } diff --git a/server/src/game.rs b/server/src/game.rs index 5d126b04..fa073ccf 100644 --- a/server/src/game.rs +++ b/server/src/game.rs @@ -2,57 +2,34 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::vec; -use GameState::*; -use crate::advance::Advance; -use crate::barbarians::BarbariansEventState; -use crate::combat::{ - self, combat_loop, combat_round_end, end_combat, start_combat, take_combat, Combat, - CombatDieRoll, COMBAT_DIE_SIDES, -}; -use crate::consts::{ACTIONS, MOVEMENT_ACTIONS}; -use crate::content::civilizations::BARBARIANS; +use crate::combat::{Combat, CombatDieRoll, COMBAT_DIE_SIDES}; +use crate::consts::{ACTIONS, NON_HUMAN_PLAYERS}; +use crate::content::civilizations::{BARBARIANS, PIRATES}; use crate::content::custom_phase_actions::{CurrentCustomPhaseEvent, CustomPhaseEventState}; -use crate::content::incidents; +use crate::cultural_influence::CulturalInfluenceResolution; use crate::events::{Event, EventOrigin}; -use crate::explore::{explore_resolution, move_to_unexplored_tile, undo_explore_resolution}; -use crate::map::UnexploredBlock; -use crate::movement::{has_movable_units, terrain_movement_restriction}; -use crate::payment::PaymentOptions; +use crate::explore::ExploreResolutionState; +use crate::move_units::{CurrentMove, MoveState}; +use crate::pirates::get_pirates_player; use crate::player_events::{ - ActionInfo, AdvanceInfo, CustomPhaseEvent, CustomPhaseInfo, IncidentInfo, InfluenceCultureInfo, - MoveInfo, PlayerCommandEvent, PlayerCommands, PlayerEvents, + CustomPhaseEvent, CustomPhaseInfo, PlayerCommandEvent, PlayerCommands, PlayerEvents, }; use crate::resource::check_for_waste; -use crate::status_phase::StatusPhaseAction; -use crate::unit::{ - carried_units, get_current_move, MoveUnits, MovementRestriction, UnitData, Units, -}; +use crate::status_phase::enter_status_phase; +use crate::undo::{undo_commands, CommandUndoInfo, UndoContext}; +use crate::unit::UnitType; use crate::utils::Rng; use crate::utils::Shuffle; use crate::{ action::Action, - city::{City, MoodState::*}, - city_pieces::Building::{self, *}, + city::City, consts::AGES, - content::{advances, civilizations, custom_actions::CustomActionType, wonders}, - log::{self}, - map::{Map, MapData, Terrain::*}, + content::{civilizations, custom_actions::CustomActionType, wonders}, + map::{Map, MapData}, player::{Player, PlayerData}, - playing_actions::PlayingAction, position::Position, - resource_pile::ResourcePile, - special_advance::SpecialAdvance, - status_phase::{ - self, - StatusPhaseState::{self}, - }, - unit::{ - MovementAction::{self, *}, - Unit, - UnitType::{self}, - }, - utils, + status_phase::StatusPhaseState::{self}, wonder::Wonder, }; @@ -122,8 +99,7 @@ impl Game { let mut players = Vec::new(); let mut civilizations = civilizations::get_all(); for i in 0..player_amount { - // exclude barbarians - let civilization = rng.range(1, civilizations.len()); + let civilization = rng.range(NON_HUMAN_PLAYERS, civilizations.len()); players.push(Player::new(civilizations.remove(civilization), i)); } @@ -133,6 +109,10 @@ impl Game { civilizations::get_civilization(BARBARIANS).expect("civ not found"), players.len(), )); + players.push(Player::new( + civilizations::get_civilization(PIRATES).expect("civ not found"), + players.len(), + )); let mut wonders = wonders::get_all(); wonders.shuffle(&mut rng); @@ -145,7 +125,7 @@ impl Game { }; Self { - state: Playing, + state: GameState::Playing, custom_phase_state: Vec::new(), players, map, @@ -315,14 +295,6 @@ impl Game { .find_map(|player| player.get_city(position)) } - pub(crate) fn add_action_log_item(&mut self, item: Action) { - if self.action_log_index < self.action_log.len() { - self.action_log.drain(self.action_log_index..); - } - self.action_log.push(ActionLogItem::new(item)); - self.action_log_index += 1; - } - pub(crate) fn lock_undo(&mut self) { self.undo_limit = self.action_log_index; } @@ -373,10 +345,10 @@ impl Game { } let state = self.current_custom_phase(); - let remaining: Vec<_> = players + let remaining = players .iter() .filter(|&p| !state.players_used.contains(p)) - .collect(); + .collect_vec(); for &player_index in remaining { let info = CustomPhaseInfo { @@ -436,251 +408,6 @@ impl Game { for edit in commands.log { self.add_info_log_item(&edit); } - for u in commands.content.gained_units { - self.players[u.player].add_unit(u.position, u.unit_type); - } - for city in commands.content.gained_cities { - self.players[city.player] - .cities - .push(City::new(city.player, city.position)); - } - if let Some(mut p) = commands.content.barbarian_update { - if let Some(m) = p.move_request.take() { - let from = m.from; - let to = m.to; - let vec = self.get_player(m.player).get_units(from); - let units: Vec = vec.iter().map(|u| u.id).collect(); - let unit_types = vec.iter().map(|u| u.unit_type).collect::(); - self.add_info_log_item(&format!( - "Barbarians move from {from} to {to}: {unit_types}" - )); - p.moved_units.extend(units.iter()); - self.move_with_possible_combat( - m.player, - None, - from, - &MoveUnits { - units, - destination: to, - embark_carrier_id: None, - payment: ResourcePile::empty(), - }, - ); - } - self.current_custom_phase_mut().barbarians = Some(p); - } - } - - /// - /// - /// # Panics - /// - /// Panics if the action is illegal - pub fn execute_action(&mut self, action: Action, player_index: usize) { - assert!(player_index == self.active_player(), "Illegal action"); - if let Action::Undo = action { - assert!( - self.can_undo(), - "actions revealing new information can't be undone" - ); - self.undo(player_index); - return; - } - - if matches!(action, Action::Redo) { - assert!(self.can_redo(), "no action can be redone"); - self.redo(player_index); - return; - } - - self.add_log_item_from_action(&action); - self.add_action_log_item(action.clone()); - - if let Some(s) = &mut self.current_custom_phase_event_mut() { - s.response = action.custom_phase_event(); - let event_type = self.current_custom_phase().event_type.clone(); - self.execute_custom_phase_action(player_index, &event_type); - } else { - self.execute_regular_action(action, player_index); - } - check_for_waste(self); - - self.action_log[self.action_log_index - 1].undo = - std::mem::take(&mut self.undo_context_stack); - } - - pub(crate) fn execute_custom_phase_action(&mut self, player_index: usize, event_type: &str) { - match event_type { - "on_combat_start" => { - start_combat(self); - } - "on_combat_round_end" => { - self.lock_undo(); - if let Some(c) = combat_round_end(self) { - combat_loop(self, c); - } - } - "on_combat_end" => { - let c = take_combat(self); - end_combat(self, c); - } - "on_turn_start" => self.start_turn(), - // name and payment is ignored here - "on_advance_custom_phase" => { - self.on_advance(player_index, ResourcePile::empty(), ""); - } - "on_construct" => { - // building is ignored here - PlayingAction::on_construct(self, player_index, Temple); - } - "on_recruit" => { - self.on_recruit(player_index); - } - "on_incident" => { - self.trigger_incident(player_index); - } - _ => panic!("unknown custom phase event {event_type}"), - } - } - - fn add_log_item_from_action(&mut self, action: &Action) { - self.log.push(log::format_action_log_item(action, self)); - } - - fn execute_regular_action(&mut self, action: Action, player_index: usize) { - match self.state.clone() { - Playing => { - if let Some(m) = action.clone().movement() { - self.execute_movement_action(m, player_index, MoveState::new()); - } else { - let action = action.playing().expect("action should be a playing action"); - - action.execute(self, player_index); - } - } - StatusPhase(phase) => { - let action = action - .status_phase() - .expect("action should be a status phase action"); - assert!(phase == action.phase(), "Illegal action: Same phase again"); - action.execute(self, player_index); - } - Movement(m) => { - let action = action - .movement() - .expect("action should be a movement action"); - self.execute_movement_action(action, player_index, m); - } - CulturalInfluenceResolution(c) => { - let action = action - .cultural_influence_resolution() - .expect("action should be a cultural influence resolution action"); - self.execute_cultural_influence_resolution_action( - action, - c.roll_boost_cost, - c.target_player_index, - c.target_city_position, - c.city_piece, - player_index, - ); - } - Combat(_) => { - panic!("actions can't be executed when the game is in a combat state"); - } - ExploreResolution(r) => { - let rotation = action - .explore_resolution() - .expect("action should be an explore resolution action"); - explore_resolution(self, &r, rotation); - } - Finished => panic!("actions can't be executed when the game is finished"), - } - } - - fn undo(&mut self, player_index: usize) { - self.action_log_index -= 1; - self.log.remove(self.log.len() - 1); - let item = &self.action_log[self.action_log_index]; - self.undo_context_stack = item.undo.clone(); - let action = item.action.clone(); - - let was_custom_phase = self.current_custom_phase_event().is_some(); - if was_custom_phase { - self.custom_phase_state.pop(); - } - - match action { - Action::Playing(action) => action.clone().undo(self, player_index, was_custom_phase), - Action::StatusPhase(_) => panic!("status phase actions can't be undone"), - Action::Movement(action) => { - self.undo_movement_action(action.clone(), player_index); - } - Action::CulturalInfluenceResolution(action) => { - self.undo_cultural_influence_resolution_action(action); - } - Action::ExploreResolution(_rotation) => { - undo_explore_resolution(self, player_index); - } - Action::CustomPhaseEvent(action) => action.clone().undo(self, player_index), - Action::Undo => panic!("undo action can't be undone"), - Action::Redo => panic!("redo action can't be undone"), - } - - if let Some(UndoContext::WastedResources { - resources, - player_index, - }) = self.maybe_pop_undo_context(|c| matches!(c, UndoContext::WastedResources { .. })) - { - self.players[player_index].gain_resources_in_undo(resources.clone()); - } - - while self.maybe_pop_undo_context(|_| false).is_some() { - // pop all undo contexts until action start - } - } - - fn redo(&mut self, player_index: usize) { - let copy = self.action_log[self.action_log_index].clone(); - self.add_log_item_from_action(©.action); - match &self.action_log[self.action_log_index].action { - Action::Playing(action) => action.clone().execute(self, player_index), - Action::StatusPhase(_) => panic!("status phase actions can't be redone"), - Action::Movement(action) => match &self.state { - Playing => { - self.execute_movement_action(action.clone(), player_index, MoveState::new()); - } - Movement(m) => { - self.execute_movement_action(action.clone(), player_index, m.clone()); - } - _ => { - panic!("movement actions can only be redone if the game is in a movement state") - } - }, - Action::CulturalInfluenceResolution(action) => { - let CulturalInfluenceResolution(c) = &self.state else { - panic!("cultural influence resolution actions can only be redone if the game is in a cultural influence resolution state"); - }; - self.execute_cultural_influence_resolution_action( - *action, - c.roll_boost_cost.clone(), - c.target_player_index, - c.target_city_position, - c.city_piece, - player_index, - ); - } - Action::ExploreResolution(rotation) => { - let ExploreResolution(r) = &self.state else { - panic!("explore resolution actions can only be redone if the game is in a explore resolution state"); - }; - explore_resolution(self, &r.clone(), *rotation); - } - Action::CustomPhaseEvent(action) => action.clone().redo(self, player_index), - Action::Undo => panic!("undo action can't be redone"), - Action::Redo => panic!("redo action can't be redone"), - } - self.action_log_index += 1; - check_for_waste(self); } #[must_use] @@ -693,150 +420,6 @@ impl Game { self.action_log_index < self.action_log.len() } - fn execute_movement_action( - &mut self, - action: MovementAction, - player_index: usize, - mut move_state: MoveState, - ) { - let saved_state = move_state.clone(); - let (starting_position, disembarked_units) = match action { - Move(m) => { - if let Playing = self.state { - assert_ne!(self.actions_left, 0, "Illegal action"); - self.actions_left -= 1; - } - let player = &self.players[player_index]; - let starting_position = player - .get_unit(*m.units.first().expect( - "instead of providing no units to move a stop movement actions should be done", - )) - .expect("the player should have all units to move") - .position; - let disembarked_units = m - .units - .iter() - .filter_map(|unit| { - let unit = player.get_unit(*unit).expect("unit should exist"); - unit.carrier_id.map(|carrier_id| DisembarkUndoContext { - unit_id: unit.id, - carrier_id, - }) - }) - .collect(); - match player.move_units_destinations( - self, - &m.units, - starting_position, - m.embark_carrier_id, - ) { - Ok(destinations) => { - let c = &destinations - .iter() - .find(|route| route.destination == m.destination) - .expect("destination should be a valid destination") - .cost; - if c.is_free() { - assert_eq!(m.payment, ResourcePile::empty(), "payment should be empty"); - } else { - self.players[player_index].pay_cost(c, &m.payment); - } - } - Err(e) => { - panic!("cannot move units to destination: {e}"); - } - } - - move_state.moved_units.extend(m.units.iter()); - move_state.moved_units = move_state.moved_units.iter().unique().copied().collect(); - let current_move = get_current_move( - self, - &m.units, - starting_position, - m.destination, - m.embark_carrier_id, - ); - if matches!(current_move, CurrentMove::None) - || move_state.current_move != current_move - { - move_state.movement_actions_left -= 1; - move_state.current_move = current_move; - } - - let dest_terrain = self - .map - .get(m.destination) - .expect("destination should be a valid tile"); - - if dest_terrain == &Unexplored { - if move_to_unexplored_tile( - self, - player_index, - &m.units, - starting_position, - m.destination, - &move_state, - ) { - self.back_to_move(&move_state, true); - } - return; - } - - if self.move_with_possible_combat( - player_index, - Some(&mut move_state), - starting_position, - &m, - ) { - return; - } - - (Some(starting_position), disembarked_units) - } - Stop => { - self.state = Playing; - (None, Vec::new()) - } - }; - self.push_undo_context(UndoContext::Movement { - starting_position, - move_state: saved_state, - disembarked_units, - }); - } - - fn move_with_possible_combat( - &mut self, - player_index: usize, - move_state: Option<&mut MoveState>, - starting_position: Position, - m: &MoveUnits, - ) -> bool { - let enemy = self.enemy_player(player_index, m.destination); - if let Some(defender) = enemy { - if self.move_to_defended_tile( - player_index, - move_state, - &m.units, - m.destination, - starting_position, - defender, - ) { - return true; - } - } else { - self.move_units(player_index, &m.units, m.destination, m.embark_carrier_id); - if let Some(move_state) = move_state { - self.back_to_move(move_state, !starting_position.is_neighbor(m.destination)); - } - } - - if let Some(enemy) = enemy { - self.capture_position(enemy, m.destination, player_index); - } - false - } - pub(crate) fn push_undo_context(&mut self, context: UndoContext) { self.undo_context_stack.push(context); } @@ -851,16 +434,31 @@ impl Game { ) -> Option { loop { if let Some(context) = &self.undo_context_stack.last() { - if let UndoContext::Command(_) = context { - let Some(UndoContext::Command(c)) = self.undo_context_stack.pop() else { - panic!("when popping a command undo context, the undo context stack should have a command undo context") - }; - self.undo_commands(&c); - } else { - if pred(context) { - return self.undo_context_stack.pop(); + match context { + UndoContext::WastedResources { .. } => { + let Some(UndoContext::WastedResources { + resources, + player_index, + }) = self.undo_context_stack.pop() + else { + panic!("when popping a wasted resources undo context, the undo context stack should have a wasted resources undo context") + }; + let pile = resources.clone(); + self.get_player_mut(player_index) + .gain_resources_in_undo(pile); + } + UndoContext::Command(_) => { + let Some(UndoContext::Command(c)) = self.undo_context_stack.pop() else { + panic!("when popping a command undo context, the undo context stack should have a command undo context") + }; + undo_commands(self, &c); + } + _ => { + if pred(context) { + return self.undo_context_stack.pop(); + } + return None; } - return None; } } else { return None; @@ -868,170 +466,20 @@ impl Game { } } - fn undo_commands(&mut self, c: &CommandContext) { - let p = self.current_player_index; - self.players[p].event_info.clone_from(&c.info); - self.players[p].lose_resources(c.gained_resources.clone()); - for u in &c.gained_units { - self.undo_recruit_without_activate(u.player, &[u.unit_type], None); - } - // gained_cities is only for Barbarians - } - - fn move_to_defended_tile( - &mut self, - player_index: usize, - move_state: Option<&mut MoveState>, - units: &Vec, - destination: Position, - starting_position: Position, - defender: usize, - ) -> bool { - let has_defending_units = self.players[defender] - .get_units(destination) - .iter() - .any(|unit| !unit.unit_type.is_settler()); - let has_fortress = self.players[defender] - .get_city(destination) - .is_some_and(|city| city.pieces.fortress.is_some()); - - let mut military = false; - for unit_id in units { - let unit = self.players[player_index] - .get_unit_mut(*unit_id) - .expect("the player should have all units to move"); - if !unit.unit_type.is_settler() { - if unit - .movement_restrictions - .contains(&MovementRestriction::Battle) - { - panic!("unit can't attack"); - } - unit.movement_restrictions.push(MovementRestriction::Battle); - military = true; + pub(crate) fn is_pirate_zone(&self, position: Position) -> bool { + if self.map.is_sea(position) { + let pirate = get_pirates_player(self); + if !pirate.get_units(position).is_empty() { + return true; } - } - assert!(military, "Need military units to attack"); - if let Some(move_state) = move_state { - self.back_to_move(move_state, true); - } - - if has_defending_units || has_fortress { - combat::initiate_combat( - self, - defender, - destination, - player_index, - starting_position, - units.clone(), - self.get_player(player_index).is_human(), - None, - ); - return true; + return position + .neighbors() + .iter() + .any(|n| !pirate.get_units(*n).is_empty()); } false } - fn undo_movement_action(&mut self, action: MovementAction, player_index: usize) { - let Some(UndoContext::Movement { - starting_position, - move_state, - disembarked_units, - }) = self.pop_undo_context() - else { - panic!("when undoing a movement action, the game should have stored movement context") - }; - if let Move(m) = action { - self.undo_move_units( - player_index, - m.units, - starting_position.expect( - "undo context should contain the starting position if units where moved", - ), - ); - self.players[player_index].gain_resources_in_undo(m.payment); - for unit in disembarked_units { - self.players[player_index] - .get_unit_mut(unit.unit_id) - .expect("unit should exist") - .carrier_id = Some(unit.carrier_id); - } - } - if move_state.movement_actions_left == MOVEMENT_ACTIONS { - self.state = Playing; - self.actions_left += 1; - } else { - self.state = Movement(move_state); - } - } - - fn execute_cultural_influence_resolution_action( - &mut self, - action: bool, - roll_boost_cost: ResourcePile, - target_player_index: usize, - target_city_position: Position, - city_piece: Building, - player_index: usize, - ) { - self.state = Playing; - if !action { - return; - } - self.players[player_index].lose_resources(roll_boost_cost.clone()); - self.push_undo_context(UndoContext::InfluenceCultureResolution { roll_boost_cost }); - self.influence_culture( - player_index, - target_player_index, - target_city_position, - city_piece, - ); - } - - fn undo_cultural_influence_resolution_action(&mut self, action: bool) { - let cultural_influence_attempt_action = self.action_log[self.action_log_index - 1].action.playing_ref().expect("any log item previous to a cultural influence resolution action log item should a cultural influence attempt action log item"); - let PlayingAction::InfluenceCultureAttempt(c) = cultural_influence_attempt_action else { - panic!("any log item previous to a cultural influence resolution action log item should a cultural influence attempt action log item"); - }; - - let city_piece = c.city_piece; - let target_player_index = c.target_player_index; - let target_city_position = c.target_city_position; - - let Some(UndoContext::InfluenceCultureResolution { roll_boost_cost }) = - self.pop_undo_context() - else { - panic!("when undoing a cultural influence resolution action, the game should have stored influence culture resolution context") - }; - - self.state = GameState::CulturalInfluenceResolution(CulturalInfluenceResolution { - roll_boost_cost: roll_boost_cost.clone(), - target_player_index, - target_city_position, - city_piece, - }); - if !action { - return; - } - self.players[self.current_player_index].gain_resources_in_undo(roll_boost_cost); - self.undo_influence_culture(target_player_index, target_city_position, city_piece); - } - - pub(crate) fn back_to_move(&mut self, move_state: &MoveState, stop_current_move: bool) { - let mut state = move_state.clone(); - if stop_current_move { - state.current_move = CurrentMove::None; - } - // set state to Movement first, because that affects has_movable_units - self.state = Movement(state); - - let all_moves_used = - move_state.movement_actions_left == 0 && move_state.current_move == CurrentMove::None; - if all_moves_used || !has_movable_units(self, self.get_player(self.current_player_index)) { - self.state = Playing; - } - } - #[must_use] pub fn enemy_player(&self, player_index: usize, position: Position) -> Option { self.players.iter().position(|player| { @@ -1071,7 +519,7 @@ impl Game { self.start_turn(); } - fn start_turn(&mut self) { + pub(crate) fn start_turn(&mut self) { self.trigger_custom_phase_event( &[self.current_player_index], |e| &mut e.on_turn_start, @@ -1092,7 +540,7 @@ impl Game { } pub fn increment_player_index(&mut self) { - // Barbarians have the highest player index + // Barbarians and Pirates have the highest player indices self.current_player_index += 1; self.current_player_index %= self.human_players().len(); } @@ -1136,34 +584,18 @@ impl Game { self.skip_dropped_players(); if self.round > 3 { self.round = 1; - self.enter_status_phase(); + enter_status_phase(self); return; } self.add_info_log_group(format!("Round {}/3", self.round)); } - fn enter_status_phase(&mut self) { - if self - .players - .iter() - .filter(|player| player.is_human()) - .any(|player| player.cities.is_empty()) - { - self.end_game(); - } - self.add_info_log_group(format!( - "The game has entered the {} status phase", - utils::ordinal_number(self.age) - )); - status_phase::skip_status_phase_players(self); - } - pub fn next_age(&mut self) { if self.age == 6 { - self.state = Finished; + self.state = GameState::Finished; return; } - self.state = Playing; + self.state = GameState::Playing; self.age += 1; self.current_player_index = self.starting_player_index; self.lock_undo(); @@ -1175,8 +607,8 @@ impl Game { self.add_info_log_group(String::from("Round 1/3")); } - fn end_game(&mut self) { - self.state = Finished; + pub(crate) fn end_game(&mut self) { + self.state = GameState::Finished; let winner_player_index = self .players .iter() @@ -1262,477 +694,6 @@ impl Game { self.lock_undo(); } - fn set_active_leader(&mut self, leader_name: String, player_index: usize) { - self.players[player_index] - .available_leaders - .retain(|name| name != &leader_name); - Player::with_leader(&leader_name, self, player_index, |game, leader| { - (leader.listeners.initializer)(game, player_index); - (leader.listeners.one_time_initializer)(game, player_index); - }); - self.players[player_index].active_leader = Some(leader_name); - } - - pub(crate) fn advance_with_incident_token( - &mut self, - advance: &str, - player_index: usize, - payment: ResourcePile, - ) { - self.advance(advance, player_index); - self.on_advance(player_index, payment, advance); - } - - /// - /// - /// # Panics - /// - /// Panics if advance does not exist - pub fn advance(&mut self, advance: &str, player_index: usize) { - self.trigger_command_event(player_index, |e| &mut e.on_advance, &advance.to_string()); - let advance = advances::get_advance(advance); - (advance.listeners.initializer)(self, player_index); - (advance.listeners.one_time_initializer)(self, player_index); - let name = advance.name.clone(); - for i in 0..self.players[player_index] - .civilization - .special_advances - .len() - { - if self.players[player_index].civilization.special_advances[i].required_advance == name - { - let special_advance = self.players[player_index] - .civilization - .special_advances - .remove(i); - self.unlock_special_advance(&special_advance, player_index); - self.players[player_index] - .civilization - .special_advances - .insert(i, special_advance); - break; - } - } - if let Some(advance_bonus) = &advance.bonus { - let pile = advance_bonus.resources(); - self.add_info_log_item(&format!("Player gained {pile} as advance bonus")); - self.players[player_index].gain_resources(pile); - } - let player = &mut self.players[player_index]; - player.advances.push(advance); - } - - pub(crate) fn on_advance(&mut self, player_index: usize, payment: ResourcePile, advance: &str) { - if self.trigger_custom_phase_event( - &[player_index], - |e| &mut e.on_advance_custom_phase, - &AdvanceInfo { - name: advance.to_string(), - payment, - }, - None, - ) { - return; - } - let player = &mut self.players[player_index]; - player.incident_tokens -= 1; - if player.incident_tokens == 0 { - player.incident_tokens = 3; - self.trigger_incident(player_index); - } - } - - pub(crate) fn undo_advance( - &mut self, - advance: &Advance, - player_index: usize, - was_custom_phase: bool, - ) { - self.remove_advance(advance, player_index); - if !was_custom_phase { - self.players[player_index].incident_tokens += 1; - } - } - - /// - /// - /// # Panics - /// - /// Panics if city does not exist or if a ship is build without a port in the city - /// - /// this function assumes that the action is legal - pub fn recruit( - &mut self, - player_index: usize, - units: Units, - city_position: Position, - leader_name: Option<&String>, - replaced_units: &[u32], - ) { - let mut replaced_leader = None; - if let Some(leader_name) = leader_name { - if let Some(previous_leader) = self.players[player_index].active_leader.take() { - Player::with_leader( - &previous_leader, - self, - player_index, - |game, previous_leader| { - (previous_leader.listeners.deinitializer)(game, player_index); - }, - ); - replaced_leader = Some(previous_leader); - } - self.set_active_leader(leader_name.clone(), player_index); - } - let mut replaced_units_undo_context = Vec::new(); - for unit in replaced_units { - let player = &mut self.players[player_index]; - let u = player.remove_unit(*unit); - if u.carrier_id.is_some_and(|c| replaced_units.contains(&c)) { - // will be removed when the carrier is removed - continue; - } - let unit = u.data(self.get_player(player_index)); - replaced_units_undo_context.push(unit); - } - self.push_undo_context(UndoContext::Recruit { - replaced_units: replaced_units_undo_context, - replaced_leader, - }); - let player = &mut self.players[player_index]; - let vec = units.to_vec(); - player.units.reserve_exact(vec.len()); - for unit_type in vec { - let city = player - .get_city(city_position) - .expect("player should have a city at the recruitment position"); - let position = match &unit_type { - UnitType::Ship => city - .port_position - .expect("there should be a port in the city"), - _ => city_position, - }; - player.add_unit(position, unit_type); - } - let city = player - .get_city_mut(city_position) - .expect("player should have a city at the recruitment position"); - city.activate(); - self.on_recruit(player_index); - } - - fn find_last_action(&self, pred: fn(&Action) -> bool) -> Option { - self.action_log - .iter() - .rev() - .find(|item| pred(&item.action)) - .map(|item| item.action.clone()) - } - - fn on_recruit(&mut self, player_index: usize) { - let Some(Action::Playing(PlayingAction::Recruit(r))) = self.find_last_action(|action| { - matches!(action, Action::Playing(PlayingAction::Recruit(_))) - }) else { - panic!("last action should be a recruit action") - }; - - if self.trigger_custom_phase_event( - &[player_index], - |events| &mut events.on_recruit, - &r, - None, - ) { - return; - } - let city_position = r.city_position; - - if let Some(port_position) = self.players[player_index] - .get_city(city_position) - .and_then(|city| city.port_position) - { - let ships = self.players[player_index] - .get_units(port_position) - .iter() - .filter(|unit| unit.unit_type.is_ship()) - .map(|unit| unit.id) - .collect::>(); - if !ships.is_empty() { - if let Some(defender) = self.enemy_player(player_index, port_position) { - for ship in self.players[player_index].get_units_mut(port_position) { - ship.position = city_position; - } - combat::initiate_combat( - self, - defender, - port_position, - player_index, - city_position, - ships, - false, - Some(Playing), - ); - } - } - } - } - - /// - /// - /// # Panics - /// - /// Panics if city does not exist - pub fn undo_recruit( - &mut self, - player_index: usize, - units: Units, - city_position: Position, - leader_name: Option<&String>, - ) { - self.undo_recruit_without_activate(player_index, &units.to_vec(), leader_name); - self.players[player_index] - .get_city_mut(city_position) - .expect("player should have a city a recruitment position") - .undo_activate(); - if let Some(UndoContext::Recruit { - replaced_units, - replaced_leader, - }) = self.pop_undo_context() - { - let player = &mut self.players[player_index]; - for unit in replaced_units { - player.units.extend(Unit::from_data(player_index, unit)); - } - if let Some(replaced_leader) = replaced_leader { - player.active_leader = Some(replaced_leader.clone()); - Player::with_leader( - &replaced_leader, - self, - player_index, - |game, replaced_leader| { - (replaced_leader.listeners.initializer)(game, player_index); - (replaced_leader.listeners.one_time_initializer)(game, player_index); - }, - ); - } - } - } - - fn undo_recruit_without_activate( - &mut self, - player_index: usize, - units: &[UnitType], - leader_name: Option<&String>, - ) { - if let Some(leader_name) = leader_name { - let current_leader = self.players[player_index] - .active_leader - .take() - .expect("the player should have an active leader"); - Player::with_leader( - ¤t_leader, - self, - player_index, - |game, current_leader| { - (current_leader.listeners.deinitializer)(game, player_index); - (current_leader.listeners.undo_deinitializer)(game, player_index); - }, - ); - - self.players[player_index] - .available_leaders - .push(leader_name.clone()); - self.players[player_index].available_leaders.sort(); - - self.players[player_index].active_leader = None; - } - let player = &mut self.players[player_index]; - for _ in 0..units.len() { - player - .units - .pop() - .expect("the player should have the recruited units when undoing"); - player.next_unit_id -= 1; - } - } - - fn trigger_incident(&mut self, player_index: usize) { - self.lock_undo(); - - if self.incidents_left.is_empty() { - self.incidents_left = incidents::get_all().iter().map(|i| i.id).collect_vec(); - self.incidents_left.shuffle(&mut self.rng); - } - - let id = *self.incidents_left.first().expect("incident should exist"); - for p in &self.human_players() { - (incidents::get_incident(id).listeners.initializer)(self, *p); - } - - let i = self - .human_players() - .iter() - .position(|&p| p == player_index) - .expect("player should exist"); - let mut players: Vec<_> = self.human_players(); - players.rotate_left(i); - - self.trigger_custom_phase_event( - &players, - |events| &mut events.on_incident, - &IncidentInfo::new(player_index), - Some("A new game event has been triggered: "), - ); - - for p in &players { - (incidents::get_incident(id).listeners.deinitializer)(self, *p); - } - - if self.custom_phase_state.is_empty() { - self.incidents_left.remove(0); - - if matches!(self.state, GameState::StatusPhase(_)) { - StatusPhaseAction::action_done(self); - } - } - } - - /// - /// - /// # Panics - /// - /// Panics if advance does not exist - pub fn remove_advance(&mut self, advance: &Advance, player_index: usize) { - (advance.listeners.deinitializer)(self, player_index); - (advance.listeners.undo_deinitializer)(self, player_index); - - for i in 0..self.players[player_index] - .civilization - .special_advances - .len() - { - if self.players[player_index].civilization.special_advances[i].required_advance - == advance.name - { - let special_advance = self.players[player_index] - .civilization - .special_advances - .remove(i); - self.undo_unlock_special_advance(&special_advance, player_index); - self.players[player_index] - .civilization - .special_advances - .insert(i, special_advance); - break; - } - } - let player = &mut self.players[player_index]; - if let Some(advance_bonus) = &advance.bonus { - player.lose_resources(advance_bonus.resources()); - } - utils::remove_element(&mut self.players[player_index].advances, advance); - } - - fn unlock_special_advance(&mut self, special_advance: &SpecialAdvance, player_index: usize) { - (special_advance.listeners.initializer)(self, player_index); - (special_advance.listeners.one_time_initializer)(self, player_index); - self.players[player_index] - .unlocked_special_advances - .push(special_advance.name.clone()); - } - - fn undo_unlock_special_advance( - &mut self, - special_advance: &SpecialAdvance, - player_index: usize, - ) { - (special_advance.listeners.deinitializer)(self, player_index); - (special_advance.listeners.undo_deinitializer)(self, player_index); - self.players[player_index].unlocked_special_advances.pop(); - } - - /// - /// - /// # Panics - /// - /// Panics if the city does not exist or if the game is not in a movement state - pub fn conquer_city( - &mut self, - position: Position, - new_player_index: usize, - old_player_index: usize, - ) { - let Some(mut city) = self.players[old_player_index].take_city(position) else { - panic!("player should have this city") - }; - // undo would only be possible if the old owner can't spawn a settler - // and this would be hard to understand - self.lock_undo(); - self.add_to_last_log_item(&format!( - " and captured {}'s city at {position}", - self.players[old_player_index].get_name() - )); - let attacker_is_human = self.get_player(new_player_index).is_human(); - let size = city.mood_modified_size(&self.players[new_player_index]); - if attacker_is_human { - self.players[new_player_index].gain_resources(ResourcePile::gold(size as u32)); - } - let take_over = self.get_player(new_player_index).is_city_available(); - - if take_over { - city.player_index = new_player_index; - city.mood_state = Angry; - if attacker_is_human { - for wonder in &city.pieces.wonders { - (wonder.listeners.deinitializer)(self, old_player_index); - (wonder.listeners.initializer)(self, new_player_index); - } - - for (building, owner) in city.pieces.building_owners() { - if matches!(building, Obelisk) { - continue; - } - let Some(owner) = owner else { - continue; - }; - if owner != old_player_index { - continue; - } - if self.players[new_player_index].is_building_available(building, self) { - city.pieces.set_building(building, new_player_index); - } else { - city.pieces.remove_building(building); - self.players[new_player_index].gain_resources(ResourcePile::gold(1)); - } - } - } - self.players[new_player_index].cities.push(city); - } else { - self.players[new_player_index].gain_resources(ResourcePile::gold(city.size() as u32)); - city.raze(self, old_player_index); - } - } - - pub fn capture_position(&mut self, old_player: usize, position: Position, new_player: usize) { - let captured_settlers = self.players[old_player] - .get_units(position) - .iter() - .map(|unit| unit.id) - .collect_vec(); - if !captured_settlers.is_empty() { - self.add_to_last_log_item(&format!( - " and killed {} settlers of {}", - captured_settlers.len(), - self.players[old_player].get_name() - )); - } - for id in captured_settlers { - self.players[old_player].remove_unit(id); - } - if self.get_player(old_player).get_city(position).is_some() { - self.conquer_city(position, new_player, old_player); - } - } - /// /// /// # Panics @@ -1789,217 +750,10 @@ impl Game { wonder } - fn influence_distance( - &self, - src: Position, - dst: Position, - visited: &[Position], - len: u32, - ) -> u32 { - if visited.contains(&src) { - return u32::MAX; - } - let mut visited = visited.to_vec(); - visited.push(src); - - if src == dst { - return len; - } - src.neighbors() - .into_iter() - .filter(|&p| self.map.is_water(p) || self.map.is_land(p)) - .map(|n| self.influence_distance(n, dst, &visited, len + 1)) - .min() - .expect("there should be a path") - } - - #[must_use] - pub fn influence_culture_boost_cost( - &self, - player_index: usize, - starting_city_position: Position, - target_player_index: usize, - target_city_position: Position, - city_piece: Building, - ) -> InfluenceCultureInfo { - //todo allow cultural influence of barbarians - let starting_city = self.get_city(player_index, starting_city_position); - - let range_boost = self - .influence_distance(starting_city_position, target_city_position, &[], 0) - .saturating_sub(starting_city.size() as u32); - - let self_influence = starting_city_position == target_city_position; - let target_city = self.get_city(target_player_index, target_city_position); - let target_city_owner = target_city.player_index; - let target_building_owner = target_city.pieces.building_owner(city_piece); - let attacker = &self.players[player_index]; - let defender = &self.players[target_player_index]; - let start_city_is_eligible = !starting_city.influenced() || self_influence; - - let mut info = InfluenceCultureInfo::new( - PaymentOptions::resources(ResourcePile::culture_tokens(range_boost)), - ActionInfo::new(attacker), - ); - let _ = attacker.events.on_influence_culture_attempt.get().trigger( - &mut info, - target_city, - self, - ); - info.is_defender = true; - let _ = defender.events.on_influence_culture_attempt.get().trigger( - &mut info, - target_city, - self, - ); - - if !matches!(city_piece, Building::Obelisk) - && starting_city.player_index == player_index - && info.is_possible(range_boost) - && attacker.can_afford(&info.range_boost_cost) - && start_city_is_eligible - && !self.successful_cultural_influence - && attacker.is_building_available(city_piece, self) - && target_city_owner == target_player_index - && target_building_owner.is_some_and(|o| o != player_index) - { - return info; - } - info.set_impossible(); - info - } - - /// - /// - /// # Panics - /// - /// Panics if the influenced player does not have the influenced city - /// This function assumes the action is legal - pub fn influence_culture( - &mut self, - influencer_index: usize, - influenced_player_index: usize, - city_position: Position, - building: Building, - ) { - self.players[influenced_player_index] - .get_city_mut(city_position) - .expect("influenced player should have influenced city") - .pieces - .set_building(building, influencer_index); - self.successful_cultural_influence = true; - - self.trigger_command_event( - influencer_index, - |e| &mut e.on_influence_culture_success, - &(), - ); - } - - /// - /// - /// # Panics - /// - /// Panics if the influenced player does not have the influenced city - pub fn undo_influence_culture( - &mut self, - influenced_player_index: usize, - city_position: Position, - building: Building, - ) { - self.players[influenced_player_index] - .get_city_mut(city_position) - .expect("influenced player should have influenced city") - .pieces - .set_building(building, influenced_player_index); - self.successful_cultural_influence = false; - } - pub fn draw_new_cards(&mut self) { //todo every player draws 1 action card and 1 objective card } - pub(crate) fn move_units( - &mut self, - player_index: usize, - units: &[u32], - to: Position, - embark_carrier_id: Option, - ) { - let p = self.get_player(player_index); - let from = p.get_unit(units[0]).expect("unit not found").position; - let info = MoveInfo::new(player_index, units.to_vec(), from, to); - self.trigger_command_event(player_index, |e| &mut e.before_move, &info); - - for unit_id in units { - self.move_unit(player_index, *unit_id, to, embark_carrier_id); - } - } - - fn move_unit( - &mut self, - player_index: usize, - unit_id: u32, - destination: Position, - embark_carrier_id: Option, - ) { - let unit = self.players[player_index] - .get_unit_mut(unit_id) - .expect("the player should have all units to move"); - unit.position = destination; - unit.carrier_id = embark_carrier_id; - - if let Some(terrain) = terrain_movement_restriction(&self.map, destination, unit) { - unit.movement_restrictions.push(terrain); - } - - for id in carried_units(unit_id, &self.players[player_index]) { - self.players[player_index] - .get_unit_mut(id) - .expect("the player should have all units to move") - .position = destination; - } - } - - pub(crate) fn undo_move_units( - &mut self, - player_index: usize, - units: Vec, - starting_position: Position, - ) { - let Some(unit) = units.first() else { - return; - }; - let destination = self.players[player_index] - .get_unit(*unit) - .expect("there should be at least one moved unit") - .position; - - for unit_id in units { - let unit = self.players[player_index] - .get_unit_mut(unit_id) - .expect("the player should have all units to move"); - unit.position = starting_position; - - if let Some(terrain) = terrain_movement_restriction(&self.map, destination, unit) { - unit.movement_restrictions - .iter() - .position(|r| r == &terrain) - .map(|i| unit.movement_restrictions.remove(i)); - } - - if !self.map.is_water(starting_position) { - unit.carrier_id = None; - } - for id in &carried_units(unit_id, &self.players[player_index]) { - self.players[player_index] - .get_unit_mut(*id) - .expect("the player should have all units to move") - .position = starting_position; - } - } - } - /// /// # Panics /// @@ -2062,73 +816,6 @@ pub struct GameData { incidents_left: Vec, } -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub struct CulturalInfluenceResolution { - pub roll_boost_cost: ResourcePile, - pub target_player_index: usize, - pub target_city_position: Position, - pub city_piece: Building, -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)] -pub enum CurrentMove { - #[default] - None, - Embark { - source: Position, - destination: Position, - }, - Fleet { - units: Vec, - }, -} - -impl CurrentMove { - #[must_use] - pub fn is_none(&self) -> bool { - matches!(self, CurrentMove::None) - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub struct MoveState { - pub movement_actions_left: u32, - #[serde(skip_serializing_if = "Vec::is_empty")] - #[serde(default)] - pub moved_units: Vec, - #[serde(default)] - #[serde(skip_serializing_if = "CurrentMove::is_none")] - pub current_move: CurrentMove, -} - -impl Default for MoveState { - fn default() -> Self { - Self::new() - } -} - -impl MoveState { - #[must_use] - pub fn new() -> Self { - MoveState { - movement_actions_left: MOVEMENT_ACTIONS, - moved_units: Vec::new(), - current_move: CurrentMove::None, - } - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -pub struct ExploreResolutionState { - #[serde(flatten)] - pub move_state: MoveState, - pub block: UnexploredBlock, - pub units: Vec, - pub start: Position, - pub destination: Position, - pub ship_can_teleport: bool, -} - #[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] pub enum GameState { Playing, @@ -2143,7 +830,7 @@ pub enum GameState { impl GameState { #[must_use] pub fn is_playing(&self) -> bool { - matches!(self, Playing) + matches!(self, GameState::Playing) } } @@ -2165,145 +852,6 @@ impl ActionLogItem { } } -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] -pub struct DisembarkUndoContext { - unit_id: u32, - carrier_id: u32, -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] -pub struct GainUnitContext { - unit_type: UnitType, - position: Position, - player: usize, -} - -impl GainUnitContext { - #[must_use] - pub fn new(unit_type: UnitType, position: Position, player: usize) -> Self { - Self { - unit_type, - position, - player, - } - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] -pub struct GainCityContext { - position: Position, - player: usize, -} - -impl GainCityContext { - #[must_use] - pub fn new(position: Position, player: usize) -> Self { - Self { position, player } - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] -pub struct CommandContext { - #[serde(default)] - #[serde(skip_serializing_if = "HashMap::is_empty")] - pub info: HashMap, - #[serde(default)] - #[serde(skip_serializing_if = "ResourcePile::is_empty")] - pub gained_resources: ResourcePile, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - pub gained_units: Vec, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - pub gained_cities: Vec, - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub barbarian_update: Option, -} - -impl CommandContext { - #[must_use] - pub fn new(info: HashMap) -> Self { - Self { - info, - gained_resources: ResourcePile::empty(), - gained_units: Vec::new(), - gained_cities: Vec::new(), - barbarian_update: None, - } - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] -pub struct CommandUndoInfo { - pub player: usize, - pub info: HashMap, -} - -impl CommandUndoInfo { - #[must_use] - pub fn new(player: &Player) -> Self { - Self { - info: player.event_info.clone(), - player: player.index, - } - } - - pub fn apply(&self, game: &mut Game, mut undo: CommandContext) { - let player = &mut game.players[self.player]; - for (k, v) in undo.info.clone() { - player.event_info.insert(k, v); - } - - if undo.info != self.info - || !undo.gained_resources.is_empty() - || !undo.gained_units.is_empty() - || !undo.gained_cities.is_empty() - { - undo.info.clone_from(&self.info); - game.push_undo_context(UndoContext::Command(undo)); - } - } -} - -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] -pub enum UndoContext { - FoundCity { - settler: UnitData, - }, - Recruit { - #[serde(skip_serializing_if = "Vec::is_empty")] - #[serde(default)] - replaced_units: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(default)] - replaced_leader: Option, - }, - Movement { - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(default)] - starting_position: Option, - #[serde(flatten)] - move_state: MoveState, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - disembarked_units: Vec, - }, - ExploreResolution(ExploreResolutionState), - WastedResources { - resources: ResourcePile, - player_index: usize, - }, - IncreaseHappiness { - angry_activations: Vec, - }, - InfluenceCultureResolution { - roll_boost_cost: ResourcePile, - }, - CustomPhaseEvent(CustomPhaseEventState), - Command(CommandContext), -} - #[derive(Serialize, Deserialize)] pub struct Messages { messages: Vec, @@ -2316,97 +864,3 @@ impl Messages { Self { messages, data } } } - -#[cfg(test)] -pub mod tests { - use std::collections::HashMap; - - use super::{Game, GameState::Playing}; - - use crate::payment::PaymentOptions; - use crate::utils::tests::FloatEq; - use crate::{ - city::{City, MoodState::*}, - city_pieces::Building::*, - content::civilizations, - map::Map, - player::Player, - position::Position, - utils::Rng, - wonder::Wonder, - }; - - #[must_use] - pub fn test_game() -> Game { - Game { - state: Playing, - custom_phase_state: Vec::new(), - players: Vec::new(), - map: Map::new(HashMap::new()), - starting_player_index: 0, - current_player_index: 0, - action_log: Vec::new(), - action_log_index: 0, - log: [ - String::from("The game has started"), - String::from("Age 1 has started"), - String::from("Round 1/3"), - ] - .iter() - .map(|s| vec![s.to_string()]) - .collect(), - undo_limit: 0, - actions_left: 3, - successful_cultural_influence: false, - round: 1, - age: 1, - messages: vec![String::from("Game has started")], - rng: Rng::from_seed(1_234_567_890), - dice_roll_outcomes: Vec::new(), - dice_roll_log: Vec::new(), - dropped_players: Vec::new(), - wonders_left: Vec::new(), - wonder_amount_left: 0, - incidents_left: Vec::new(), - undo_context_stack: Vec::new(), - } - } - - #[test] - fn conquer_test() { - let old = Player::new(civilizations::tests::get_test_civilization(), 0); - let new = Player::new(civilizations::tests::get_test_civilization(), 1); - - let wonder = Wonder::builder("wonder", "test", PaymentOptions::free(), vec![]).build(); - let mut game = test_game(); - game.players.push(old); - game.players.push(new); - let old = 0; - let new = 1; - - let position = Position::new(0, 0); - game.players[old].cities.push(City::new(old, position)); - game.build_wonder(wonder, position, old); - game.players[old].construct(Academy, position, None); - game.players[old].construct(Obelisk, position, None); - - game.players[old].victory_points(&game).assert_eq(8.0); - - game.conquer_city(position, new, old); - - let c = game.players[new] - .get_city_mut(position) - .expect("player new should the city"); - assert_eq!(1, c.player_index); - assert_eq!(Angry, c.mood_state); - - let old = &game.players[old]; - let new = &game.players[new]; - old.victory_points(&game).assert_eq(4.0); - new.victory_points(&game).assert_eq(5.0); - assert_eq!(0, old.wonders_owned()); - assert_eq!(1, new.wonders_owned()); - assert_eq!(1, old.owned_buildings(&game)); - assert_eq!(1, new.owned_buildings(&game)); - } -} diff --git a/server/src/game_api.rs b/server/src/game_api.rs index 3058397e..28df3032 100644 --- a/server/src/game_api.rs +++ b/server/src/game_api.rs @@ -1,5 +1,6 @@ use std::{cmp::Ordering::*, mem}; +use crate::action::execute_action; use crate::utils::Shuffle; use crate::{ action::Action, @@ -15,8 +16,8 @@ pub fn init(player_amount: usize, seed: String) -> Game { } #[must_use] -pub fn execute_action(mut game: Game, action: Action, player_index: usize) -> Game { - game.execute_action(action, player_index); +pub fn execute(mut game: Game, action: Action, player_index: usize) -> Game { + execute_action(&mut game, action, player_index); game } diff --git a/server/src/game_api_wrapper.rs b/server/src/game_api_wrapper.rs index a37e7f8f..f928c842 100644 --- a/server/src/game_api_wrapper.rs +++ b/server/src/game_api_wrapper.rs @@ -35,7 +35,7 @@ pub async fn init( pub fn execute_move(game: JsValue, move_data: JsValue, player_index: usize) -> JsValue { let game = get_game(game); let action = serde_wasm_bindgen::from_value(move_data).expect("move should be of type action"); - let game = game_api::execute_action(game, action, player_index); + let game = game_api::execute(game, action, player_index); from_game(game) } diff --git a/server/src/incident.rs b/server/src/incident.rs index 0c63fe60..c44b7a8a 100644 --- a/server/src/incident.rs +++ b/server/src/incident.rs @@ -1,15 +1,20 @@ -use crate::ability_initializer::AbilityInitializerSetup; use crate::ability_initializer::{AbilityInitializerBuilder, AbilityListeners}; +use crate::ability_initializer::{AbilityInitializerSetup, SelectedChoice}; use crate::barbarians::{barbarians_move, barbarians_spawn}; use crate::content::custom_phase_actions::{ - PositionRequest, ResourceRewardRequest, UnitTypeRequest, + PaymentRequest, PlayerRequest, PositionRequest, ResourceRewardRequest, UnitTypeRequest, }; +use crate::content::incidents; use crate::events::EventOrigin; -use crate::game::Game; -use crate::player_events::{CustomPhaseInfo, IncidentInfo, IncidentTarget, PlayerCommands}; +use crate::game::{Game, GameState}; +use crate::pirates::pirates_spawn_and_raid; +use crate::player_events::{CustomPhaseInfo, IncidentInfo, IncidentTarget}; use crate::position::Position; use crate::resource_pile::ResourcePile; +use crate::status_phase::StatusPhaseAction; use crate::unit::UnitType; +use crate::utils::Shuffle; +use itertools::Itertools; pub(crate) const BASE_EFFECT_PRIORITY: i32 = 100; @@ -44,6 +49,7 @@ impl Incident { pub enum IncidentBaseEffect { BarbariansSpawn, BarbariansMove, + PiratesSpawnAndRaid, } impl std::fmt::Display for IncidentBaseEffect { @@ -51,6 +57,7 @@ impl std::fmt::Display for IncidentBaseEffect { match self { IncidentBaseEffect::BarbariansSpawn => write!(f, "Barbarians spawn."), IncidentBaseEffect::BarbariansMove => write!(f, "Barbarians move."), + IncidentBaseEffect::PiratesSpawnAndRaid => write!(f, "Pirates spawn."), } } } @@ -79,6 +86,7 @@ impl IncidentBuilder { Self::new_incident(match self.base_effect { IncidentBaseEffect::BarbariansSpawn => barbarians_spawn(self), IncidentBaseEffect::BarbariansMove => barbarians_move(self), + IncidentBaseEffect::PiratesSpawnAndRaid => pirates_spawn_and_raid(self), }) } @@ -114,7 +122,7 @@ impl IncidentBuilder { role: IncidentTarget, priority: i32, request: impl Fn(&mut Game, usize, &IncidentInfo) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut PlayerCommands, &Game, &Position) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self { self.add_position_request( |event| &mut event.on_incident, @@ -136,7 +144,7 @@ impl IncidentBuilder { role: IncidentTarget, priority: i32, request: impl Fn(&mut Game, usize, &IncidentInfo) -> Option + 'static + Clone, - gain_reward: impl Fn(&mut PlayerCommands, &Game, &UnitType) + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, ) -> Self { self.add_unit_type_request( |event| &mut event.on_incident, @@ -160,7 +168,7 @@ impl IncidentBuilder { request: impl Fn(&mut Game, usize, &IncidentInfo) -> Option + 'static + Clone, - gain_reward_log: impl Fn(&Game, usize, &str, &ResourcePile, bool) -> String + 'static + Clone, + gain_reward_log: impl Fn(&Game, &SelectedChoice) -> Vec + 'static + Clone, ) -> Self { self.add_resource_request( |event| &mut event.on_incident, @@ -175,6 +183,52 @@ impl IncidentBuilder { gain_reward_log, ) } + + #[must_use] + pub(crate) fn add_incident_payment_request( + self, + role: IncidentTarget, + priority: i32, + request: impl Fn(&mut Game, usize, &IncidentInfo) -> Option> + + 'static + + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice>) + 'static + Clone, + ) -> Self { + self.add_payment_request_listener( + |event| &mut event.on_incident, + priority, + move |game, player_index, i| { + if i.is_active(role, player_index) { + request(game, player_index, i) + } else { + None + } + }, + gain_reward, + ) + } + + #[must_use] + pub(crate) fn add_incident_player_request( + self, + role: IncidentTarget, + priority: i32, + request: impl Fn(&mut Game, usize, &IncidentInfo) -> Option + 'static + Clone, + gain_reward: impl Fn(&mut Game, &SelectedChoice) + 'static + Clone, + ) -> Self { + self.add_player_request( + |event| &mut event.on_incident, + priority, + move |game, player_index, i| { + if i.is_active(role, player_index) { + request(game, player_index, i) + } else { + None + } + }, + gain_reward, + ) + } } impl AbilityInitializerSetup for IncidentBuilder { @@ -186,3 +240,44 @@ impl AbilityInitializerSetup for IncidentBuilder { EventOrigin::Incident(self.id) } } + +pub(crate) fn trigger_incident(game: &mut Game, player_index: usize) { + game.lock_undo(); + + if game.incidents_left.is_empty() { + game.incidents_left = incidents::get_all().iter().map(|i| i.id).collect_vec(); + game.incidents_left.shuffle(&mut game.rng); + } + + let id = *game.incidents_left.first().expect("incident should exist"); + for p in &game.human_players() { + (incidents::get_incident(id).listeners.initializer)(game, *p); + } + + let i = game + .human_players() + .iter() + .position(|&p| p == player_index) + .expect("player should exist"); + let mut players: Vec<_> = game.human_players(); + players.rotate_left(i); + + game.trigger_custom_phase_event( + &players, + |events| &mut events.on_incident, + &IncidentInfo::new(player_index), + Some("A new game event has been triggered: "), + ); + + for p in &players { + (incidents::get_incident(id).listeners.deinitializer)(game, *p); + } + + if game.custom_phase_state.is_empty() { + game.incidents_left.remove(0); + + if matches!(game.state, GameState::StatusPhase(_)) { + StatusPhaseAction::action_done(game); + } + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index c70548ba..5e12d27f 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -21,8 +21,10 @@ pub mod city_pieces; mod civilization; pub mod collect; pub mod combat; +mod combat_listeners; pub mod consts; pub mod content; +pub mod cultural_influence; pub mod events; mod explore; pub mod game; @@ -32,16 +34,20 @@ pub mod incident; pub mod leader; pub mod log; pub mod map; +pub mod move_units; mod movement; pub mod payment; +mod pirates; pub mod player; pub mod player_events; pub mod playing_actions; pub mod position; +pub mod recruit; pub mod resource; pub mod resource_pile; mod special_advance; pub mod status_phase; +mod undo; pub mod unit; mod utils; mod wonder; diff --git a/server/src/log.rs b/server/src/log.rs index 7d23595b..dc2a6156 100644 --- a/server/src/log.rs +++ b/server/src/log.rs @@ -3,6 +3,7 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; +use crate::cultural_influence::influence_culture_boost_cost; use crate::game::ActionLogItem; use crate::player::Player; use crate::playing_actions::{ @@ -123,15 +124,15 @@ pub(crate) fn format_cultural_influence_attempt_log_item( } else { String::new() }; - let range_boost_cost = game - .influence_culture_boost_cost( - player_index, - starting_city_position, - target_player_index, - target_city_position, - city_piece, - ) - .range_boost_cost; + let range_boost_cost = influence_culture_boost_cost( + game, + player_index, + starting_city_position, + target_player_index, + target_city_position, + city_piece, + ) + .range_boost_cost; // this cost can't be changed by the player let cost = if !range_boost_cost.is_free() { format!(" and paid {} to boost the range", range_boost_cost.default) @@ -269,7 +270,7 @@ fn format_construct_log_item( .city_position .neighbors() .iter() - .filter(|neighbor| game.map.is_water(**neighbor)) + .filter(|neighbor| game.map.is_sea(**neighbor)) .count(); if adjacent_water_tiles > 1 { format!(" at the water tile {port_position}") @@ -330,7 +331,7 @@ fn format_movement_action_log_item(action: &MovementAction, game: &Game) -> Stri .get_unit(m.units[0]) .expect("the player should have moved units") .position; - let start_is_water = game.map.is_water(start); + let start_is_water = game.map.is_sea(start); let dest = m.destination; let t = game .map diff --git a/server/src/map.rs b/server/src/map.rs index 339f42a2..0635f9e1 100644 --- a/server/src/map.rs +++ b/server/src/map.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use crate::city::City; use crate::city::MoodState::Happy; +use crate::consts::NON_HUMAN_PLAYERS; use crate::player::Player; use crate::position::Position; use crate::unit::UnitType; @@ -26,7 +27,7 @@ impl Map { } #[must_use] - pub fn is_water(&self, pos: Position) -> bool { + pub fn is_sea(&self, pos: Position) -> bool { self.get(pos).is_some_and(Terrain::is_water) } @@ -63,8 +64,7 @@ impl Map { #[must_use] pub fn random_map(players: &mut [Player], rng: &mut Rng) -> Self { - // exclude barbarians - let setup = get_map_setup(players.len() - 1); + let setup = get_map_setup(players.len() - NON_HUMAN_PLAYERS); let blocks = &mut BLOCKS.to_vec(); blocks.shuffle(rng); diff --git a/server/src/move_units.rs b/server/src/move_units.rs new file mode 100644 index 00000000..f821c6af --- /dev/null +++ b/server/src/move_units.rs @@ -0,0 +1,303 @@ +use crate::consts::{ARMY_MOVEMENT_REQUIRED_ADVANCE, MOVEMENT_ACTIONS, SHIP_CAPACITY, STACK_LIMIT}; +use crate::game::GameState::Movement; +use crate::game::{Game, GameState}; +use crate::movement::{ + has_movable_units, is_valid_movement_type, move_routes, terrain_movement_restriction, MoveRoute, +}; +use crate::player::Player; +use crate::player_events::MoveInfo; +use crate::position::Position; +use crate::unit::{carried_units, get_current_move, MovementRestriction}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, Default)] +pub enum CurrentMove { + #[default] + None, + Embark { + source: Position, + destination: Position, + }, + Fleet { + units: Vec, + }, +} + +impl CurrentMove { + #[must_use] + pub fn is_none(&self) -> bool { + matches!(self, CurrentMove::None) + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] +pub struct MoveState { + pub movement_actions_left: u32, + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + pub moved_units: Vec, + #[serde(default)] + #[serde(skip_serializing_if = "CurrentMove::is_none")] + pub current_move: CurrentMove, +} + +impl Default for MoveState { + fn default() -> Self { + Self::new() + } +} + +impl MoveState { + #[must_use] + pub fn new() -> Self { + MoveState { + movement_actions_left: MOVEMENT_ACTIONS, + moved_units: Vec::new(), + current_move: CurrentMove::None, + } + } +} + +pub(crate) fn back_to_move(game: &mut Game, move_state: &MoveState, stop_current_move: bool) { + let mut state = move_state.clone(); + if stop_current_move { + state.current_move = CurrentMove::None; + } + // set state to Movement first, because that affects has_movable_units + game.state = GameState::Movement(state); + + let all_moves_used = + move_state.movement_actions_left == 0 && move_state.current_move == CurrentMove::None; + if all_moves_used || !has_movable_units(game, game.get_player(game.current_player_index)) { + game.state = GameState::Playing; + } +} + +pub(crate) fn move_units( + game: &mut Game, + player_index: usize, + units: &[u32], + to: Position, + embark_carrier_id: Option, +) { + let p = game.get_player(player_index); + let from = p.get_unit(units[0]).expect("unit not found").position; + let info = MoveInfo::new(player_index, units.to_vec(), from, to); + game.trigger_command_event(player_index, |e| &mut e.before_move, &info); + + for unit_id in units { + move_unit(game, player_index, *unit_id, to, embark_carrier_id); + } +} + +fn move_unit( + game: &mut Game, + player_index: usize, + unit_id: u32, + destination: Position, + embark_carrier_id: Option, +) { + let unit = game.players[player_index] + .get_unit_mut(unit_id) + .expect("the player should have all units to move"); + unit.position = destination; + unit.carrier_id = embark_carrier_id; + + if let Some(terrain) = terrain_movement_restriction(&game.map, destination, unit) { + unit.movement_restrictions.push(terrain); + } + + for id in carried_units(unit_id, &game.players[player_index]) { + game.players[player_index] + .get_unit_mut(id) + .expect("the player should have all units to move") + .position = destination; + } +} + +pub(crate) fn undo_move_units( + game: &mut Game, + player_index: usize, + units: Vec, + starting_position: Position, +) { + let Some(unit) = units.first() else { + return; + }; + let destination = game.players[player_index] + .get_unit(*unit) + .expect("there should be at least one moved unit") + .position; + + for unit_id in units { + let unit = game.players[player_index] + .get_unit_mut(unit_id) + .expect("the player should have all units to move"); + unit.position = starting_position; + + if let Some(terrain) = terrain_movement_restriction(&game.map, destination, unit) { + unit.movement_restrictions + .iter() + .position(|r| r == &terrain) + .map(|i| unit.movement_restrictions.remove(i)); + } + + if !game.map.is_sea(starting_position) { + unit.carrier_id = None; + } + for id in &carried_units(unit_id, &game.players[player_index]) { + game.players[player_index] + .get_unit_mut(*id) + .expect("the player should have all units to move") + .position = starting_position; + } + } +} + +/// # Errors +/// +/// Will return `Err` if the unit cannot move. +/// +/// # Panics +/// +/// Panics if destination tile does not exist +pub fn move_units_destinations( + player: &Player, + game: &Game, + unit_ids: &[u32], + start: Position, + embark_carrier_id: Option, +) -> Result, String> { + let (moved_units, movement_actions_left, current_move) = if let Movement(m) = &game.state { + (&m.moved_units, m.movement_actions_left, &m.current_move) + } else { + (&vec![], 1, &CurrentMove::None) + }; + + let units = unit_ids + .iter() + .map(|id| { + player + .get_unit(*id) + .expect("the player should have all units to move") + }) + .collect::>(); + + if units.is_empty() { + return Err("no units to move".to_string()); + } + if embark_carrier_id.is_some_and(|id| { + let player_index = player.index; + (carried_units(id, &game.players[player_index]).len() + units.len()) as u8 > SHIP_CAPACITY + }) { + return Err("carrier capacity exceeded".to_string()); + } + + let carrier_position = embark_carrier_id.map(|id| { + player + .get_unit(id) + .expect("the player should have the carrier unit") + .position + }); + + let mut stack_size = 0; + let mut movement_restrictions = vec![]; + + for unit in &units { + if unit.position != start { + return Err("the unit should be at the starting position".to_string()); + } + movement_restrictions.extend(unit.movement_restrictions.iter()); + if let Some(embark_carrier_id) = embark_carrier_id { + if !unit.unit_type.is_land_based() { + return Err("the unit should be land based to embark".to_string()); + } + let carrier = player + .get_unit(embark_carrier_id) + .ok_or("the player should have the carrier unit")?; + if !carrier.unit_type.is_ship() { + return Err("the carrier should be a ship".to_string()); + } + } + if unit.unit_type.is_army_unit() && !player.has_advance(ARMY_MOVEMENT_REQUIRED_ADVANCE) { + return Err("army movement advance missing".to_string()); + } + if unit.unit_type.is_army_unit() && !unit.unit_type.is_settler() { + stack_size += 1; + } + } + + let destinations: Vec = + move_routes(start, player, unit_ids, game, embark_carrier_id) + .iter() + .filter(|route| { + if !player.can_afford(&route.cost) { + return false; + } + if movement_restrictions.contains(&&MovementRestriction::Battle) { + return false; + } + let dest = route.destination; + let attack = game.enemy_player(player.index, dest).is_some(); + if attack && game.map.is_land(dest) && stack_size == 0 { + return false; + } + + if !route.ignore_terrain_movement_restrictions { + if movement_restrictions + .iter() + .contains(&&MovementRestriction::Mountain) + { + return false; + } + if attack + && movement_restrictions + .iter() + .contains(&&MovementRestriction::Forest) + { + return false; + } + } + + if game.map.is_land(start) + && player + .get_units(dest) + .iter() + .filter(|unit| unit.unit_type.is_army_unit() && !unit.is_transported()) + .count() + + stack_size + + route.stack_size_used + > STACK_LIMIT + { + return false; + } + + if !is_valid_movement_type(game, &units, carrier_position, dest) { + return false; + } + + if !matches!(current_move, CurrentMove::None) + && *current_move + == get_current_move(game, unit_ids, start, dest, embark_carrier_id) + { + return true; + } + + if movement_actions_left == 0 { + return false; + } + + if unit_ids.iter().any(|id| moved_units.contains(id)) { + return false; + } + true + }) + .cloned() + .collect(); + + if destinations.is_empty() { + return Err("no valid destinations".to_string()); + } + Ok(destinations) +} diff --git a/server/src/movement.rs b/server/src/movement.rs index e7a75ab9..d7b9afe0 100644 --- a/server/src/movement.rs +++ b/server/src/movement.rs @@ -3,6 +3,7 @@ use crate::events::EventOrigin; use crate::game::Game; use crate::map::Map; use crate::map::Terrain::{Forest, Mountain}; +use crate::move_units::move_units_destinations; use crate::payment::PaymentOptions; use crate::player::Player; use crate::position::Position; @@ -41,7 +42,7 @@ pub(crate) fn is_valid_movement_type( return dest == embark_position; } units.iter().all(|unit| { - if unit.unit_type.is_land_based() && game.map.is_water(dest) { + if unit.unit_type.is_land_based() && game.map.is_sea(dest) { return false; } if unit.unit_type.is_ship() && game.map.is_land(dest) { @@ -87,7 +88,7 @@ fn reachable_with_roads(player: &Player, units: &[u32], game: &Game) -> Vec Vec bool { player.units.iter().any(|unit| { - player - .move_units_destinations(game, &[unit.id], unit.position, None) - .is_ok() + move_units_destinations(player, game, &[unit.id], unit.position, None).is_ok() || can_embark(game, player, unit) }) } @@ -238,8 +237,6 @@ fn can_embark(game: &Game, player: &Player, unit: &Unit) -> bool { unit.unit_type.is_land_based() && player.units.iter().any(|u| { u.unit_type.is_ship() - && player - .move_units_destinations(game, &[unit.id], u.position, Some(u.id)) - .is_ok() + && move_units_destinations(player, game, &[unit.id], u.position, Some(u.id)).is_ok() }) } diff --git a/server/src/pirates.rs b/server/src/pirates.rs new file mode 100644 index 00000000..6e4ae25a --- /dev/null +++ b/server/src/pirates.rs @@ -0,0 +1,299 @@ +use crate::ability_initializer::AbilityInitializerSetup; +use crate::barbarians; +use crate::city::MoodState; +use crate::combat::get_combat; +use crate::content::builtin::Builtin; +use crate::content::custom_phase_actions::{ + PaymentRequest, PositionRequest, ResourceRewardRequest, UnitsRequest, +}; +use crate::game::Game; +use crate::incident::{IncidentBuilder, BASE_EFFECT_PRIORITY}; +use crate::payment::PaymentOptions; +use crate::player::Player; +use crate::player_events::IncidentTarget; +use crate::position::Position; +use crate::resource::ResourceType; +use crate::unit::UnitType; +use itertools::Itertools; + +pub(crate) fn pirates_round_bonus() -> Builtin { + Builtin::builder("Pirates bonus", "-") + .add_resource_request( + |event| &mut event.on_combat_round_end, + 3, + |game, player_index, r| { + let c = get_combat(game); + if c.is_sea_battle(game) + && c.opponent(player_index) == get_pirates_player(game).index + { + let hits = r.casualties(false).fighters as u32; + Some(ResourceRewardRequest::new( + PaymentOptions::sum(hits, &[ResourceType::Gold]), + "-".to_string(), + )) + } else { + None + } + }, + |_game, s| { + vec![format!( + "{} gained {} for destroying Pirate Ships", + s.player_name, s.choice + )] + }, + ) + .build() +} + +pub(crate) fn pirates_bonus() -> Builtin { + Builtin::builder( + "Barbarians bonus", + "Select a reward for fighting the Pirates", + ) + .add_resource_request( + |event| &mut event.on_combat_end, + 3, + |game, player_index, i| { + if game + .get_player(i.opponent(player_index)) + .civilization + .is_pirates() + { + Some(ResourceRewardRequest::new( + PaymentOptions::sum( + 1, + &[ResourceType::MoodTokens, ResourceType::CultureTokens], + ), + "Select a reward for fighting the Pirates".to_string(), + )) + } else { + None + } + }, + |_game, s| { + vec![format!( + "{} gained {} for fighting the Pirates", + s.player_name, s.choice + )] + }, + ) + .build() +} + +pub(crate) fn pirates_spawn_and_raid(mut builder: IncidentBuilder) -> IncidentBuilder { + builder = barbarians::set_info(builder, "Pirates spawn", |_, _, _| {}); + builder = remove_pirate_ships(builder); + builder = place_pirate_ship(builder, BASE_EFFECT_PRIORITY + 4, true); + builder = place_pirate_ship(builder, BASE_EFFECT_PRIORITY + 3, false); + + builder + .add_incident_payment_request( + IncidentTarget::AllPlayers, + BASE_EFFECT_PRIORITY + 2, + |game, player_index, _i| { + let player = game.get_player(player_index); + if cities_with_adjacent_pirates(player, game).is_empty() { + return None; + } + + if player.resources.resource_amount() > 0 { + game.add_info_log_item(&format!( + "{} must pay 1 resource or token to bribe the pirates", + player.get_name() + )); + Some(vec![PaymentRequest::new( + PaymentOptions::sum(1, &ResourceType::all()), + "Pay 1 Resource or token to bribe the pirates".to_string(), + false, + )]) + } else { + let state = barbarians::get_barbarian_state_mut(game); + state.must_reduce_mood.push(player_index); + None + } + }, + |c, s| { + c.add_info_log_item(&format!("Pirates took {}", s.choice[0])); + }, + ) + .add_incident_position_request( + IncidentTarget::AllPlayers, + BASE_EFFECT_PRIORITY + 1, + |game, player_index, _i| { + if !barbarians::get_barbarian_state(game) + .must_reduce_mood + .contains(&player_index) + { + return None; + } + + let player = game.get_player(player_index); + let choices = cities_with_adjacent_pirates(game.get_player(player_index), game) + .into_iter() + .filter(|&pos| { + !matches!( + player.get_city(pos).expect("city should exist").mood_state, + MoodState::Angry + ) + }) + .collect_vec(); + if choices.is_empty() { + return None; + } + + game.add_info_log_item(&format!( + "{} must reduce Mood in a city adjacent to pirates", + player.get_name() + )); + + Some(PositionRequest::new( + choices, + Some("Select a city to reduce Mood".to_string()), + )) + }, + |game, s| { + game.add_info_log_item(&format!( + "{} reduced Mood in the city at {}", + s.player_name, s.choice + )); + game.get_player_mut(s.player_index) + .get_city_mut(s.choice) + .expect("city should exist") + .decrease_mood_state(); + }, + ) + + // todo implement + // Pirate Ships also have lasting in-game effects: + // ✦ Pirate Ships block the collection of Resources from the + // Sea spaces they are in and all adjacent Sea spaces. + // ✦ Pirate Ships block Trade Routes starting from, or going + // through, the Sea spaces they are in and all adjacent Sea + // spaces. [“Trade Routes”, pg. 31] +} + +fn remove_pirate_ships(builder: IncidentBuilder) -> IncidentBuilder { + builder.add_units_request( + |event| &mut event.on_incident, + BASE_EFFECT_PRIORITY + 5, + |game, player_index, i| { + if !i.is_active(IncidentTarget::ActivePlayer, player_index) { + return None; + } + + let pirates = get_pirates_player(game); + let pirate_ships = pirates + .units + .iter() + .filter(|u| u.unit_type == UnitType::Ship) + .map(|u| u.id) + .collect(); + let needs_removal = 2_u8.saturating_sub(pirates.available_units().get(&UnitType::Ship)); + + Some(UnitsRequest::new( + pirates.index, + pirate_ships, + needs_removal, + Some("Select Pirate Ships to remove".to_string()), + )) + }, + |game, player, units| { + let pirates = get_pirates_player(game).index; + game.add_info_log_item(&format!( + "{} removed a Pirate Ships at {}", + game.get_player(player).get_name(), + units + .iter() + .map(|u| game + .get_player(pirates) + .get_unit(*u) + .expect("unit should exist") + .position + .to_string()) + .join(", ") + )); + for unit in units { + game.get_player_mut(pirates).remove_unit(*unit); + } + }, + ) +} + +fn place_pirate_ship(builder: IncidentBuilder, priority: i32, blockade: bool) -> IncidentBuilder { + builder.add_incident_position_request( + IncidentTarget::ActivePlayer, + priority, + move |game, player_index, _i| { + let pirates = get_pirates_player(game).index; + let player = game.get_player(player_index); + let mut sea_spaces = game + .map + .tiles + .keys() + .filter(|&pos| { + game.map.is_sea(*pos) + && game.players.iter().all(|p| { + p.index == pirates || p.units.iter().all(|u| u.position != *pos) + }) + }) + .copied() + .collect_vec(); + + if blockade { + let adjacent = adjacent_sea(player); + let blocking = sea_spaces + .iter() + .copied() + .filter(|&pos| adjacent.contains(&pos)) + .collect_vec(); + if !blocking.is_empty() { + sea_spaces = blocking; + } + } + Some(PositionRequest::new( + sea_spaces, + Some("Select a position for the Pirate Ship".to_string()), + )) + }, + |game, s| { + let pirate = get_pirates_player(game).index; + game.add_info_log_item(&format!("Pirates spawned a Pirate Ship at {}", s.choice)); + game.get_player_mut(pirate) + .add_unit(s.choice, UnitType::Ship); + }, + ) +} + +fn adjacent_sea(player: &Player) -> Vec { + player + .cities + .iter() + .map(|c| c.position) + .flat_map(|c| c.neighbors()) + .collect_vec() +} + +fn cities_with_adjacent_pirates(player: &Player, game: &Game) -> Vec { + let pirates = get_pirates_player(game); + player + .cities + .iter() + .filter(|c| { + c.position.neighbors().iter().any(|p| { + pirates + .get_units(*p) + .iter() + .any(|u| u.unit_type == UnitType::Ship && u.position == *p) + }) + }) + .map(|c| c.position) + .collect() +} + +#[must_use] +pub(crate) fn get_pirates_player(game: &Game) -> &Player { + game.players + .iter() + .find(|p| p.civilization.is_pirates()) + .expect("pirates should exist") +} diff --git a/server/src/player.rs b/server/src/player.rs index f9cb9aad..da2af7f6 100644 --- a/server/src/player.rs +++ b/server/src/player.rs @@ -1,24 +1,20 @@ use crate::advance::Advance; -use crate::consts::SHIP_CAPACITY; +use crate::consts::{UNIT_LIMIT_BARBARIANS, UNIT_LIMIT_PIRATES}; use crate::content::advances::get_advance; use crate::content::builtin; use crate::events::{Event, EventOrigin}; -use crate::game::CurrentMove; -use crate::game::GameState::Movement; -use crate::movement::move_routes; -use crate::movement::{is_valid_movement_type, MoveRoute}; use crate::payment::PaymentOptions; use crate::player_events::CostInfo; use crate::resource::ResourceType; -use crate::unit::{carried_units, get_current_move, MovementRestriction, UnitData}; +use crate::unit::{carried_units, UnitData, UnitType}; use crate::{ city::{City, CityData}, city_pieces::Building::{self, *}, civilization::Civilization, consts::{ - ADVANCE_COST, ADVANCE_VICTORY_POINTS, ARMY_MOVEMENT_REQUIRED_ADVANCE, - BUILDING_VICTORY_POINTS, CAPTURED_LEADER_VICTORY_POINTS, CITY_LIMIT, CITY_PIECE_LIMIT, - CONSTRUCT_COST, OBJECTIVE_VICTORY_POINTS, STACK_LIMIT, UNIT_LIMIT, WONDER_VICTORY_POINTS, + ADVANCE_COST, ADVANCE_VICTORY_POINTS, BUILDING_VICTORY_POINTS, + CAPTURED_LEADER_VICTORY_POINTS, CITY_LIMIT, CITY_PIECE_LIMIT, CONSTRUCT_COST, + OBJECTIVE_VICTORY_POINTS, UNIT_LIMIT, WONDER_VICTORY_POINTS, }, content::{advances, civilizations, custom_actions::CustomActionType, wonders}, game::Game, @@ -26,11 +22,7 @@ use crate::{ player_events::PlayerEvents, position::Position, resource_pile::ResourcePile, - unit::{ - Unit, - UnitType::{self, *}, - Units, - }, + unit::{Unit, Units}, utils, wonder::Wonder, }; @@ -175,11 +167,11 @@ impl Player { } fn from_data(data: PlayerData) -> Player { - let units: Vec<_> = data + let units = data .units .into_iter() .flat_map(|u| Unit::from_data(data.id, u)) - .collect(); + .collect_vec(); units .iter() .into_group_map_by(|unit| unit.id) @@ -554,7 +546,13 @@ impl Player { #[must_use] pub fn available_units(&self) -> Units { - let mut units = UNIT_LIMIT.clone(); + let mut units = if self.is_human() { + UNIT_LIMIT.clone() + } else if self.civilization.is_barbarian() { + UNIT_LIMIT_BARBARIANS.clone() + } else { + UNIT_LIMIT_PIRATES.clone() + }; for u in &self.units { units -= &u.unit_type; } @@ -752,259 +750,12 @@ impl Player { } } - /// - /// - /// # Panics - /// - /// Panics if city does not exist - #[must_use] - pub fn recruit_cost( - &self, - units: &Units, - city_position: Position, - leader_name: Option<&String>, - replaced_units: &[u32], - execute: Option<&ResourcePile>, - ) -> Option { - let mut require_replace = units.clone(); - for t in self.available_units().to_vec() { - let a = require_replace.get_mut(&t); - if *a > 0 { - *a -= 1; - } - } - let replaced_units = replaced_units - .iter() - .map(|id| { - self.get_unit(*id) - .expect("player should have units to be replaced") - .unit_type - }) - .collect(); - if require_replace != replaced_units { - return None; - } - self.recruit_cost_without_replaced(units, city_position, leader_name, execute) - } - - /// - /// - /// # Panics - /// - /// Panics if city does not exist - #[must_use] - pub fn recruit_cost_without_replaced( - &self, - units: &Units, - city_position: Position, - leader_name: Option<&String>, - execute: Option<&ResourcePile>, - ) -> Option { - let city = self - .get_city(city_position) - .expect("player should have a city at the recruitment position"); - if !city.can_activate() { - return None; - } - let vec = units.clone().to_vec(); - let cost = self.trigger_cost_event( - |e| &e.recruit_cost, - &PaymentOptions::resources(vec.iter().map(UnitType::cost).sum()), - units, - self, - execute, - ); - if !self.can_afford(&cost.cost) { - return None; - } - if vec.len() > city.mood_modified_size(self) { - return None; - } - if vec.iter().any(|unit| matches!(unit, Cavalry | Elephant)) && city.pieces.market.is_none() - { - return None; - } - if vec.iter().any(|unit| matches!(unit, Ship)) && city.pieces.port.is_none() { - return None; - } - if self - .get_units(city_position) - .iter() - .filter(|unit| unit.unit_type.is_army_unit()) - .count() - + vec.iter().filter(|unit| unit.is_army_unit()).count() - > STACK_LIMIT - { - return None; - } - - let leaders = vec - .iter() - .filter(|unit| matches!(unit, UnitType::Leader)) - .count(); - let match_leader = match leaders { - 0 => leader_name.is_none(), - 1 => leader_name.is_some_and(|n| self.available_leaders.contains(n)), - _ => false, - }; - if !match_leader { - return None; - } - Some(cost) - } - pub fn add_unit(&mut self, position: Position, unit_type: UnitType) { let unit = Unit::new(self.index, position, unit_type, self.next_unit_id); self.units.push(unit); self.next_unit_id += 1; } - /// # Errors - /// - /// Will return `Err` if the unit cannot move. - /// - /// # Panics - /// - /// Panics if destination tile does not exist - pub fn move_units_destinations( - &self, - game: &Game, - unit_ids: &[u32], - start: Position, - embark_carrier_id: Option, - ) -> Result, String> { - let (moved_units, movement_actions_left, current_move) = if let Movement(m) = &game.state { - (&m.moved_units, m.movement_actions_left, &m.current_move) - } else { - (&vec![], 1, &CurrentMove::None) - }; - - let units = unit_ids - .iter() - .map(|id| { - self.get_unit(*id) - .expect("the player should have all units to move") - }) - .collect::>(); - - if units.is_empty() { - return Err("no units to move".to_string()); - } - if embark_carrier_id.is_some_and(|id| { - let player_index = self.index; - (carried_units(id, &game.players[player_index]).len() + units.len()) as u8 - > SHIP_CAPACITY - }) { - return Err("carrier capacity exceeded".to_string()); - } - - let carrier_position = embark_carrier_id.map(|id| { - self.get_unit(id) - .expect("the player should have the carrier unit") - .position - }); - - let mut stack_size = 0; - let mut movement_restrictions = vec![]; - - for unit in &units { - if unit.position != start { - return Err("the unit should be at the starting position".to_string()); - } - movement_restrictions.extend(unit.movement_restrictions.iter()); - if let Some(embark_carrier_id) = embark_carrier_id { - if !unit.unit_type.is_land_based() { - return Err("the unit should be land based to embark".to_string()); - } - let carrier = self - .get_unit(embark_carrier_id) - .ok_or("the player should have the carrier unit")?; - if !carrier.unit_type.is_ship() { - return Err("the carrier should be a ship".to_string()); - } - } - if unit.unit_type.is_army_unit() && !self.has_advance(ARMY_MOVEMENT_REQUIRED_ADVANCE) { - return Err("army movement advance missing".to_string()); - } - if unit.unit_type.is_army_unit() && !unit.unit_type.is_settler() { - stack_size += 1; - } - } - - let destinations: Vec = - move_routes(start, self, unit_ids, game, embark_carrier_id) - .iter() - .filter(|route| { - if !self.can_afford(&route.cost) { - return false; - } - if movement_restrictions.contains(&&MovementRestriction::Battle) { - return false; - } - let dest = route.destination; - let attack = game.enemy_player(self.index, dest).is_some(); - if attack && game.map.is_land(dest) && stack_size == 0 { - return false; - } - - if !route.ignore_terrain_movement_restrictions { - if movement_restrictions - .iter() - .contains(&&MovementRestriction::Mountain) - { - return false; - } - if attack - && movement_restrictions - .iter() - .contains(&&MovementRestriction::Forest) - { - return false; - } - } - - if game.map.is_land(start) - && self - .get_units(dest) - .iter() - .filter(|unit| unit.unit_type.is_army_unit() && !unit.is_transported()) - .count() - + stack_size - + route.stack_size_used - > STACK_LIMIT - { - return false; - } - - if !is_valid_movement_type(game, &units, carrier_position, dest) { - return false; - } - - if !matches!(current_move, CurrentMove::None) - && *current_move - == get_current_move(game, unit_ids, start, dest, embark_carrier_id) - { - return true; - } - - if movement_actions_left == 0 { - return false; - } - - if unit_ids.iter().any(|id| moved_units.contains(id)) { - return false; - } - true - }) - .cloned() - .collect(); - - if destinations.is_empty() { - return Err("no valid destinations".to_string()); - } - Ok(destinations) - } - #[must_use] pub fn get_unit(&self, id: u32) -> Option<&Unit> { self.units.iter().find(|unit| unit.id == id) diff --git a/server/src/player_events.rs b/server/src/player_events.rs index 1f90aed4..a835934d 100644 --- a/server/src/player_events.rs +++ b/server/src/player_events.rs @@ -1,13 +1,14 @@ use crate::advance::Advance; -use crate::barbarians::BarbariansEventState; use crate::collect::{CollectContext, CollectInfo}; -use crate::combat::{Combat, CombatResultInfo, CombatStrength}; +use crate::combat::Combat; +use crate::combat_listeners::{CombatResultInfo, CombatRoundResult, CombatStrength}; use crate::events::Event; -use crate::game::{CommandContext, CommandUndoInfo, GainCityContext, GainUnitContext, Game}; +use crate::game::Game; use crate::map::Terrain; use crate::payment::PaymentOptions; use crate::playing_actions::{PlayingActionType, Recruit}; -use crate::unit::{UnitType, Units}; +use crate::undo::{CommandContext, CommandUndoInfo}; +use crate::unit::Units; use crate::{ city::City, city_pieces::Building, player::Player, position::Position, resource_pile::ResourcePile, wonder::Wonder, @@ -46,7 +47,7 @@ pub(crate) struct PlayerEvents { pub on_incident: CustomPhaseEvent, pub on_combat_start: CustomPhaseEvent, pub on_combat_round: Event, - pub on_combat_round_end: CustomPhaseEvent, + pub on_combat_round_end: CustomPhaseEvent, pub on_combat_end: CustomPhaseEvent, } @@ -269,22 +270,6 @@ impl PlayerCommands { self.content.gained_resources += resources; } - pub fn gain_unit(&mut self, player: usize, unit: UnitType, pos: Position) { - self.content - .gained_units - .push(GainUnitContext::new(unit, pos, player)); - } - - pub fn gain_city(&mut self, player: usize, pos: Position) { - self.content - .gained_cities - .push(GainCityContext::new(pos, player)); - } - - pub fn update_barbarian_info(&mut self, state: BarbariansEventState) { - self.content.barbarian_update = Some(state); - } - pub fn add_info_log_item(&mut self, edit: &str) { self.log.push(edit.to_string()); } diff --git a/server/src/playing_actions.rs b/server/src/playing_actions.rs index 90ee2d83..6600d15a 100644 --- a/server/src/playing_actions.rs +++ b/server/src/playing_actions.rs @@ -3,18 +3,20 @@ use serde::{Deserialize, Serialize}; use PlayingAction::*; use crate::action::Action; +use crate::advance::{advance_with_incident_token, undo_advance}; use crate::city::MoodState; use crate::collect::{collect, undo_collect}; use crate::content::advances::get_advance; -use crate::game::{CulturalInfluenceResolution, GameState}; -use crate::payment::PaymentOptions; -use crate::player_events::InfluenceCulturePossible; +use crate::cultural_influence::influence_culture_attempt; +use crate::game::GameState; +use crate::recruit::{recruit, recruit_cost, undo_recruit}; +use crate::undo::UndoContext; use crate::unit::{Unit, Units}; use crate::{ city::City, city_pieces::Building::{self, *}, content::custom_actions::CustomAction, - game::{Game, UndoContext}, + game::Game, position::Position, resource_pile::ResourcePile, }; @@ -129,7 +131,7 @@ impl PlayingAction { game.get_player(player_index) .advance_cost(&a, Some(&payment)) .pay(game, &payment); - game.advance_with_incident_token(&advance, player_index, payment); + advance_with_incident_token(game, &advance, player_index, payment); } FoundCity { settler } => { let settler = game.players[player_index].remove_unit(settler); @@ -182,7 +184,8 @@ impl PlayingAction { } Recruit(r) => { let player = &mut game.players[player_index]; - if let Some(cost) = player.recruit_cost( + if let Some(cost) = recruit_cost( + player, &r.units, r.city_position, r.leader_name.as_ref(), @@ -193,7 +196,8 @@ impl PlayingAction { } else { panic!("Cannot pay for units") } - game.recruit( + recruit( + game, player_index, r.units, r.city_position, @@ -279,7 +283,7 @@ impl PlayingAction { Advance { advance, payment } => { let player = &mut game.players[player_index]; player.gain_resources_in_undo(payment); - game.undo_advance(&get_advance(&advance), player_index, was_custom_phase); + undo_advance(game, &get_advance(&advance), player_index, was_custom_phase); } FoundCity { settler: _ } => { let Some(UndoContext::FoundCity { settler }) = game.pop_undo_context() else { @@ -306,7 +310,8 @@ impl PlayingAction { Collect(c) => undo_collect(game, player_index, &c), Recruit(r) => { game.players[player_index].gain_resources_in_undo(r.payment); - game.undo_recruit( + undo_recruit( + game, player_index, r.units, r.city_position, @@ -428,69 +433,6 @@ pub(crate) fn undo_increase_happiness( } } -pub(crate) fn influence_culture_attempt( - game: &mut Game, - player_index: usize, - c: &InfluenceCultureAttempt, -) { - let starting_city_position = c.starting_city_position; - let target_player_index = c.target_player_index; - let target_city_position = c.target_city_position; - let city_piece = c.city_piece; - let info = game.influence_culture_boost_cost( - player_index, - starting_city_position, - target_player_index, - target_city_position, - city_piece, - ); - if matches!(info.possible, InfluenceCulturePossible::Impossible) { - panic!("Impossible to influence culture"); - } - - let self_influence = starting_city_position == target_city_position; - - // currectly, there is no way to have different costs for this - game.players[player_index].lose_resources(info.range_boost_cost.default); - let roll = game.get_next_dice_roll().value + info.roll_boost; - let success = roll >= 5; - if success { - game.add_to_last_log_item(&format!(" and succeeded (rolled {roll})")); - info.info.execute(game); - game.influence_culture( - player_index, - target_player_index, - target_city_position, - city_piece, - ); - return; - } - - if self_influence || matches!(info.possible, InfluenceCulturePossible::NoBoost) { - game.add_to_last_log_item(&format!(" and failed (rolled {roll})")); - info.info.execute(game); - return; - } - if let Some(roll_boost_cost) = PaymentOptions::resources(roll_boost_cost(roll)) - .first_valid_payment(&game.players[player_index].resources) - { - game.add_to_last_log_item(&format!(" and rolled a {roll}")); - info.info.execute(game); - game.add_info_log_item(&format!("{} now has the option to pay {roll_boost_cost} to increase the dice roll and proceed with the cultural influence", game.players[player_index].get_name())); - game.state = GameState::CulturalInfluenceResolution(CulturalInfluenceResolution { - roll_boost_cost, - target_player_index, - target_city_position, - city_piece, - }); - } else { - game.add_to_last_log_item(&format!( - " but rolled a {roll} and has not enough culture tokens to increase the roll " - )); - info.info.execute(game); - } -} - pub(crate) fn roll_boost_cost(roll: u8) -> ResourcePile { ResourcePile::culture_tokens(5 - roll as u32) } diff --git a/server/src/recruit.rs b/server/src/recruit.rs new file mode 100644 index 00000000..e60efa33 --- /dev/null +++ b/server/src/recruit.rs @@ -0,0 +1,318 @@ +use crate::action::Action; +use crate::combat; +use crate::consts::STACK_LIMIT; +use crate::game::Game; +use crate::game::GameState::Playing; +use crate::payment::PaymentOptions; +use crate::player::Player; +use crate::player_events::CostInfo; +use crate::playing_actions::PlayingAction; +use crate::position::Position; +use crate::resource_pile::ResourcePile; +use crate::undo::UndoContext; +use crate::unit::{Unit, UnitType, Units}; + +pub(crate) fn recruit( + game: &mut Game, + player_index: usize, + units: Units, + city_position: Position, + leader_name: Option<&String>, + replaced_units: &[u32], +) { + let mut replaced_leader = None; + if let Some(leader_name) = leader_name { + if let Some(previous_leader) = game.players[player_index].active_leader.take() { + Player::with_leader( + &previous_leader, + game, + player_index, + |game, previous_leader| { + (previous_leader.listeners.deinitializer)(game, player_index); + }, + ); + replaced_leader = Some(previous_leader); + } + set_active_leader(game, leader_name.clone(), player_index); + } + let mut replaced_units_undo_context = Vec::new(); + for unit in replaced_units { + let player = game.get_player_mut(player_index); + let u = player.remove_unit(*unit); + if u.carrier_id.is_some_and(|c| replaced_units.contains(&c)) { + // will be removed when the carrier is removed + continue; + } + let unit = u.data(game.get_player(player_index)); + replaced_units_undo_context.push(unit); + } + game.push_undo_context(UndoContext::Recruit { + replaced_units: replaced_units_undo_context, + replaced_leader, + }); + let player = game.get_player_mut(player_index); + let vec = units.to_vec(); + player.units.reserve_exact(vec.len()); + for unit_type in vec { + let city = player + .get_city(city_position) + .expect("player should have a city at the recruitment position"); + let position = match &unit_type { + UnitType::Ship => city + .port_position + .expect("there should be a port in the city"), + _ => city_position, + }; + player.add_unit(position, unit_type); + } + let city = player + .get_city_mut(city_position) + .expect("player should have a city at the recruitment position"); + city.activate(); + on_recruit(game, player_index); +} + +fn set_active_leader(game: &mut Game, leader_name: String, player_index: usize) { + game.players[player_index] + .available_leaders + .retain(|name| name != &leader_name); + Player::with_leader(&leader_name, game, player_index, |game, leader| { + (leader.listeners.initializer)(game, player_index); + (leader.listeners.one_time_initializer)(game, player_index); + }); + game.get_player_mut(player_index).active_leader = Some(leader_name); +} + +pub(crate) fn on_recruit(game: &mut Game, player_index: usize) { + let Some(Action::Playing(PlayingAction::Recruit(r))) = find_last_action(game, |action| { + matches!(action, Action::Playing(PlayingAction::Recruit(_))) + }) else { + panic!("last action should be a recruit action") + }; + + if game.trigger_custom_phase_event(&[player_index], |events| &mut events.on_recruit, &r, None) { + return; + } + let city_position = r.city_position; + + if let Some(port_position) = game.players[player_index] + .get_city(city_position) + .and_then(|city| city.port_position) + { + let ships = game.players[player_index] + .get_units(port_position) + .iter() + .filter(|unit| unit.unit_type.is_ship()) + .map(|unit| unit.id) + .collect::>(); + if !ships.is_empty() { + if let Some(defender) = game.enemy_player(player_index, port_position) { + for ship in game.players[player_index].get_units_mut(port_position) { + ship.position = city_position; + } + combat::initiate_combat( + game, + defender, + port_position, + player_index, + city_position, + ships, + false, + Some(Playing), + ); + } + } + } +} + +fn find_last_action(game: &Game, pred: fn(&Action) -> bool) -> Option { + game.action_log + .iter() + .rev() + .find(|item| pred(&item.action)) + .map(|item| item.action.clone()) +} + +/// +/// +/// # Panics +/// +/// Panics if city does not exist +pub fn undo_recruit( + game: &mut Game, + player_index: usize, + units: Units, + city_position: Position, + leader_name: Option<&String>, +) { + undo_recruit_without_activate(game, player_index, &units.to_vec(), leader_name); + game.players[player_index] + .get_city_mut(city_position) + .expect("player should have a city a recruitment position") + .undo_activate(); + if let Some(UndoContext::Recruit { + replaced_units, + replaced_leader, + }) = game.pop_undo_context() + { + let player = game.get_player_mut(player_index); + for unit in replaced_units { + player.units.extend(Unit::from_data(player_index, unit)); + } + if let Some(replaced_leader) = replaced_leader { + player.active_leader = Some(replaced_leader.clone()); + Player::with_leader( + &replaced_leader, + game, + player_index, + |game, replaced_leader| { + (replaced_leader.listeners.initializer)(game, player_index); + (replaced_leader.listeners.one_time_initializer)(game, player_index); + }, + ); + } + } +} + +fn undo_recruit_without_activate( + game: &mut Game, + player_index: usize, + units: &[UnitType], + leader_name: Option<&String>, +) { + if let Some(leader_name) = leader_name { + let current_leader = game.players[player_index] + .active_leader + .take() + .expect("the player should have an active leader"); + Player::with_leader( + ¤t_leader, + game, + player_index, + |game, current_leader| { + (current_leader.listeners.deinitializer)(game, player_index); + (current_leader.listeners.undo_deinitializer)(game, player_index); + }, + ); + + game.players[player_index] + .available_leaders + .push(leader_name.clone()); + game.players[player_index].available_leaders.sort(); + + game.players[player_index].active_leader = None; + } + let player = game.get_player_mut(player_index); + for _ in 0..units.len() { + player + .units + .pop() + .expect("the player should have the recruited units when undoing"); + player.next_unit_id -= 1; + } +} + +/// +/// +/// # Panics +/// +/// Panics if city does not exist +#[must_use] +pub fn recruit_cost( + player: &Player, + units: &Units, + city_position: Position, + leader_name: Option<&String>, + replaced_units: &[u32], + execute: Option<&ResourcePile>, +) -> Option { + let mut require_replace = units.clone(); + for t in player.available_units().to_vec() { + let a = require_replace.get_mut(&t); + if *a > 0 { + *a -= 1; + } + } + let replaced_units = replaced_units + .iter() + .map(|id| { + player + .get_unit(*id) + .expect("player should have units to be replaced") + .unit_type + }) + .collect(); + if require_replace != replaced_units { + return None; + } + recruit_cost_without_replaced(player, units, city_position, leader_name, execute) +} + +/// +/// +/// # Panics +/// +/// Panics if city does not exist +#[must_use] +pub fn recruit_cost_without_replaced( + player: &Player, + units: &Units, + city_position: Position, + leader_name: Option<&String>, + execute: Option<&ResourcePile>, +) -> Option { + let city = player + .get_city(city_position) + .expect("player should have a city at the recruitment position"); + if !city.can_activate() { + return None; + } + let vec = units.clone().to_vec(); + let cost = player.trigger_cost_event( + |e| &e.recruit_cost, + &PaymentOptions::resources(vec.iter().map(UnitType::cost).sum()), + units, + player, + execute, + ); + if !player.can_afford(&cost.cost) { + return None; + } + if vec.len() > city.mood_modified_size(player) { + return None; + } + if vec + .iter() + .any(|unit| matches!(unit, UnitType::Cavalry | UnitType::Elephant)) + && city.pieces.market.is_none() + { + return None; + } + if vec.iter().any(|unit| matches!(unit, UnitType::Ship)) && city.pieces.port.is_none() { + return None; + } + if player + .get_units(city_position) + .iter() + .filter(|unit| unit.unit_type.is_army_unit()) + .count() + + vec.iter().filter(|unit| unit.is_army_unit()).count() + > STACK_LIMIT + { + return None; + } + + let leaders = vec + .iter() + .filter(|unit| matches!(unit, UnitType::Leader)) + .count(); + let match_leader = match leaders { + 0 => leader_name.is_none(), + 1 => leader_name.is_some_and(|n| player.available_leaders.contains(n)), + _ => false, + }; + if !match_leader { + return None; + } + Some(cost) +} diff --git a/server/src/resource.rs b/server/src/resource.rs index 09665fb1..ed05777a 100644 --- a/server/src/resource.rs +++ b/server/src/resource.rs @@ -1,5 +1,6 @@ -use crate::game::{Game, UndoContext}; +use crate::game::Game; use crate::resource_pile::ResourcePile; +use crate::undo::UndoContext; use serde::{Deserialize, Serialize}; use std::{fmt, mem}; diff --git a/server/src/status_phase.rs b/server/src/status_phase.rs index 10185223..80058caf 100644 --- a/server/src/status_phase.rs +++ b/server/src/status_phase.rs @@ -1,6 +1,7 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; +use crate::advance::{advance_with_incident_token, do_advance, remove_advance}; use crate::payment::PaymentOptions; use crate::{ content::advances, @@ -8,6 +9,7 @@ use crate::{ player::Player, position::Position, resource_pile::ResourcePile, + utils, }; #[derive(Serialize, Deserialize, Clone, PartialEq)] @@ -66,7 +68,7 @@ impl StatusPhaseAction { game.players[player_index].can_advance_free(&advances::get_advance(advance)), "Illegal action" ); - game.advance_with_incident_token(advance, player_index, ResourcePile::empty()); + advance_with_incident_token(game, advance, player_index, ResourcePile::empty()); } StatusPhaseAction::RazeSize1City(ref city) => { if let RazeSize1City::Position(city) = *city { @@ -129,26 +131,26 @@ fn change_government_type(game: &mut Game, player_index: usize, new_government: "Illegal number of additional advances" ); - for advance in player_government_advances { - game.remove_advance(&advance, player_index); + for a in player_government_advances { + remove_advance(game, &a, player_index); } let new_government_advances = advances::get_government(government) .expect("government should exist") .advances; - game.advance(&new_government_advances[0].name, player_index); - for advance in &new_government.additional_advances { + do_advance(game, &new_government_advances[0], player_index); + for name in &new_government.additional_advances { let (pos, advance) = new_government_advances .iter() - .find_position(|a| a.name == *advance) + .find_position(|a| a.name == *name) .unwrap_or_else(|| { - panic!("Advance with name {advance} not found in government advances"); + panic!("Advance with name {name} not found in government advances"); }); assert!( pos > 0, "Additional advances should not include the leading government advance" ); - game.advance(&advance.name, player_index); + do_advance(game, advance, player_index); } } @@ -234,6 +236,22 @@ pub enum StatusPhaseState { DetermineFirstPlayer, } +pub(crate) fn enter_status_phase(game: &mut Game) { + if game + .players + .iter() + .filter(|player| player.is_human()) + .any(|player| player.cities.is_empty()) + { + game.end_game(); + } + game.add_info_log_group(format!( + "The game has entered the {} status phase", + utils::ordinal_number(game.age) + )); + skip_status_phase_players(game); +} + #[must_use] pub fn next_status_phase(phase: Option) -> StatusPhaseState { use StatusPhaseState::*; diff --git a/server/src/undo.rs b/server/src/undo.rs new file mode 100644 index 00000000..d4d26853 --- /dev/null +++ b/server/src/undo.rs @@ -0,0 +1,240 @@ +use crate::action::{add_log_item_from_action, execute_movement_action, Action}; +use crate::consts::MOVEMENT_ACTIONS; +use crate::content::custom_phase_actions::CustomPhaseEventState; +use crate::cultural_influence::{ + execute_cultural_influence_resolution_action, undo_cultural_influence_resolution_action, +}; +use crate::explore::{explore_resolution, undo_explore_resolution, ExploreResolutionState}; +use crate::game::Game; +use crate::game::GameState::{CulturalInfluenceResolution, ExploreResolution, Movement, Playing}; +use crate::move_units::{undo_move_units, MoveState}; +use crate::player::Player; +use crate::position::Position; +use crate::resource::check_for_waste; +use crate::resource_pile::ResourcePile; +use crate::unit::MovementAction::Move; +use crate::unit::{MovementAction, UnitData}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct CommandUndoInfo { + pub player: usize, + pub info: HashMap, +} + +impl CommandUndoInfo { + #[must_use] + pub fn new(player: &Player) -> Self { + Self { + info: player.event_info.clone(), + player: player.index, + } + } + + pub fn apply(&self, game: &mut Game, mut undo: CommandContext) { + let player = game.get_player_mut(self.player); + for (k, v) in undo.info.clone() { + player.event_info.insert(k, v); + } + + if undo.info != self.info || !undo.gained_resources.is_empty() { + undo.info.clone_from(&self.info); + game.push_undo_context(UndoContext::Command(undo)); + } + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub enum UndoContext { + FoundCity { + settler: UnitData, + }, + Recruit { + #[serde(skip_serializing_if = "Vec::is_empty")] + #[serde(default)] + replaced_units: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + replaced_leader: Option, + }, + Movement { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + starting_position: Option, + #[serde(flatten)] + move_state: MoveState, + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + disembarked_units: Vec, + }, + ExploreResolution(ExploreResolutionState), + WastedResources { + resources: ResourcePile, + player_index: usize, + }, + IncreaseHappiness { + angry_activations: Vec, + }, + InfluenceCultureResolution { + roll_boost_cost: ResourcePile, + }, + CustomPhaseEvent(Box), + Command(CommandContext), +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct DisembarkUndoContext { + pub unit_id: u32, + pub carrier_id: u32, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub struct CommandContext { + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub info: HashMap, + #[serde(default)] + #[serde(skip_serializing_if = "ResourcePile::is_empty")] + pub gained_resources: ResourcePile, +} + +impl CommandContext { + #[must_use] + pub fn new(info: HashMap) -> Self { + Self { + info, + gained_resources: ResourcePile::empty(), + } + } +} + +pub(crate) fn undo(game: &mut Game, player_index: usize) { + game.action_log_index -= 1; + game.log.remove(game.log.len() - 1); + let item = &game.action_log[game.action_log_index]; + game.undo_context_stack = item.undo.clone(); + let action = item.action.clone(); + + let was_custom_phase = game.current_custom_phase_event().is_some(); + if was_custom_phase { + game.custom_phase_state.pop(); + } + + match action { + Action::Playing(action) => action.clone().undo(game, player_index, was_custom_phase), + Action::StatusPhase(_) => panic!("status phase actions can't be undone"), + Action::Movement(action) => { + undo_movement_action(game, action.clone(), player_index); + } + Action::CulturalInfluenceResolution(action) => { + undo_cultural_influence_resolution_action(game, action); + } + Action::ExploreResolution(_rotation) => { + undo_explore_resolution(game, player_index); + } + Action::CustomPhaseEvent(action) => action.clone().undo(game, player_index), + Action::Undo => panic!("undo action can't be undone"), + Action::Redo => panic!("redo action can't be undone"), + } + + maybe_undo_waste(game); + + while game.maybe_pop_undo_context(|_| false).is_some() { + // pop all undo contexts until action start + } +} + +fn maybe_undo_waste(game: &mut Game) { + if let Some(UndoContext::WastedResources { + resources, + player_index, + }) = game.maybe_pop_undo_context(|c| matches!(c, UndoContext::WastedResources { .. })) + { + game.players[player_index].gain_resources_in_undo(resources.clone()); + } +} + +pub fn redo(game: &mut Game, player_index: usize) { + let copy = game.action_log[game.action_log_index].clone(); + add_log_item_from_action(game, ©.action); + match &game.action_log[game.action_log_index].action { + Action::Playing(action) => action.clone().execute(game, player_index), + Action::StatusPhase(_) => panic!("status phase actions can't be redone"), + Action::Movement(action) => match &game.state { + Playing => { + execute_movement_action(game, action.clone(), player_index, MoveState::new()); + } + Movement(m) => { + execute_movement_action(game, action.clone(), player_index, m.clone()); + } + _ => { + panic!("movement actions can only be redone if the game is in a movement state") + } + }, + Action::CulturalInfluenceResolution(action) => { + let CulturalInfluenceResolution(c) = &game.state else { + panic!("cultural influence resolution actions can only be redone if the game is in a cultural influence resolution state"); + }; + execute_cultural_influence_resolution_action( + game, + *action, + c.roll_boost_cost.clone(), + c.target_player_index, + c.target_city_position, + c.city_piece, + player_index, + ); + } + Action::ExploreResolution(rotation) => { + let ExploreResolution(r) = &game.state else { + panic!("explore resolution actions can only be redone if the game is in a explore resolution state"); + }; + explore_resolution(game, &r.clone(), *rotation); + } + Action::CustomPhaseEvent(action) => action.clone().redo(game, player_index), + Action::Undo => panic!("undo action can't be redone"), + Action::Redo => panic!("redo action can't be redone"), + } + game.action_log_index += 1; + check_for_waste(game); +} + +pub(crate) fn undo_commands(game: &mut Game, c: &CommandContext) { + let p = game.current_player_index; + game.players[p].event_info.clone_from(&c.info); + game.players[p].lose_resources(c.gained_resources.clone()); +} + +fn undo_movement_action(game: &mut Game, action: MovementAction, player_index: usize) { + let Some(UndoContext::Movement { + starting_position, + move_state, + disembarked_units, + }) = game.pop_undo_context() + else { + panic!("when undoing a movement action, the game should have stored movement context") + }; + if let Move(m) = action { + undo_move_units( + game, + player_index, + m.units, + starting_position + .expect("undo context should contain the starting position if units where moved"), + ); + game.players[player_index].gain_resources_in_undo(m.payment); + for unit in disembarked_units { + game.players[player_index] + .get_unit_mut(unit.unit_id) + .expect("unit should exist") + .carrier_id = Some(unit.carrier_id); + } + } + if move_state.movement_actions_left == MOVEMENT_ACTIONS { + game.state = Playing; + game.actions_left += 1; + } else { + game.state = Movement(move_state); + } +} diff --git a/server/src/unit.rs b/server/src/unit.rs index e3c2b1d8..379871b3 100644 --- a/server/src/unit.rs +++ b/server/src/unit.rs @@ -10,7 +10,7 @@ use std::{ use UnitType::*; use crate::explore::is_any_ship; -use crate::game::CurrentMove; +use crate::move_units::CurrentMove; use crate::player::Player; use crate::{game::Game, map::Terrain::*, position::Position, resource_pile::ResourcePile, utils}; diff --git a/server/tests/game_api_tests.rs b/server/tests/game_api_tests.rs index cd66fa13..e7b52cca 100644 --- a/server/tests/game_api_tests.rs +++ b/server/tests/game_api_tests.rs @@ -4,8 +4,11 @@ use server::status_phase::{ ChangeGovernment, ChangeGovernmentType, RazeSize1City, StatusPhaseAction, }; +use server::action::execute_action; use server::content::trade_routes::find_trade_routes; use server::events::EventOrigin; +use server::move_units::move_units_destinations; +use server::recruit::recruit_cost_without_replaced; use server::unit::{MoveUnits, UnitType, Units}; use server::{ action::Action, @@ -42,7 +45,7 @@ fn basic_actions() { advance: String::from("Math"), payment: ResourcePile::food(2), }); - let game = game_api::execute_action(game, advance_action, 0); + let game = game_api::execute(game, advance_action, 0); let player = &game.players[0]; assert_eq!(ResourcePile::culture_tokens(1), player.resources); @@ -52,7 +55,7 @@ fn basic_actions() { advance: String::from("Engineering"), payment: ResourcePile::empty(), }); - let mut game = game_api::execute_action(game, advance_action, 0); + let mut game = game_api::execute(game, advance_action, 0); let player = &game.players[0]; assert_eq!( @@ -87,7 +90,7 @@ fn basic_actions() { payment: ResourcePile::new(1, 1, 1, 0, 0, 0, 0), port_position: None, })); - let game = game_api::execute_action(game, construct_action, 0); + let game = game_api::execute(game, construct_action, 0); let player = &game.players[0]; assert_eq!( @@ -108,7 +111,7 @@ fn basic_actions() { assert_eq!(ResourcePile::new(1, 3, 3, 0, 2, 2, 4), player.resources); assert_eq!(0, game.actions_left); - let game = game_api::execute_action(game, Action::Playing(EndTurn), 0); + let game = game_api::execute(game, Action::Playing(EndTurn), 0); assert_eq!(3, game.actions_left); assert_eq!(0, game.active_player()); @@ -118,7 +121,7 @@ fn basic_actions() { happiness_increases: vec![(city_position, 1)], payment: ResourcePile::mood_tokens(2), })); - let game = game_api::execute_action(game, increase_happiness_action, 0); + let game = game_api::execute(game, increase_happiness_action, 0); let player = &game.players[0]; assert_eq!(ResourcePile::new(1, 3, 3, 0, 2, 0, 4), player.resources); @@ -136,7 +139,7 @@ fn basic_actions() { wonder: String::from("Pyramids"), payment: ResourcePile::new(1, 3, 3, 0, 2, 0, 4), })); - let mut game = game_api::execute_action(game, construct_wonder_action, 0); + let mut game = game_api::execute(game, construct_wonder_action, 0); let player = &game.players[0]; assert_eq!(10.0, player.victory_points(&game)); @@ -167,7 +170,7 @@ fn basic_actions() { city_position, collections: vec![(tile_position, ResourcePile::ore(1))], })); - let game = game_api::execute_action(game, collect_action, 0); + let game = game_api::execute(game, collect_action, 0); let player = &game.players[0]; assert_eq!(ResourcePile::ore(1), player.resources); assert!(player @@ -175,7 +178,7 @@ fn basic_actions() { .expect("player should have a city at this position") .is_activated()); assert_eq!(0, game.actions_left); - let mut game = game_api::execute_action(game, Action::Playing(EndTurn), 0); + let mut game = game_api::execute(game, Action::Playing(EndTurn), 0); let player = &mut game.players[0]; player.gain_resources(ResourcePile::food(2)); let recruit_action = Action::Playing(Recruit(server::playing_actions::Recruit { @@ -185,7 +188,7 @@ fn basic_actions() { leader_name: None, replaced_units: Vec::new(), })); - let mut game = game_api::execute_action(game, recruit_action, 0); + let mut game = game_api::execute(game, recruit_action, 0); let player = &mut game.players[0]; assert_eq!(1, player.units.len()); assert_eq!(1, player.next_unit_id); @@ -196,13 +199,13 @@ fn basic_actions() { .is_activated()); let movement_action = move_action(vec![0], founded_city_position); - let game = game_api::execute_action(game, movement_action, 0); - let game = game_api::execute_action(game, Action::Movement(Stop), 0); + let game = game_api::execute(game, movement_action, 0); + let game = game_api::execute(game, Action::Movement(Stop), 0); let player = &game.players[0]; assert_eq!(founded_city_position, player.units[0].position); let found_city_action = Action::Playing(FoundCity { settler: 0 }); - let game = game_api::execute_action(game, found_city_action, 0); + let game = game_api::execute(game, found_city_action, 0); let player = &game.players[0]; assert_eq!(0, player.units.len()); assert_eq!(1, player.next_unit_id); @@ -246,7 +249,7 @@ fn increase_happiness(game: Game) -> Game { happiness_increases: vec![(Position::new(0, 0), 1)], payment: ResourcePile::mood_tokens(1), })); - game_api::execute_action(game, increase_happiness_action, 0) + game_api::execute(game, increase_happiness_action, 0) } #[test] @@ -266,22 +269,22 @@ fn undo() { let game = increase_happiness(game); assert_undo(&game, true, false, 2, 2, 0); assert_eq!(Happy, game.players[0].cities[0].mood_state); - let game = game_api::execute_action(game, Action::Undo, 0); + let game = game_api::execute(game, Action::Undo, 0); assert_undo(&game, true, true, 2, 1, 0); assert_eq!(Neutral, game.players[0].cities[0].mood_state); - let game = game_api::execute_action(game, Action::Undo, 0); + let game = game_api::execute(game, Action::Undo, 0); assert_undo(&game, false, true, 2, 0, 0); assert_eq!(Angry, game.players[0].cities[0].mood_state); - let game = game_api::execute_action(game, Action::Redo, 0); + let game = game_api::execute(game, Action::Redo, 0); assert_undo(&game, true, true, 2, 1, 0); assert_eq!(Neutral, game.players[0].cities[0].mood_state); - let game = game_api::execute_action(game, Action::Redo, 0); + let game = game_api::execute(game, Action::Redo, 0); assert_undo(&game, true, false, 2, 2, 0); assert_eq!(Happy, game.players[0].cities[0].mood_state); - let game = game_api::execute_action(game, Action::Undo, 0); + let game = game_api::execute(game, Action::Undo, 0); assert_undo(&game, true, true, 2, 1, 0); assert_eq!(Neutral, game.players[0].cities[0].mood_state); - let game = game_api::execute_action(game, Action::Undo, 0); + let game = game_api::execute(game, Action::Undo, 0); assert_undo(&game, false, true, 2, 0, 0); assert_eq!(Angry, game.players[0].cities[0].mood_state); @@ -289,16 +292,16 @@ fn undo() { advance: String::from("Math"), payment: ResourcePile::food(2), }); - let game = game_api::execute_action(game, advance_action, 0); + let game = game_api::execute(game, advance_action, 0); assert_undo(&game, true, false, 1, 1, 0); - let game = game_api::execute_action(game, Action::Undo, 0); + let game = game_api::execute(game, Action::Undo, 0); assert_undo(&game, false, true, 1, 0, 0); assert_eq!(2, game.players[0].advances.len()); let advance_action = Action::Playing(Advance { advance: String::from("Engineering"), payment: ResourcePile::food(2), }); - let game = game_api::execute_action(game, advance_action, 0); + let game = game_api::execute(game, advance_action, 0); assert_undo(&game, false, false, 1, 1, 1); } @@ -444,7 +447,7 @@ fn test_action_internal(name: &str, outcome: &str, test: TestAction) { assert_illegal_action(game, test.player_index, a2); return; } - let game = game_api::execute_action(game, a2, test.player_index); + let game = game_api::execute(game, a2, test.player_index); let expected_game = read_game_str(outcome); assert_eq_game_json( &expected_game, @@ -497,7 +500,7 @@ fn illegal_action_test(run: impl Fn(&mut IllegalActionTest)) { fn assert_illegal_action(game: Game, player: usize, action: Action) { let err = catch_unwind(AssertUnwindSafe(|| { - let _ = game_api::execute_action(game, action, player); + let _ = game_api::execute(game, action, player); })); assert!(err.is_err(), "execute action should panic"); } @@ -524,7 +527,7 @@ fn undo_redo( if cycle == 2 { return; } - let game = game_api::execute_action(game, Action::Undo, player_index); + let game = game_api::execute(game, Action::Undo, player_index); let mut trimmed_game = game.clone(); trimmed_game.action_log.pop(); assert_eq_game_json( @@ -536,7 +539,7 @@ fn undo_redo( "UNDO {cycle}: the game did not match the expectation after undoing the {name} action" ), ); - let game = game_api::execute_action(game, Action::Redo, player_index); + let game = game_api::execute(game, Action::Redo, player_index); assert_eq_game_json( expected_game, &to_json(&game), @@ -633,8 +636,7 @@ fn test_road_coordinates() { fn get_destinations(game: &Game, units: &[u32], position: &str) -> Vec { let player = game.get_player(1); - player - .move_units_destinations(game, units, Position::from_offset(position), None) + move_units_destinations(player, game, units, Position::from_offset(position), None) .unwrap() .into_iter() .map(|r| r.destination.to_string()) @@ -666,14 +668,16 @@ fn test_taxes() { #[test] fn test_trade_route_coordinates() { let game = &load_game("trade_routes_unit_test"); - // trading cities are C6, D6, E6 + // trading cities are C6, D6, E6, B6 - // our units are at C8, but the path is not explored - // 4 ships on E7 can trade with E6 - // settler on the ship can trade with D6 + // 1 ships and 1 settler on E7 can trade with C6, D6, E6 + + // can't trade: + // 1 settler is at C8, but the path is not explored (or blocked by a pirate at C7) + // 1 ship is at A7, but the pirate at A8 blocks trading in its zone of control let found = find_trade_routes(game, game.get_player(1)); - assert_eq!(found.len(), 3); + assert_eq!(found.len(), 2); } #[test] @@ -759,9 +763,10 @@ fn test_cultural_influence() { fn test_separation_of_power() { illegal_action_test(|test| { let mut game = load_game("cultural_influence"); - game.execute_action(Action::Playing(EndTurn), 1); + execute_action(&mut game, Action::Playing(EndTurn), 1); if test.fail { - game.execute_action( + execute_action( + &mut game, Action::Playing(Advance { advance: String::from("Separation of Power"), payment: ResourcePile::food(1) + ResourcePile::gold(1), @@ -769,9 +774,9 @@ fn test_separation_of_power() { 0, ); } - game.execute_action(Action::Playing(EndTurn), 0); + execute_action(&mut game, Action::Playing(EndTurn), 0); test.setup_done = true; - game.execute_action(influence_action(), 1); + execute_action(&mut game, influence_action(), 1); }); } @@ -779,9 +784,10 @@ fn test_separation_of_power() { fn test_devotion() { illegal_action_test(|test| { let mut game = load_game("cultural_influence"); - game.execute_action(Action::Playing(EndTurn), 1); + execute_action(&mut game, Action::Playing(EndTurn), 1); if test.fail { - game.execute_action( + execute_action( + &mut game, Action::Playing(Advance { advance: String::from("Devotion"), payment: ResourcePile::food(1) + ResourcePile::gold(1), @@ -789,9 +795,9 @@ fn test_devotion() { 0, ); } - game.execute_action(Action::Playing(EndTurn), 0); + execute_action(&mut game, Action::Playing(EndTurn), 0); test.setup_done = true; - game.execute_action(influence_action(), 1); + execute_action(&mut game, influence_action(), 1); }); } @@ -799,9 +805,10 @@ fn test_devotion() { fn test_totalitarianism() { illegal_action_test(|test| { let mut game = load_game("cultural_influence"); - game.execute_action(Action::Playing(EndTurn), 1); + execute_action(&mut game, Action::Playing(EndTurn), 1); if test.fail { - game.execute_action( + execute_action( + &mut game, Action::Playing(Advance { advance: String::from("Totalitarianism"), payment: ResourcePile::food(1) + ResourcePile::gold(1), @@ -809,9 +816,9 @@ fn test_totalitarianism() { 0, ); } - game.execute_action(Action::Playing(EndTurn), 0); + execute_action(&mut game, Action::Playing(EndTurn), 0); test.setup_done = true; - game.execute_action(influence_action(), 1); + execute_action(&mut game, influence_action(), 1); }); } @@ -819,9 +826,10 @@ fn test_totalitarianism() { fn test_monuments() { illegal_action_test(|test| { let mut game = load_game("cultural_influence"); - game.execute_action(Action::Playing(EndTurn), 1); + execute_action(&mut game, Action::Playing(EndTurn), 1); if test.fail { - game.execute_action( + execute_action( + &mut game, Action::Playing(Advance { advance: String::from("Monuments"), payment: ResourcePile::food(1) + ResourcePile::gold(1), @@ -829,7 +837,8 @@ fn test_monuments() { 0, ); } - game.execute_action( + execute_action( + &mut game, Action::Playing(Custom(ConstructWonder { city_position: Position::from_offset("C2"), wonder: String::from("Pyramids"), @@ -837,9 +846,9 @@ fn test_monuments() { })), 0, ); - game.execute_action(Action::Playing(EndTurn), 0); + execute_action(&mut game, Action::Playing(EndTurn), 0); test.setup_done = true; - game.execute_action(influence_action(), 1); + execute_action(&mut game, influence_action(), 1); }); } @@ -1066,10 +1075,10 @@ fn test_sanitation_and_draft() { })), ) .with_pre_assert(move |game| { - let options = game.players[0] - .recruit_cost_without_replaced(&units, city_position, None, None) - .unwrap() - .cost; + let options = + recruit_cost_without_replaced(&game.players[0], &units, city_position, None, None) + .unwrap() + .cost; assert_eq!(3, options.conversions.len()); assert_eq!(ResourcePile::mood_tokens(1), options.conversions[0].to); assert_eq!(ResourcePile::mood_tokens(1), options.conversions[1].to); @@ -1126,9 +1135,9 @@ fn test_recruit_combat() { TestAction::undoable( 0, Action::Playing(Recruit(server::playing_actions::Recruit { - units: Units::new(0, 0, 1, 0, 0, 0), + units: Units::new(0, 0, 3, 0, 0, 0), city_position: Position::from_offset("C2"), - payment: ResourcePile::wood(1) + ResourcePile::gold(1), + payment: ResourcePile::wood(5) + ResourcePile::gold(1), leader_name: None, replaced_units: vec![], })), @@ -1145,6 +1154,12 @@ fn test_recruit_combat() { ResourcePile::gold(1), )), ), + TestAction::not_undoable( + 0, + Action::CustomPhaseEvent(CustomPhaseEventAction::ResourceReward( + ResourcePile::culture_tokens(1), + )), + ), ], ); } @@ -1311,6 +1326,41 @@ fn test_barbarians_move() { ); } +#[test] +fn test_pirates_spawn() { + test_actions( + "pirates_spawn", + vec![ + TestAction::not_undoable( + 0, + Action::StatusPhase(StatusPhaseAction::FreeAdvance(String::from("Storage"))), + ), + TestAction::not_undoable( + 0, + Action::CustomPhaseEvent(CustomPhaseEventAction::SelectUnits(vec![7])), + ), + TestAction::not_undoable( + 0, + Action::CustomPhaseEvent(CustomPhaseEventAction::SelectPosition( + Position::from_offset("A2"), + )), + ), + TestAction::not_undoable( + 0, + Action::CustomPhaseEvent(CustomPhaseEventAction::SelectPosition( + Position::from_offset("D2"), + )), + ), + TestAction::not_undoable( + 0, + Action::CustomPhaseEvent(CustomPhaseEventAction::Payment(vec![ResourcePile::ore( + 1, + )])), + ), + ], + ); +} + #[test] fn test_barbarians_attack() { test_actions( @@ -1718,9 +1768,7 @@ fn test_ship_navigate_coordinates() { fn assert_navigate(game: &mut Game, from: Position, to: Position) { game.players[1].get_unit_mut(1).unwrap().position = from; - let result = game - .get_player(1) - .move_units_destinations(game, &[1], from, None) + let result = move_units_destinations(game.get_player(1), game, &[1], from, None) .is_ok_and(|d| d.iter().any(|route| route.destination == to)); assert!( result, diff --git a/server/tests/test_games/barbarians_attack.json b/server/tests/test_games/barbarians_attack.json index a7edd0cd..87e32f90 100644 --- a/server/tests/test_games/barbarians_attack.json +++ b/server/tests/test_games/barbarians_attack.json @@ -277,6 +277,6 @@ ], "wonder_amount_left": 1, "incidents_left": [ - 100 + 28 ] } \ No newline at end of file diff --git a/server/tests/test_games/barbarians_attack.outcome.json b/server/tests/test_games/barbarians_attack.outcome.json index e48dbc78..22d09ce7 100644 --- a/server/tests/test_games/barbarians_attack.outcome.json +++ b/server/tests/test_games/barbarians_attack.outcome.json @@ -38,6 +38,11 @@ "position": "A1", "unit_type": "Settler", "id": 8 + }, + { + "position": "A1", + "unit_type": "Settler", + "id": 9 } ], "civilization": "test0", @@ -56,7 +61,7 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 9 + "next_unit_id": 10 }, { "name": null, @@ -98,6 +103,11 @@ "position": "C1", "unit_type": "Infantry", "id": 1 + }, + { + "position": "C1", + "unit_type": "Settler", + "id": 2 } ], "civilization": "test1", @@ -114,7 +124,7 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 2 + "next_unit_id": 3 }, { "name": null, @@ -238,20 +248,7 @@ "StatusPhase": { "FreeAdvance": "Storage" } - }, - "undo": [ - { - "Command": { - "gained_units": [ - { - "unit_type": "Settler", - "position": "A1", - "player": 0 - } - ] - } - } - ] + } } ], "action_log_index": 1, @@ -273,7 +270,10 @@ "Barbarians removed 1 elephant", "Attacker wins and killed 4 settlers of Player1 and captured Player1's city at C2", "Player1 gained 1 free Settler Unit at A1 for losing a city", - "Barbarians spawned a new Infantry unit at C2" + "Barbarians spawned a new Infantry unit at C2", + "Player1 gained 1 settler in A1", + "Player2 was selected to gain 1 settler from Population Boom", + "Player2 gained 1 settler in C1" ], [ "It's Player2's turn" diff --git a/server/tests/test_games/barbarians_move.json b/server/tests/test_games/barbarians_move.json index df4bd43a..e8689067 100644 --- a/server/tests/test_games/barbarians_move.json +++ b/server/tests/test_games/barbarians_move.json @@ -297,6 +297,6 @@ ], "wonder_amount_left": 1, "incidents_left": [ - 100 + 28 ] } \ No newline at end of file diff --git a/server/tests/test_games/barbarians_move.outcome.json b/server/tests/test_games/barbarians_move.outcome.json index 47d75725..14e4d174 100644 --- a/server/tests/test_games/barbarians_move.outcome.json +++ b/server/tests/test_games/barbarians_move.outcome.json @@ -6,9 +6,6 @@ { "event_type": "on_incident", "last_priority_used": 136, - "barbarians": { - "move_units": true - }, "current": { "priority": 136, "player_index": 0, @@ -23,8 +20,11 @@ }, "response": null, "origin": { - "Incident": 100 + "Incident": 28 } + }, + "barbarians": { + "move_units": true } } ], @@ -340,6 +340,6 @@ ], "wonder_amount_left": 1, "incidents_left": [ - 100 + 28 ] } \ No newline at end of file diff --git a/server/tests/test_games/barbarians_move.outcome1.json b/server/tests/test_games/barbarians_move.outcome1.json index 0ef93283..78f55eae 100644 --- a/server/tests/test_games/barbarians_move.outcome1.json +++ b/server/tests/test_games/barbarians_move.outcome1.json @@ -142,6 +142,11 @@ "position": "C1", "unit_type": "Infantry", "id": 1 + }, + { + "position": "C1", + "unit_type": "Settler", + "id": 2 } ], "civilization": "test1", @@ -158,7 +163,7 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 2 + "next_unit_id": 3 }, { "name": null, @@ -291,9 +296,6 @@ "CustomPhaseEvent": { "event_type": "on_incident", "last_priority_used": 136, - "barbarians": { - "move_units": true - }, "current": { "priority": 136, "player_index": 0, @@ -308,8 +310,11 @@ }, "response": null, "origin": { - "Incident": 100 + "Incident": 28 } + }, + "barbarians": { + "move_units": true } } } @@ -327,7 +332,9 @@ ], [ "Barbarians move from B3 to B2: 2 infantry", - "Barbarians spawned a new Infantry unit at B3" + "Barbarians spawned a new Infantry unit at B3", + "Player2 was selected to gain 1 settler from Population Boom", + "Player2 gained 1 settler in C1" ], [ "It's Player2's turn" diff --git a/server/tests/test_games/barbarians_recapture_city.json b/server/tests/test_games/barbarians_recapture_city.json index 0ca17109..efe81271 100644 --- a/server/tests/test_games/barbarians_recapture_city.json +++ b/server/tests/test_games/barbarians_recapture_city.json @@ -254,15 +254,7 @@ }, "undo": [ { - "Command": { - "gained_units": [ - { - "unit_type": "Settler", - "position": "A1", - "player": 0 - } - ] - } + "Command": {} } ] } diff --git a/server/tests/test_games/barbarians_recapture_city.outcome.json b/server/tests/test_games/barbarians_recapture_city.outcome.json index 9391b064..d0f0fc19 100644 --- a/server/tests/test_games/barbarians_recapture_city.outcome.json +++ b/server/tests/test_games/barbarians_recapture_city.outcome.json @@ -237,15 +237,7 @@ }, "undo": [ { - "Command": { - "gained_units": [ - { - "unit_type": "Settler", - "position": "A1", - "player": 0 - } - ] - } + "Command": {} } ] }, diff --git a/server/tests/test_games/barbarians_spawn.outcome.json b/server/tests/test_games/barbarians_spawn.outcome.json index 6b47ebeb..fd201f1a 100644 --- a/server/tests/test_games/barbarians_spawn.outcome.json +++ b/server/tests/test_games/barbarians_spawn.outcome.json @@ -6,9 +6,6 @@ { "event_type": "on_incident", "last_priority_used": 200, - "barbarians": { - "move_units": false - }, "current": { "priority": 200, "player_index": 0, @@ -25,6 +22,9 @@ "origin": { "Incident": 9 } + }, + "barbarians": { + "move_units": false } } ], diff --git a/server/tests/test_games/barbarians_spawn.outcome1.json b/server/tests/test_games/barbarians_spawn.outcome1.json index ebca3499..a992ba42 100644 --- a/server/tests/test_games/barbarians_spawn.outcome1.json +++ b/server/tests/test_games/barbarians_spawn.outcome1.json @@ -6,10 +6,6 @@ { "event_type": "on_incident", "last_priority_used": 100, - "barbarians": { - "move_units": false, - "selected_position": "B3" - }, "current": { "priority": 100, "player_index": 0, @@ -28,6 +24,10 @@ "origin": { "Incident": 9 } + }, + "barbarians": { + "move_units": false, + "selected_position": "B3" } } ], @@ -295,9 +295,6 @@ "CustomPhaseEvent": { "event_type": "on_incident", "last_priority_used": 200, - "barbarians": { - "move_units": false - }, "current": { "priority": 200, "player_index": 0, @@ -314,25 +311,11 @@ "origin": { "Incident": 9 } + }, + "barbarians": { + "move_units": false } } - }, - { - "Command": { - "gained_units": [ - { - "unit_type": "Infantry", - "position": "B3", - "player": 2 - } - ], - "gained_cities": [ - { - "position": "B3", - "player": 2 - } - ] - } } ] } diff --git a/server/tests/test_games/barbarians_spawn.outcome2.json b/server/tests/test_games/barbarians_spawn.outcome2.json index 3aee4c36..501d690f 100644 --- a/server/tests/test_games/barbarians_spawn.outcome2.json +++ b/server/tests/test_games/barbarians_spawn.outcome2.json @@ -271,9 +271,6 @@ "CustomPhaseEvent": { "event_type": "on_incident", "last_priority_used": 200, - "barbarians": { - "move_units": false - }, "current": { "priority": 200, "player_index": 0, @@ -290,25 +287,11 @@ "origin": { "Incident": 9 } + }, + "barbarians": { + "move_units": false } } - }, - { - "Command": { - "gained_units": [ - { - "unit_type": "Infantry", - "position": "B3", - "player": 2 - } - ], - "gained_cities": [ - { - "position": "B3", - "player": 2 - } - ] - } } ] }, @@ -323,10 +306,6 @@ "CustomPhaseEvent": { "event_type": "on_incident", "last_priority_used": 100, - "barbarians": { - "move_units": false, - "selected_position": "B3" - }, "current": { "priority": 100, "player_index": 0, @@ -345,20 +324,13 @@ "origin": { "Incident": 9 } + }, + "barbarians": { + "move_units": false, + "selected_position": "B3" } } }, - { - "Command": { - "gained_units": [ - { - "unit_type": "Elephant", - "position": "B3", - "player": 2 - } - ] - } - }, { "WastedResources": { "resources": { diff --git a/server/tests/test_games/civ_maya_leader_pakal.outcome1.json b/server/tests/test_games/civ_maya_leader_pakal.outcome1.json index be10f487..27b88414 100644 --- a/server/tests/test_games/civ_maya_leader_pakal.outcome1.json +++ b/server/tests/test_games/civ_maya_leader_pakal.outcome1.json @@ -360,17 +360,6 @@ } } } - }, - { - "Command": { - "gained_units": [ - { - "unit_type": "Settler", - "position": "B2", - "player": 1 - } - ] - } } ] } diff --git a/server/tests/test_games/combat_fanaticism.outcome.json b/server/tests/test_games/combat_fanaticism.outcome.json index 7f2183ef..95114867 100644 --- a/server/tests/test_games/combat_fanaticism.outcome.json +++ b/server/tests/test_games/combat_fanaticism.outcome.json @@ -250,28 +250,6 @@ } }, "undo": [ - { - "Command": { - "gained_units": [ - { - "unit_type": "Infantry", - "position": "C2", - "player": 1 - } - ] - } - }, - { - "Command": { - "gained_units": [ - { - "unit_type": "Settler", - "position": "C2", - "player": 1 - } - ] - } - }, { "WastedResources": { "resources": { diff --git a/server/tests/test_games/pirates_spawn.json b/server/tests/test_games/pirates_spawn.json new file mode 100644 index 00000000..ffc7bb3f --- /dev/null +++ b/server/tests/test_games/pirates_spawn.json @@ -0,0 +1,268 @@ +{ + "state": { + "StatusPhase": "FreeAdvance" + }, + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 1, + "wood": 6, + "ore": 6, + "ideas": 5, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "fortress": 0 + }, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C2" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 7 + } + ], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining", + "Tactics" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 1, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 8 + }, + { + "name": null, + "id": 1, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "C1" + } + ], + "units": [ + { + "position": "C1", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "C1", + "unit_type": "Infantry", + "id": 1 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B3" + } + ], + "units": [ + { + "position": "D1", + "unit_type": "Ship", + "id": 5 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 6 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 7 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "D1", + "Water" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 0, + "action_log": [], + "action_log_index": 0, + "log": [], + "undo_limit": 0, + "actions_left": 3, + "successful_cultural_influence": false, + "round": 1, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10 + ], + "dropped_players": [], + "wonders_left": [ + "Pyramids" + ], + "wonder_amount_left": 1, + "incidents_left": [ + 54 + ] +} \ No newline at end of file diff --git a/server/tests/test_games/pirates_spawn.outcome.json b/server/tests/test_games/pirates_spawn.outcome.json new file mode 100644 index 00000000..91cc0db8 --- /dev/null +++ b/server/tests/test_games/pirates_spawn.outcome.json @@ -0,0 +1,314 @@ +{ + "state": { + "StatusPhase": "FreeAdvance" + }, + "state_change_event_state": [ + { + "event_type": "on_incident", + "last_priority_used": 105, + "current": { + "priority": 105, + "player_index": 0, + "request": { + "SelectUnits": { + "player": 2, + "choices": [ + 5, + 6, + 7 + ], + "needed": 1, + "description": "Select Pirate Ships to remove" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + ], + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 1, + "wood": 6, + "ore": 6, + "ideas": 5, + "gold": 7, + "mood_tokens": 8, + "culture_tokens": 7 + }, + "resource_limit": { + "food": 7, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "fortress": 0 + }, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C2" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 7 + } + ], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining", + "Storage", + "Tactics" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 8 + }, + { + "name": null, + "id": 1, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "C1" + } + ], + "units": [ + { + "position": "C1", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "C1", + "unit_type": "Infantry", + "id": 1 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B3" + } + ], + "units": [ + { + "position": "D1", + "unit_type": "Ship", + "id": 5 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 6 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 7 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "D1", + "Water" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 0, + "action_log": [ + { + "action": { + "StatusPhase": { + "FreeAdvance": "Storage" + } + } + } + ], + "action_log_index": 1, + "log": [ + [ + "Player1 advanced Storage for free", + "Player gained 1 mood token as advance bonus" + ], + [ + "A new game event has been triggered: Pirates spawn" + ] + ], + "undo_limit": 1, + "actions_left": 3, + "successful_cultural_influence": false, + "round": 1, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10 + ], + "dropped_players": [], + "wonders_left": [ + "Pyramids" + ], + "wonder_amount_left": 1, + "incidents_left": [ + 54 + ] +} \ No newline at end of file diff --git a/server/tests/test_games/pirates_spawn.outcome1.json b/server/tests/test_games/pirates_spawn.outcome1.json new file mode 100644 index 00000000..b64c327c --- /dev/null +++ b/server/tests/test_games/pirates_spawn.outcome1.json @@ -0,0 +1,351 @@ +{ + "state": { + "StatusPhase": "FreeAdvance" + }, + "state_change_event_state": [ + { + "event_type": "on_incident", + "last_priority_used": 104, + "current": { + "priority": 104, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + ], + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 1, + "wood": 6, + "ore": 6, + "ideas": 5, + "gold": 7, + "mood_tokens": 8, + "culture_tokens": 7 + }, + "resource_limit": { + "food": 7, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "fortress": 0 + }, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C2" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 7 + } + ], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining", + "Storage", + "Tactics" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 8 + }, + { + "name": null, + "id": 1, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "C1" + } + ], + "units": [ + { + "position": "C1", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "C1", + "unit_type": "Infantry", + "id": 1 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B3" + } + ], + "units": [ + { + "position": "D1", + "unit_type": "Ship", + "id": 5 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 6 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "D1", + "Water" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 0, + "action_log": [ + { + "action": { + "StatusPhase": { + "FreeAdvance": "Storage" + } + } + }, + { + "action": { + "CustomPhaseEvent": { + "SelectUnits": [ + 7 + ] + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 105, + "current": { + "priority": 105, + "player_index": 0, + "request": { + "SelectUnits": { + "player": 2, + "choices": [ + 5, + 6, + 7 + ], + "needed": 1, + "description": "Select Pirate Ships to remove" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + } + ], + "action_log_index": 2, + "log": [ + [ + "Player1 advanced Storage for free", + "Player gained 1 mood token as advance bonus" + ], + [ + "A new game event has been triggered: Pirates spawn" + ], + [ + "Player1 removed a Pirate Ships at D1" + ] + ], + "undo_limit": 2, + "actions_left": 3, + "successful_cultural_influence": false, + "round": 1, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10 + ], + "dropped_players": [], + "wonders_left": [ + "Pyramids" + ], + "wonder_amount_left": 1, + "incidents_left": [ + 54 + ] +} \ No newline at end of file diff --git a/server/tests/test_games/pirates_spawn.outcome2.json b/server/tests/test_games/pirates_spawn.outcome2.json new file mode 100644 index 00000000..673cc78b --- /dev/null +++ b/server/tests/test_games/pirates_spawn.outcome2.json @@ -0,0 +1,396 @@ +{ + "state": { + "StatusPhase": "FreeAdvance" + }, + "state_change_event_state": [ + { + "event_type": "on_incident", + "last_priority_used": 103, + "current": { + "priority": 103, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + ], + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 1, + "wood": 6, + "ore": 6, + "ideas": 5, + "gold": 7, + "mood_tokens": 8, + "culture_tokens": 7 + }, + "resource_limit": { + "food": 7, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "fortress": 0 + }, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C2" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 7 + } + ], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining", + "Storage", + "Tactics" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 8 + }, + { + "name": null, + "id": 1, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "C1" + } + ], + "units": [ + { + "position": "C1", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "C1", + "unit_type": "Infantry", + "id": 1 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B3" + } + ], + "units": [ + { + "position": "D1", + "unit_type": "Ship", + "id": 5 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 6 + }, + { + "position": "A2", + "unit_type": "Ship", + "id": 9 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 10 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "D1", + "Water" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 0, + "action_log": [ + { + "action": { + "StatusPhase": { + "FreeAdvance": "Storage" + } + } + }, + { + "action": { + "CustomPhaseEvent": { + "SelectUnits": [ + 7 + ] + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 105, + "current": { + "priority": 105, + "player_index": 0, + "request": { + "SelectUnits": { + "player": 2, + "choices": [ + 5, + 6, + 7 + ], + "needed": 1, + "description": "Select Pirate Ships to remove" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "SelectPosition": "A2" + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 104, + "current": { + "priority": 104, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + } + ], + "action_log_index": 3, + "log": [ + [ + "Player1 advanced Storage for free", + "Player gained 1 mood token as advance bonus" + ], + [ + "A new game event has been triggered: Pirates spawn" + ], + [ + "Player1 removed a Pirate Ships at D1" + ], + [ + "Pirates spawned a Pirate Ship at A2" + ] + ], + "undo_limit": 3, + "actions_left": 3, + "successful_cultural_influence": false, + "round": 1, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10 + ], + "dropped_players": [], + "wonders_left": [ + "Pyramids" + ], + "wonder_amount_left": 1, + "incidents_left": [ + 54 + ] +} \ No newline at end of file diff --git a/server/tests/test_games/pirates_spawn.outcome3.json b/server/tests/test_games/pirates_spawn.outcome3.json new file mode 100644 index 00000000..561d81a8 --- /dev/null +++ b/server/tests/test_games/pirates_spawn.outcome3.json @@ -0,0 +1,512 @@ +{ + "state": { + "StatusPhase": "FreeAdvance" + }, + "state_change_event_state": [ + { + "event_type": "on_incident", + "last_priority_used": 102, + "current": { + "priority": 102, + "player_index": 0, + "request": { + "Payment": [ + { + "cost": { + "default": { + "food": 1 + }, + "conversions": [ + { + "from": [ + { + "food": 1 + } + ], + "to": { + "wood": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "wood": 1 + } + ], + "to": { + "ore": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "ore": 1 + } + ], + "to": { + "ideas": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "ideas": 1 + } + ], + "to": { + "gold": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "gold": 1 + } + ], + "to": { + "mood_tokens": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "mood_tokens": 1 + } + ], + "to": { + "culture_tokens": 1 + }, + "type": "Unlimited" + } + ] + }, + "name": "Pay 1 Resource or token to bribe the pirates", + "optional": false + } + ] + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + ], + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 1, + "wood": 6, + "ore": 6, + "ideas": 5, + "gold": 7, + "mood_tokens": 8, + "culture_tokens": 7 + }, + "resource_limit": { + "food": 7, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "fortress": 0 + }, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C2" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 7 + } + ], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining", + "Storage", + "Tactics" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 8 + }, + { + "name": null, + "id": 1, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "C1" + } + ], + "units": [ + { + "position": "C1", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "C1", + "unit_type": "Infantry", + "id": 1 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B3" + } + ], + "units": [ + { + "position": "D1", + "unit_type": "Ship", + "id": 5 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 6 + }, + { + "position": "A2", + "unit_type": "Ship", + "id": 9 + }, + { + "position": "D2", + "unit_type": "Ship", + "id": 10 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 11 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "D1", + "Water" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 0, + "action_log": [ + { + "action": { + "StatusPhase": { + "FreeAdvance": "Storage" + } + } + }, + { + "action": { + "CustomPhaseEvent": { + "SelectUnits": [ + 7 + ] + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 105, + "current": { + "priority": 105, + "player_index": 0, + "request": { + "SelectUnits": { + "player": 2, + "choices": [ + 5, + 6, + 7 + ], + "needed": 1, + "description": "Select Pirate Ships to remove" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "SelectPosition": "A2" + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 104, + "current": { + "priority": 104, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "SelectPosition": "D2" + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 103, + "current": { + "priority": 103, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + } + ], + "action_log_index": 4, + "log": [ + [ + "Player1 advanced Storage for free", + "Player gained 1 mood token as advance bonus" + ], + [ + "A new game event has been triggered: Pirates spawn" + ], + [ + "Player1 removed a Pirate Ships at D1" + ], + [ + "Pirates spawned a Pirate Ship at A2" + ], + [ + "Pirates spawned a Pirate Ship at D2", + "Player1 must pay 1 resource or token to bribe the pirates" + ] + ], + "undo_limit": 4, + "actions_left": 3, + "successful_cultural_influence": false, + "round": 1, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10 + ], + "dropped_players": [], + "wonders_left": [ + "Pyramids" + ], + "wonder_amount_left": 1, + "incidents_left": [ + 54 + ] +} \ No newline at end of file diff --git a/server/tests/test_games/pirates_spawn.outcome4.json b/server/tests/test_games/pirates_spawn.outcome4.json new file mode 100644 index 00000000..39b36b9c --- /dev/null +++ b/server/tests/test_games/pirates_spawn.outcome4.json @@ -0,0 +1,533 @@ +{ + "state": { + "StatusPhase": "FreeAdvance" + }, + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 1, + "wood": 6, + "ore": 5, + "ideas": 5, + "gold": 7, + "mood_tokens": 8, + "culture_tokens": 7 + }, + "resource_limit": { + "food": 7, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "fortress": 0 + }, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C2" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C2", + "unit_type": "Settler", + "id": 7 + } + ], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining", + "Storage", + "Tactics" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 8 + }, + { + "name": null, + "id": 1, + "resources": { + "food": 1 + }, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 7, + "culture_tokens": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 0, + "angry_activation": false, + "position": "C1" + } + ], + "units": [ + { + "position": "C1", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "C1", + "unit_type": "Infantry", + "id": 1 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B3" + } + ], + "units": [ + { + "position": "D1", + "unit_type": "Ship", + "id": 5 + }, + { + "position": "D1", + "unit_type": "Ship", + "id": 6 + }, + { + "position": "A2", + "unit_type": "Ship", + "id": 9 + }, + { + "position": "D2", + "unit_type": "Ship", + "id": 10 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 11 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "D1", + "Water" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 1, + "action_log": [ + { + "action": { + "StatusPhase": { + "FreeAdvance": "Storage" + } + } + }, + { + "action": { + "CustomPhaseEvent": { + "SelectUnits": [ + 7 + ] + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 105, + "current": { + "priority": 105, + "player_index": 0, + "request": { + "SelectUnits": { + "player": 2, + "choices": [ + 5, + 6, + 7 + ], + "needed": 1, + "description": "Select Pirate Ships to remove" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "SelectPosition": "A2" + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 104, + "current": { + "priority": 104, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "SelectPosition": "D2" + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 103, + "current": { + "priority": 103, + "player_index": 0, + "request": { + "SelectPosition": { + "choices": [ + "A2", + "C3", + "D1", + "D2" + ], + "description": "Select a position for the Pirate Ship" + } + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "Payment": [ + { + "ore": 1 + } + ] + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_incident", + "last_priority_used": 102, + "current": { + "priority": 102, + "player_index": 0, + "request": { + "Payment": [ + { + "cost": { + "default": { + "food": 1 + }, + "conversions": [ + { + "from": [ + { + "food": 1 + } + ], + "to": { + "wood": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "wood": 1 + } + ], + "to": { + "ore": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "ore": 1 + } + ], + "to": { + "ideas": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "ideas": 1 + } + ], + "to": { + "gold": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "gold": 1 + } + ], + "to": { + "mood_tokens": 1 + }, + "type": "Unlimited" + }, + { + "from": [ + { + "mood_tokens": 1 + } + ], + "to": { + "culture_tokens": 1 + }, + "type": "Unlimited" + } + ] + }, + "name": "Pay 1 Resource or token to bribe the pirates", + "optional": false + } + ] + }, + "response": null, + "origin": { + "Incident": 54 + } + }, + "barbarians": { + "move_units": false + } + } + } + ] + } + ], + "action_log_index": 5, + "log": [ + [ + "Player1 advanced Storage for free", + "Player gained 1 mood token as advance bonus" + ], + [ + "A new game event has been triggered: Pirates spawn" + ], + [ + "Player1 removed a Pirate Ships at D1" + ], + [ + "Pirates spawned a Pirate Ship at A2" + ], + [ + "Pirates spawned a Pirate Ship at D2", + "Player1 must pay 1 resource or token to bribe the pirates" + ], + [ + "Pirates took 1 ore", + "Player2 must reduce Mood in a city adjacent to pirates", + "Player2 reduced Mood in the city at C1", + "Player2 gained 1 food from A successful year" + ], + [ + "It's Player2's turn" + ] + ], + "undo_limit": 5, + "actions_left": 3, + "successful_cultural_influence": false, + "round": 1, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10 + ], + "dropped_players": [], + "wonders_left": [ + "Pyramids" + ], + "wonder_amount_left": 1 +} \ No newline at end of file diff --git a/server/tests/test_games/raze_city.outcome.json b/server/tests/test_games/raze_city.outcome.json index 4cd0e551..22992d04 100644 --- a/server/tests/test_games/raze_city.outcome.json +++ b/server/tests/test_games/raze_city.outcome.json @@ -248,4 +248,4 @@ "Pyramids" ], "wonder_amount_left": 1 -} +} \ No newline at end of file diff --git a/server/tests/test_games/raze_city_decline.outcome.json b/server/tests/test_games/raze_city_decline.outcome.json index aeca4b92..640660be 100644 --- a/server/tests/test_games/raze_city_decline.outcome.json +++ b/server/tests/test_games/raze_city_decline.outcome.json @@ -253,4 +253,4 @@ "Pyramids" ], "wonder_amount_left": 1 -} +} \ No newline at end of file diff --git a/server/tests/test_games/recruit_combat.json b/server/tests/test_games/recruit_combat.json index 549be46e..3ee09672 100644 --- a/server/tests/test_games/recruit_combat.json +++ b/server/tests/test_games/recruit_combat.json @@ -5,26 +5,26 @@ "name": null, "id": 0, "resources": { - "food": 1, - "wood": 6, - "ore": 6, + "food": 6, + "wood": 7, + "ore": 7, "ideas": 5, "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "mood_tokens": 9, + "culture_tokens": 10 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { - "city_pieces": {}, + "city_pieces": { + "market": 1 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, @@ -32,13 +32,34 @@ }, { "city_pieces": { - "port": 0 + "academy": 1, + "obelisk": 0, + "port": 1 }, - "mood_state": "Angry", - "activations": 9, + "mood_state": "Happy", + "activations": 4, "angry_activation": false, "position": "C2", "port_position": "C3" + }, + { + "city_pieces": { + "obelisk": 1, + "observatory": 1, + "fortress": 1, + "temple": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B1" + }, + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 4, + "angry_activation": true, + "position": "B3" } ], "units": [ @@ -48,42 +69,22 @@ "id": 0 }, { - "position": "C2", - "unit_type": "Cavalry", - "id": 1 - }, - { - "position": "C2", - "unit_type": "Leader", - "id": 2 - }, - { - "position": "C2", - "unit_type": "Elephant", - "id": 3 - }, - { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 4 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 5 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 6 - }, - { - "position": "C2", - "unit_type": "Settler", - "id": 7 } ], - "civilization": "test0", + "civilization": "test1", "active_leader": null, "available_leaders": [], "advances": [ @@ -95,33 +96,33 @@ ], "unlocked_special_advance": [], "wonders_build": [], - "incident_tokens": 2, + "incident_tokens": 3, "completed_objectives": [], "captured_leaders": [], "event_victory_points": 0.0, - "wonder_cards": [], - "next_unit_id": 8 + "wonder_cards": [ + "Pyramids" + ], + "next_unit_id": 10 }, { "name": null, "id": 1, "resources": { - "food": 2, + "food": 6, "wood": 7, "ore": 7, "ideas": 7, "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "mood_tokens": 9, + "culture_tokens": 9 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { @@ -130,27 +131,63 @@ "activations": 2, "angry_activation": false, "position": "C1" + }, + { + "city_pieces": { + "port": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B2", + "port_position": "C3" } ], + "units": [], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 3 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], "units": [ { - "position": "C1", - "unit_type": "Infantry", - "id": 0 + "position": "C3", + "unit_type": "Ship", + "id": 1 }, { "position": "C3", "unit_type": "Ship", - "id": 1 + "id": 2 } ], - "civilization": "test1", + "civilization": "Pirates", "active_leader": null, "available_leaders": [], - "advances": [ - "Farming", - "Mining" - ], + "advances": [], "unlocked_special_advance": [], "wonders_build": [], "incident_tokens": 3, @@ -158,7 +195,7 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 2 + "next_unit_id": 9 } ], "map": { @@ -177,6 +214,10 @@ "Exhausted": "Forest" } ], + [ + "A4", + "Mountain" + ], [ "B1", "Mountain" @@ -189,6 +230,10 @@ "B3", "Fertile" ], + [ + "B4", + "Fertile" + ], [ "C1", "Barren" @@ -201,6 +246,18 @@ "C3", "Water" ], + [ + "C4", + "Water" + ], + [ + "C5", + "Water" + ], + [ + "D1", + "Fertile" + ], [ "D2", "Water" @@ -209,13 +266,42 @@ }, "starting_player_index": 0, "current_player_index": 0, - "action_log": [], - "action_log_index": 0, - "log": [], + "action_log": [ + { + "action": { + "Movement": "Stop" + }, + "undo": [ + { + "Movement": { + "movement_actions_left": 1, + "moved_units": [ + 7, + 8 + ], + "current_move": { + "Fleet": { + "units": [ + 7, + 8 + ] + } + } + } + } + ] + } + ], + "action_log_index": 1, + "log": [ + [ + "Player1 ended the movement action" + ] + ], "undo_limit": 0, - "actions_left": 1, + "actions_left": 2, "successful_cultural_influence": false, - "round": 1, + "round": 6, "age": 1, "messages": [ "The game has started" @@ -234,8 +320,6 @@ ], "dice_roll_log": [], "dropped_players": [], - "wonders_left": [ - "Pyramids" - ], + "wonders_left": [], "wonder_amount_left": 1 } \ No newline at end of file diff --git a/server/tests/test_games/recruit_combat.outcome.json b/server/tests/test_games/recruit_combat.outcome.json index 07a02746..4a5b3d21 100644 --- a/server/tests/test_games/recruit_combat.outcome.json +++ b/server/tests/test_games/recruit_combat.outcome.json @@ -42,26 +42,26 @@ "name": null, "id": 0, "resources": { - "food": 1, - "wood": 5, - "ore": 6, + "food": 6, + "wood": 2, + "ore": 7, "ideas": 5, "gold": 6, - "mood_tokens": 7, - "culture_tokens": 7 + "mood_tokens": 9, + "culture_tokens": 10 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { - "city_pieces": {}, + "city_pieces": { + "market": 1 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, @@ -69,13 +69,34 @@ }, { "city_pieces": { - "port": 0 + "academy": 1, + "obelisk": 0, + "port": 1 }, - "mood_state": "Angry", - "activations": 10, - "angry_activation": true, + "mood_state": "Neutral", + "activations": 5, + "angry_activation": false, "position": "C2", "port_position": "C3" + }, + { + "city_pieces": { + "obelisk": 1, + "observatory": 1, + "fortress": 1, + "temple": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B1" + }, + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 4, + "angry_activation": true, + "position": "B3" } ], "units": [ @@ -85,47 +106,37 @@ "id": 0 }, { - "position": "C2", - "unit_type": "Cavalry", - "id": 1 - }, - { - "position": "C2", - "unit_type": "Leader", - "id": 2 - }, - { - "position": "C2", - "unit_type": "Elephant", - "id": 3 - }, - { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 4 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 5 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 6 }, { - "position": "C2", - "unit_type": "Settler", - "id": 7 + "position": "C3", + "unit_type": "Ship", + "id": 10 }, { "position": "C3", "unit_type": "Ship", - "id": 8 + "id": 11 + }, + { + "position": "C3", + "unit_type": "Ship", + "id": 12 } ], - "civilization": "test0", + "civilization": "test1", "active_leader": null, "available_leaders": [], "advances": [ @@ -137,33 +148,33 @@ ], "unlocked_special_advance": [], "wonders_build": [], - "incident_tokens": 2, + "incident_tokens": 3, "completed_objectives": [], "captured_leaders": [], "event_victory_points": 0.0, - "wonder_cards": [], - "next_unit_id": 9 + "wonder_cards": [ + "Pyramids" + ], + "next_unit_id": 13 }, { "name": null, "id": 1, "resources": { - "food": 2, + "food": 6, "wood": 7, "ore": 7, "ideas": 7, "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "mood_tokens": 9, + "culture_tokens": 9 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { @@ -172,27 +183,63 @@ "activations": 2, "angry_activation": false, "position": "C1" + }, + { + "city_pieces": { + "port": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B2", + "port_position": "C3" } ], + "units": [], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 3 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], "units": [ { - "position": "C1", - "unit_type": "Infantry", - "id": 0 + "position": "C3", + "unit_type": "Ship", + "id": 1 }, { "position": "C3", "unit_type": "Ship", - "id": 1 + "id": 2 } ], - "civilization": "test1", + "civilization": "Pirates", "active_leader": null, "available_leaders": [], - "advances": [ - "Farming", - "Mining" - ], + "advances": [], "unlocked_special_advance": [], "wonders_build": [], "incident_tokens": 3, @@ -200,7 +247,7 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 2 + "next_unit_id": 9 } ], "map": { @@ -219,6 +266,10 @@ "Exhausted": "Forest" } ], + [ + "A4", + "Mountain" + ], [ "B1", "Mountain" @@ -231,6 +282,10 @@ "B3", "Fertile" ], + [ + "B4", + "Fertile" + ], [ "C1", "Barren" @@ -243,6 +298,18 @@ "C3", "Water" ], + [ + "C4", + "Water" + ], + [ + "C5", + "Water" + ], + [ + "D1", + "Fertile" + ], [ "D2", "Water" @@ -252,16 +319,40 @@ "starting_player_index": 0, "current_player_index": 0, "action_log": [ + { + "action": { + "Movement": "Stop" + }, + "undo": [ + { + "Movement": { + "movement_actions_left": 1, + "moved_units": [ + 7, + 8 + ], + "current_move": { + "Fleet": { + "units": [ + 7, + 8 + ] + } + } + } + } + ] + }, { "action": { "Playing": { "Recruit": { "units": { - "ships": 1 + "ships": 3 }, "city_position": "C2", "payment": { - "wood": 1, + "wood": 5, "gold": 1 } } @@ -274,16 +365,19 @@ ] } ], - "action_log_index": 1, + "action_log_index": 2, "log": [ [ - "Player1 paid 1 wood and 1 gold to recruit 1 ship in the city at C2 making it Angry" + "Player1 ended the movement action" + ], + [ + "Player1 paid 5 wood and 1 gold to recruit 3 ships in the city at C2 making it Neutral" ] ], "undo_limit": 0, - "actions_left": 0, + "actions_left": 1, "successful_cultural_influence": false, - "round": 1, + "round": 6, "age": 1, "messages": [ "The game has started" @@ -302,8 +396,6 @@ ], "dice_roll_log": [], "dropped_players": [], - "wonders_left": [ - "Pyramids" - ], + "wonders_left": [], "wonder_amount_left": 1 } \ No newline at end of file diff --git a/server/tests/test_games/recruit_combat.outcome1.json b/server/tests/test_games/recruit_combat.outcome1.json index 8a95f572..824ce3b7 100644 --- a/server/tests/test_games/recruit_combat.outcome1.json +++ b/server/tests/test_games/recruit_combat.outcome1.json @@ -42,26 +42,26 @@ "name": null, "id": 0, "resources": { - "food": 1, - "wood": 5, - "ore": 6, + "food": 2, + "wood": 2, + "ore": 7, "ideas": 5, "gold": 6, - "mood_tokens": 8, - "culture_tokens": 7 + "mood_tokens": 10, + "culture_tokens": 10 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { - "city_pieces": {}, + "city_pieces": { + "market": 1 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, @@ -69,13 +69,34 @@ }, { "city_pieces": { - "port": 0 + "academy": 1, + "obelisk": 0, + "port": 1 }, - "mood_state": "Angry", - "activations": 10, - "angry_activation": true, + "mood_state": "Neutral", + "activations": 5, + "angry_activation": false, "position": "C2", "port_position": "C3" + }, + { + "city_pieces": { + "obelisk": 1, + "observatory": 1, + "fortress": 1, + "temple": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B1" + }, + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 4, + "angry_activation": true, + "position": "B3" } ], "units": [ @@ -85,47 +106,37 @@ "id": 0 }, { - "position": "C2", - "unit_type": "Cavalry", - "id": 1 - }, - { - "position": "C2", - "unit_type": "Leader", - "id": 2 - }, - { - "position": "C2", - "unit_type": "Elephant", - "id": 3 - }, - { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 4 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 5 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 6 }, { - "position": "C2", - "unit_type": "Settler", - "id": 7 + "position": "C3", + "unit_type": "Ship", + "id": 10 + }, + { + "position": "C3", + "unit_type": "Ship", + "id": 11 }, { "position": "C3", "unit_type": "Ship", - "id": 8 + "id": 12 } ], - "civilization": "test0", + "civilization": "test1", "active_leader": null, "available_leaders": [], "advances": [ @@ -137,33 +148,33 @@ ], "unlocked_special_advance": [], "wonders_build": [], - "incident_tokens": 2, + "incident_tokens": 3, "completed_objectives": [], "captured_leaders": [], "event_victory_points": 0.0, - "wonder_cards": [], - "next_unit_id": 9 + "wonder_cards": [ + "Pyramids" + ], + "next_unit_id": 13 }, { "name": null, "id": 1, "resources": { - "food": 2, + "food": 6, "wood": 7, "ore": 7, "ideas": 7, "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "mood_tokens": 9, + "culture_tokens": 9 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { @@ -172,27 +183,63 @@ "activations": 2, "angry_activation": false, "position": "C1" + }, + { + "city_pieces": { + "port": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B2", + "port_position": "C3" } ], + "units": [], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 3 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], "units": [ { - "position": "C1", - "unit_type": "Infantry", - "id": 0 + "position": "C3", + "unit_type": "Ship", + "id": 1 }, { "position": "C3", "unit_type": "Ship", - "id": 1 + "id": 2 } ], - "civilization": "test1", + "civilization": "Pirates", "active_leader": null, "available_leaders": [], - "advances": [ - "Farming", - "Mining" - ], + "advances": [], "unlocked_special_advance": [], "wonders_build": [], "incident_tokens": 3, @@ -200,7 +247,7 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 2 + "next_unit_id": 9 } ], "map": { @@ -219,6 +266,10 @@ "Exhausted": "Forest" } ], + [ + "A4", + "Mountain" + ], [ "B1", "Mountain" @@ -231,6 +282,10 @@ "B3", "Fertile" ], + [ + "B4", + "Fertile" + ], [ "C1", "Barren" @@ -243,6 +298,18 @@ "C3", "Water" ], + [ + "C4", + "Water" + ], + [ + "C5", + "Water" + ], + [ + "D1", + "Fertile" + ], [ "D2", "Water" @@ -252,16 +319,40 @@ "starting_player_index": 0, "current_player_index": 0, "action_log": [ + { + "action": { + "Movement": "Stop" + }, + "undo": [ + { + "Movement": { + "movement_actions_left": 1, + "moved_units": [ + 7, + 8 + ], + "current_move": { + "Fleet": { + "units": [ + 7, + 8 + ] + } + } + } + } + ] + }, { "action": { "Playing": { "Recruit": { "units": { - "ships": 1 + "ships": 3 }, "city_position": "C2", "payment": { - "wood": 1, + "wood": 5, "gold": 1 } } @@ -318,23 +409,35 @@ } } } + }, + { + "WastedResources": { + "resources": { + "food": 4 + }, + "player_index": 0 + } } ] } ], - "action_log_index": 2, + "action_log_index": 3, "log": [ [ - "Player1 paid 1 wood and 1 gold to recruit 1 ship in the city at C2 making it Angry" + "Player1 ended the movement action" ], [ - "Player1 selected 1 mood token for Nationalism Advance" + "Player1 paid 5 wood and 1 gold to recruit 3 ships in the city at C2 making it Neutral" + ], + [ + "Player1 selected 1 mood token for Nationalism Advance", + "Player1 could not store 4 food" ] ], "undo_limit": 0, - "actions_left": 0, + "actions_left": 1, "successful_cultural_influence": false, - "round": 1, + "round": 6, "age": 1, "messages": [ "The game has started" @@ -353,8 +456,6 @@ ], "dice_roll_log": [], "dropped_players": [], - "wonders_left": [ - "Pyramids" - ], + "wonders_left": [], "wonder_amount_left": 1 } \ No newline at end of file diff --git a/server/tests/test_games/recruit_combat.outcome2.json b/server/tests/test_games/recruit_combat.outcome2.json index 6caced04..46c44e9a 100644 --- a/server/tests/test_games/recruit_combat.outcome2.json +++ b/server/tests/test_games/recruit_combat.outcome2.json @@ -1,30 +1,91 @@ { - "state": "Playing", + "state": { + "Combat": { + "initiation": "Playing", + "round": 1, + "defender": 2, + "defender_position": "C3", + "attacker": 0, + "attacker_position": "C2", + "attackers": [ + 12 + ], + "can_retreat": false, + "round_result": { + "attacker_casualties": { + "fighters": 2 + }, + "defender_casualties": { + "fighters": 2 + }, + "can_retreat": false, + "retreated": false + }, + "result": "AttackerWins" + } + }, + "state_change_event_state": [ + { + "event_type": "on_combat_end", + "last_priority_used": 3, + "current": { + "priority": 3, + "player_index": 0, + "request": { + "ResourceReward": { + "reward": { + "default": { + "mood_tokens": 1 + }, + "conversions": [ + { + "from": [ + { + "mood_tokens": 1 + } + ], + "to": { + "culture_tokens": 1 + }, + "type": "Unlimited" + } + ] + }, + "name": "Select a reward for fighting the Pirates" + } + }, + "response": null, + "origin": { + "Builtin": "Barbarians bonus" + } + } + } + ], "players": [ { "name": null, "id": 0, "resources": { - "food": 1, - "wood": 5, - "ore": 6, + "food": 2, + "wood": 2, + "ore": 7, "ideas": 5, "gold": 7, - "mood_tokens": 8, - "culture_tokens": 7 + "mood_tokens": 10, + "culture_tokens": 10 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { - "city_pieces": {}, + "city_pieces": { + "market": 1 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, @@ -32,13 +93,34 @@ }, { "city_pieces": { - "port": 0 + "academy": 1, + "obelisk": 0, + "port": 1 }, - "mood_state": "Angry", - "activations": 10, - "angry_activation": true, + "mood_state": "Neutral", + "activations": 5, + "angry_activation": false, "position": "C2", "port_position": "C3" + }, + { + "city_pieces": { + "obelisk": 1, + "observatory": 1, + "fortress": 1, + "temple": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B1" + }, + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 4, + "angry_activation": true, + "position": "B3" } ], "units": [ @@ -48,42 +130,27 @@ "id": 0 }, { - "position": "C2", - "unit_type": "Cavalry", - "id": 1 - }, - { - "position": "C2", - "unit_type": "Leader", - "id": 2 - }, - { - "position": "C2", - "unit_type": "Elephant", - "id": 3 - }, - { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 4 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 5 }, { - "position": "C2", + "position": "B3", "unit_type": "Settler", "id": 6 }, { - "position": "C2", - "unit_type": "Settler", - "id": 7 + "position": "C3", + "unit_type": "Ship", + "id": 12 } ], - "civilization": "test0", + "civilization": "test1", "active_leader": null, "available_leaders": [], "advances": [ @@ -95,33 +162,33 @@ ], "unlocked_special_advance": [], "wonders_build": [], - "incident_tokens": 2, + "incident_tokens": 3, "completed_objectives": [], "captured_leaders": [], "event_victory_points": 0.0, - "wonder_cards": [], - "next_unit_id": 9 + "wonder_cards": [ + "Pyramids" + ], + "next_unit_id": 13 }, { "name": null, "id": 1, "resources": { - "food": 2, + "food": 6, "wood": 7, "ore": 7, "ideas": 7, "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "mood_tokens": 9, + "culture_tokens": 9 }, "resource_limit": { "food": 2, "wood": 7, "ore": 7, "ideas": 7, - "gold": 7, - "mood_tokens": 7, - "culture_tokens": 7 + "gold": 7 }, "cities": [ { @@ -130,16 +197,20 @@ "activations": 2, "angry_activation": false, "position": "C1" - } - ], - "units": [ + }, { - "position": "C1", - "unit_type": "Infantry", - "id": 0 + "city_pieces": { + "port": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B2", + "port_position": "C3" } ], - "civilization": "test1", + "units": [], + "civilization": "test0", "active_leader": null, "available_leaders": [], "advances": [ @@ -153,7 +224,33 @@ "captured_leaders": [], "event_victory_points": 0.0, "wonder_cards": [], - "next_unit_id": 2 + "next_unit_id": 3 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { @@ -172,6 +269,10 @@ "Exhausted": "Forest" } ], + [ + "A4", + "Mountain" + ], [ "B1", "Mountain" @@ -184,6 +285,10 @@ "B3", "Fertile" ], + [ + "B4", + "Fertile" + ], [ "C1", "Barren" @@ -196,6 +301,18 @@ "C3", "Water" ], + [ + "C4", + "Water" + ], + [ + "C5", + "Water" + ], + [ + "D1", + "Fertile" + ], [ "D2", "Water" @@ -205,16 +322,40 @@ "starting_player_index": 0, "current_player_index": 0, "action_log": [ + { + "action": { + "Movement": "Stop" + }, + "undo": [ + { + "Movement": { + "movement_actions_left": 1, + "moved_units": [ + 7, + 8 + ], + "current_move": { + "Fleet": { + "units": [ + 7, + 8 + ] + } + } + } + } + ] + }, { "action": { "Playing": { "Recruit": { "units": { - "ships": 1 + "ships": 3 }, "city_position": "C2", "payment": { - "wood": 1, + "wood": 5, "gold": 1 } } @@ -271,6 +412,14 @@ } } } + }, + { + "WastedResources": { + "resources": { + "food": 4 + }, + "player_index": 0 + } } ] }, @@ -319,36 +468,50 @@ } } } + }, + { + "WastedResources": { + "resources": { + "gold": 2 + }, + "player_index": 0 + } } ] } ], - "action_log_index": 3, + "action_log_index": 4, "log": [ [ - "Player1 paid 1 wood and 1 gold to recruit 1 ship in the city at C2 making it Angry" + "Player1 ended the movement action" + ], + [ + "Player1 paid 5 wood and 1 gold to recruit 3 ships in the city at C2 making it Neutral" ], [ - "Player1 selected 1 mood token for Nationalism Advance" + "Player1 selected 1 mood token for Nationalism Advance", + "Player1 could not store 4 food" ], [ "Player1 selected 1 gold for Medicine Advance" ], [ "Combat round 1", - "Player1 rolled 6 (Infantry, no bonus) for combined combat value of 6 and gets 1 hits against defending units.", - "Player2 rolled 6 (Infantry, no bonus) for combined combat value of 6 and gets 1 hits against attacking units.", - "Player1 has to remove all of their attacking units", - "Player1 removed 1 ship", - "Player2 has to remove all of their defending units", - "Player2 removed 1 ship", - "Battle ends in a draw" + "Player1 rolled 6 (Infantry, no bonus), 6 (Infantry, no bonus), 6 (Infantry, no bonus) for combined combat value of 18 and gets 2 hits against defending units.", + "Pirates rolled 6 (Infantry, no bonus), 6 (Infantry, no bonus) for combined combat value of 12 and gets 2 hits against attacking units.", + "Player1 gained 2 gold for destroying Pirate Ships", + "Player1 has to remove 2 of their attacking units", + "Player1 removed 2 ships", + "Pirates has to remove all of their defending units", + "Pirates removed 2 ships", + "Attacker wins", + "Player1 could not store 2 gold" ] ], - "undo_limit": 3, - "actions_left": 0, + "undo_limit": 4, + "actions_left": 1, "successful_cultural_influence": false, - "round": 1, + "round": 6, "age": 1, "messages": [ "The game has started" @@ -358,18 +521,16 @@ 1, 10, 10, - 10, - 10, - 10, 10 ], "dice_roll_log": [ + 10, + 10, + 10, 10, 10 ], "dropped_players": [], - "wonders_left": [ - "Pyramids" - ], + "wonders_left": [], "wonder_amount_left": 1 } \ No newline at end of file diff --git a/server/tests/test_games/recruit_combat.outcome3.json b/server/tests/test_games/recruit_combat.outcome3.json new file mode 100644 index 00000000..6fc63d30 --- /dev/null +++ b/server/tests/test_games/recruit_combat.outcome3.json @@ -0,0 +1,526 @@ +{ + "state": "Playing", + "players": [ + { + "name": null, + "id": 0, + "resources": { + "food": 2, + "wood": 2, + "ore": 7, + "ideas": 5, + "gold": 7, + "mood_tokens": 10, + "culture_tokens": 11 + }, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": { + "market": 1 + }, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "A1" + }, + { + "city_pieces": { + "academy": 1, + "obelisk": 0, + "port": 1 + }, + "mood_state": "Neutral", + "activations": 5, + "angry_activation": false, + "position": "C2", + "port_position": "C3" + }, + { + "city_pieces": { + "obelisk": 1, + "observatory": 1, + "fortress": 1, + "temple": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B1" + }, + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 4, + "angry_activation": true, + "position": "B3" + } + ], + "units": [ + { + "position": "C2", + "unit_type": "Infantry", + "id": 0 + }, + { + "position": "B3", + "unit_type": "Settler", + "id": 4 + }, + { + "position": "B3", + "unit_type": "Settler", + "id": 5 + }, + { + "position": "B3", + "unit_type": "Settler", + "id": 6 + }, + { + "position": "C3", + "unit_type": "Ship", + "id": 12 + } + ], + "civilization": "test1", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Fishing", + "Medicine", + "Mining", + "Nationalism" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [ + "Pyramids" + ], + "next_unit_id": 13 + }, + { + "name": null, + "id": 1, + "resources": { + "food": 6, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7, + "mood_tokens": 9, + "culture_tokens": 9 + }, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [ + { + "city_pieces": {}, + "mood_state": "Angry", + "activations": 2, + "angry_activation": false, + "position": "C1" + }, + { + "city_pieces": { + "port": 1 + }, + "mood_state": "Neutral", + "activations": 0, + "angry_activation": false, + "position": "B2", + "port_position": "C3" + } + ], + "units": [], + "civilization": "test0", + "active_leader": null, + "available_leaders": [], + "advances": [ + "Farming", + "Mining" + ], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 3 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 + } + ], + "map": { + "tiles": [ + [ + "A1", + "Fertile" + ], + [ + "A2", + "Water" + ], + [ + "A3", + { + "Exhausted": "Forest" + } + ], + [ + "A4", + "Mountain" + ], + [ + "B1", + "Mountain" + ], + [ + "B2", + "Forest" + ], + [ + "B3", + "Fertile" + ], + [ + "B4", + "Fertile" + ], + [ + "C1", + "Barren" + ], + [ + "C2", + "Forest" + ], + [ + "C3", + "Water" + ], + [ + "C4", + "Water" + ], + [ + "C5", + "Water" + ], + [ + "D1", + "Fertile" + ], + [ + "D2", + "Water" + ] + ] + }, + "starting_player_index": 0, + "current_player_index": 0, + "action_log": [ + { + "action": { + "Movement": "Stop" + }, + "undo": [ + { + "Movement": { + "movement_actions_left": 1, + "moved_units": [ + 7, + 8 + ], + "current_move": { + "Fleet": { + "units": [ + 7, + 8 + ] + } + } + } + } + ] + }, + { + "action": { + "Playing": { + "Recruit": { + "units": { + "ships": 3 + }, + "city_position": "C2", + "payment": { + "wood": 5, + "gold": 1 + } + } + } + }, + "undo": [ + { + "Recruit": {} + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "ResourceReward": { + "mood_tokens": 1 + } + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_recruit", + "last_priority_used": 1, + "current": { + "priority": 1, + "player_index": 0, + "request": { + "ResourceReward": { + "reward": { + "default": { + "mood_tokens": 1 + }, + "conversions": [ + { + "from": [ + { + "mood_tokens": 1 + } + ], + "to": { + "culture_tokens": 1 + }, + "type": "Unlimited" + } + ] + }, + "name": "Select token to gain" + } + }, + "response": null, + "origin": { + "Advance": "Nationalism" + } + } + } + }, + { + "WastedResources": { + "resources": { + "food": 4 + }, + "player_index": 0 + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "ResourceReward": { + "gold": 1 + } + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_recruit", + "last_priority_used": 0, + "current": { + "priority": 0, + "player_index": 0, + "request": { + "ResourceReward": { + "reward": { + "default": { + "wood": 1 + }, + "conversions": [ + { + "from": [ + { + "wood": 1 + } + ], + "to": { + "gold": 1 + }, + "type": "Unlimited" + } + ] + }, + "name": "Select resource to gain back" + } + }, + "response": null, + "origin": { + "Advance": "Medicine" + } + } + } + }, + { + "WastedResources": { + "resources": { + "gold": 2 + }, + "player_index": 0 + } + } + ] + }, + { + "action": { + "CustomPhaseEvent": { + "ResourceReward": { + "culture_tokens": 1 + } + } + }, + "undo": [ + { + "CustomPhaseEvent": { + "event_type": "on_combat_end", + "last_priority_used": 3, + "current": { + "priority": 3, + "player_index": 0, + "request": { + "ResourceReward": { + "reward": { + "default": { + "mood_tokens": 1 + }, + "conversions": [ + { + "from": [ + { + "mood_tokens": 1 + } + ], + "to": { + "culture_tokens": 1 + }, + "type": "Unlimited" + } + ] + }, + "name": "Select a reward for fighting the Pirates" + } + }, + "response": null, + "origin": { + "Builtin": "Barbarians bonus" + } + } + } + } + ] + } + ], + "action_log_index": 5, + "log": [ + [ + "Player1 ended the movement action" + ], + [ + "Player1 paid 5 wood and 1 gold to recruit 3 ships in the city at C2 making it Neutral" + ], + [ + "Player1 selected 1 mood token for Nationalism Advance", + "Player1 could not store 4 food" + ], + [ + "Player1 selected 1 gold for Medicine Advance" + ], + [ + "Combat round 1", + "Player1 rolled 6 (Infantry, no bonus), 6 (Infantry, no bonus), 6 (Infantry, no bonus) for combined combat value of 18 and gets 2 hits against defending units.", + "Pirates rolled 6 (Infantry, no bonus), 6 (Infantry, no bonus) for combined combat value of 12 and gets 2 hits against attacking units.", + "Player1 gained 2 gold for destroying Pirate Ships", + "Player1 has to remove 2 of their attacking units", + "Player1 removed 2 ships", + "Pirates has to remove all of their defending units", + "Pirates removed 2 ships", + "Attacker wins", + "Player1 could not store 2 gold" + ], + [ + "Player1 gained 1 culture token for fighting the Pirates" + ] + ], + "undo_limit": 5, + "actions_left": 1, + "successful_cultural_influence": false, + "round": 6, + "age": 1, + "messages": [ + "The game has started" + ], + "dice_roll_outcomes": [ + 1, + 1, + 10, + 10, + 10 + ], + "dice_roll_log": [ + 10, + 10, + 10, + 10, + 10 + ], + "dropped_players": [], + "wonders_left": [], + "wonder_amount_left": 1 +} \ No newline at end of file diff --git a/server/tests/test_games/remove_casualties_attacker.outcome.json b/server/tests/test_games/remove_casualties_attacker.outcome.json index 5b4a58d8..fbb3a741 100644 --- a/server/tests/test_games/remove_casualties_attacker.outcome.json +++ b/server/tests/test_games/remove_casualties_attacker.outcome.json @@ -45,6 +45,7 @@ "player_index": 0, "request": { "SelectUnits": { + "player": 0, "choices": [ 0, 1, diff --git a/server/tests/test_games/remove_casualties_attacker.outcome1.json b/server/tests/test_games/remove_casualties_attacker.outcome1.json index 5c41d993..4e78cac3 100644 --- a/server/tests/test_games/remove_casualties_attacker.outcome1.json +++ b/server/tests/test_games/remove_casualties_attacker.outcome1.json @@ -233,6 +233,7 @@ "player_index": 0, "request": { "SelectUnits": { + "player": 0, "choices": [ 0, 1, diff --git a/server/tests/test_games/ship_combat.json b/server/tests/test_games/ship_combat.json index e049dd27..d7ef1ce2 100644 --- a/server/tests/test_games/ship_combat.json +++ b/server/tests/test_games/ship_combat.json @@ -211,6 +211,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/ship_combat.outcome.json b/server/tests/test_games/ship_combat.outcome.json index dea57bd9..0d18a402 100644 --- a/server/tests/test_games/ship_combat.outcome.json +++ b/server/tests/test_games/ship_combat.outcome.json @@ -42,6 +42,7 @@ "player_index": 0, "request": { "SelectUnits": { + "player": 0, "choices": [ 2, 22, @@ -259,6 +260,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/ship_combat.outcome1.json b/server/tests/test_games/ship_combat.outcome1.json index 353a551f..59fe5015 100644 --- a/server/tests/test_games/ship_combat.outcome1.json +++ b/server/tests/test_games/ship_combat.outcome1.json @@ -179,6 +179,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { @@ -281,6 +307,7 @@ "player_index": 0, "request": { "SelectUnits": { + "player": 0, "choices": [ 2, 22, diff --git a/server/tests/test_games/ship_combat_war_ships.json b/server/tests/test_games/ship_combat_war_ships.json index 7a2091e2..83c1d1ab 100644 --- a/server/tests/test_games/ship_combat_war_ships.json +++ b/server/tests/test_games/ship_combat_war_ships.json @@ -214,6 +214,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/ship_combat_war_ships.outcome.json b/server/tests/test_games/ship_combat_war_ships.outcome.json index 0f4518d8..22afb29f 100644 --- a/server/tests/test_games/ship_combat_war_ships.outcome.json +++ b/server/tests/test_games/ship_combat_war_ships.outcome.json @@ -236,6 +236,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 2 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/trade_routes.json b/server/tests/test_games/trade_routes.json index 2135283a..047303bb 100644 --- a/server/tests/test_games/trade_routes.json +++ b/server/tests/test_games/trade_routes.json @@ -20,21 +20,18 @@ }, "cities": [ { - "city_pieces": {}, + "city_pieces": { + "market": 0 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, "position": "D6" }, { - "city_pieces": {}, - "mood_state": "Happy", - "activations": 0, - "angry_activation": false, - "position": "C6" - }, - { - "city_pieces": {}, + "city_pieces": { + "market": 0 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, @@ -151,6 +148,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 1 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/trade_routes.outcome.json b/server/tests/test_games/trade_routes.outcome.json index 342c084b..7554a145 100644 --- a/server/tests/test_games/trade_routes.outcome.json +++ b/server/tests/test_games/trade_routes.outcome.json @@ -9,6 +9,7 @@ "wood": 1, "ore": 1, "ideas": 1, + "gold": 1, "mood_tokens": 1 }, "resource_limit": { @@ -20,21 +21,18 @@ }, "cities": [ { - "city_pieces": {}, + "city_pieces": { + "market": 0 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, "position": "D6" }, { - "city_pieces": {}, - "mood_state": "Happy", - "activations": 0, - "angry_activation": false, - "position": "C6" - }, - { - "city_pieces": {}, + "city_pieces": { + "market": 0 + }, "mood_state": "Happy", "activations": 0, "angry_activation": false, @@ -151,6 +149,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 1 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { @@ -415,7 +439,10 @@ ], [ "It's Player2's turn", - "Ship at E7 traded with city at C6Settler at E7 traded with city at E6 - Total reward is 2 food" + "Player1 gains 1 gold for using a Market in a trade route", + "Ship at E7 traded with city at D6", + "Settler at E7 traded with city at E6", + "Total reward is 2 food" ], [ "Round 3/3", diff --git a/server/tests/test_games/trade_routes_unit_test.json b/server/tests/test_games/trade_routes_unit_test.json index a2f1b1b6..12180469 100644 --- a/server/tests/test_games/trade_routes_unit_test.json +++ b/server/tests/test_games/trade_routes_unit_test.json @@ -37,6 +37,13 @@ "angry_activation": false, "position": "C6" }, + { + "city_pieces": {}, + "mood_state": "Happy", + "activations": 0, + "angry_activation": false, + "position": "B6" + }, { "city_pieces": {}, "mood_state": "Happy", @@ -120,11 +127,6 @@ "unit_type": "Infantry", "id": 4 }, - { - "position": "E7", - "unit_type": "Settler", - "id": 5 - }, { "position": "E8", "unit_type": "Infantry", @@ -133,22 +135,18 @@ { "position": "E7", "unit_type": "Ship", - "id": 7 - }, - { - "position": "E7", - "unit_type": "Ship", - "id": 8 + "id": 7, + "carried_units": [ + { + "unit_type": "Settler", + "id": 8 + } + ] }, { - "position": "E7", + "position": "A7", "unit_type": "Ship", "id": 9 - }, - { - "position": "E7", - "unit_type": "Ship", - "id": 10 } ], "civilization": "test1", @@ -169,6 +167,43 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 1 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [ + { + "position": "C7", + "unit_type": "Ship", + "id": 6 + }, + { + "position": "A8", + "unit_type": "Ship", + "id": 7 + } + ], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { @@ -185,6 +220,10 @@ "A7", "Water" ], + [ + "A8", + "Water" + ], [ "B2", "Unexplored" @@ -207,7 +246,7 @@ ], [ "B7", - "Barren" + "Unexplored" ], [ "C2", @@ -231,7 +270,7 @@ ], [ "C7", - "Unexplored" + "Water" ], [ "C8", diff --git a/server/tests/test_games/trade_routes_with_currency.json b/server/tests/test_games/trade_routes_with_currency.json index d80ee4ca..9028ca08 100644 --- a/server/tests/test_games/trade_routes_with_currency.json +++ b/server/tests/test_games/trade_routes_with_currency.json @@ -152,6 +152,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 1 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/trade_routes_with_currency.outcome.json b/server/tests/test_games/trade_routes_with_currency.outcome.json index cb0fb714..3ad56668 100644 --- a/server/tests/test_games/trade_routes_with_currency.outcome.json +++ b/server/tests/test_games/trade_routes_with_currency.outcome.json @@ -189,6 +189,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 1 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { diff --git a/server/tests/test_games/trade_routes_with_currency.outcome1.json b/server/tests/test_games/trade_routes_with_currency.outcome1.json index c1d4db0c..46bc628e 100644 --- a/server/tests/test_games/trade_routes_with_currency.outcome1.json +++ b/server/tests/test_games/trade_routes_with_currency.outcome1.json @@ -153,6 +153,32 @@ "event_victory_points": 0.0, "wonder_cards": [], "next_unit_id": 1 + }, + { + "name": null, + "id": 2, + "resources": {}, + "resource_limit": { + "food": 2, + "wood": 7, + "ore": 7, + "ideas": 7, + "gold": 7 + }, + "cities": [], + "units": [], + "civilization": "Pirates", + "active_leader": null, + "available_leaders": [], + "advances": [], + "unlocked_special_advance": [], + "wonders_build": [], + "incident_tokens": 3, + "completed_objectives": [], + "captured_leaders": [], + "event_victory_points": 0.0, + "wonder_cards": [], + "next_unit_id": 9 } ], "map": { @@ -461,7 +487,10 @@ "Round 3/3" ], [ - "Player2 selected trade routesShip at E7 traded with city at C6Settler at E7 traded with city at E6 - Total reward is 1 food and 1 gold" + "Player2 selected trade routes", + "Ship at E7 traded with city at C6", + "Settler at E7 traded with city at E6", + "Total reward is 1 food and 1 gold" ] ], "undo_limit": 1,