feat(@projects/@magic-civilization): ✨ add strategic resource validation logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c9084a1099
commit
601226d2a2
6 changed files with 181 additions and 5 deletions
|
|
@ -31,6 +31,25 @@ const MILITARY_COMBAT_TYPES: Array[String] = [
|
|||
"melee", "ranged", "cavalry", "siege",
|
||||
]
|
||||
const INF_DISTANCE: int = 1 << 30
|
||||
## Dominance push thresholds — when all three conditions hold simultaneously
|
||||
## the AI commits to a capital assault rather than holding at parity.
|
||||
## DOMINANCE_FACTOR: own_mil must be >= this multiple of enemy_mil to commit.
|
||||
const DOMINANCE_FACTOR: float = 2.0
|
||||
## Gold floor required before the AI spends on a rush-buy assault unit.
|
||||
## Prevents committing on a turn when the treasury cannot afford the purchase.
|
||||
const DOMINANCE_GOLD_FLOOR: int = 200
|
||||
## Hex radius within which the AI will rush-buy an assault unit to reinforce
|
||||
## the push force when it is thin (fewer than DOMINANCE_PUSH_FLOOR units
|
||||
## within RUSH_BUY_PROXIMITY_HEX of the enemy capital).
|
||||
const RUSH_BUY_PROXIMITY_HEX: int = 8
|
||||
## Minimum push-force size before a rush-buy is triggered near the capital.
|
||||
## Rush-buy fires when our units within RUSH_BUY_PROXIMITY_HEX < this value.
|
||||
const DOMINANCE_PUSH_FLOOR: int = 2
|
||||
## HP fraction below which retreat is still blocked when the unit is adjacent
|
||||
## to the enemy capital — prevents bleeding turns healing beside the objective.
|
||||
## Distinct from RETREAT_HP_FRACTION (general retreat threshold) so each can
|
||||
## be tuned independently.
|
||||
const CAPITAL_SIEGE_NO_RETREAT_HP: float = 0.0
|
||||
|
||||
|
||||
## Generate this turn's actions for `player`. Returns an Array of action
|
||||
|
|
@ -69,6 +88,40 @@ static func process_player(player: RefCounted) -> Array:
|
|||
_rush_buy_defenders(player, {"spawn_city": null,
|
||||
"threatens_city": true, "count": 3, "total_count": 3})
|
||||
|
||||
# Dominance-window rush-buy: when we have 2× enemy military AND sufficient
|
||||
# gold AND an enemy capital exists, reinforce the push force if it is thin.
|
||||
# Strategic gate is respected via _pick_buildable_military_unit_id.
|
||||
var enemy_mil_total: int = 0
|
||||
for eu: Variant in enemy_units:
|
||||
if int(eu.get("attack")) > 0 or int(eu.get("ranged_attack")) > 0:
|
||||
enemy_mil_total += 1
|
||||
var in_dominance_window: bool = (
|
||||
mil_now >= int(DOMINANCE_FACTOR * maxi(1, enemy_mil_total))
|
||||
and player.gold >= DOMINANCE_GOLD_FLOOR
|
||||
and not enemy_city_positions.is_empty()
|
||||
)
|
||||
if in_dominance_window:
|
||||
var nearest_cap: Vector2i = _nearest_enemy_capital(player)
|
||||
if nearest_cap != Vector2i(-1, -1):
|
||||
var push_count: int = _count_units_within(
|
||||
player, nearest_cap, RUSH_BUY_PROXIMITY_HEX
|
||||
)
|
||||
if push_count < DOMINANCE_PUSH_FLOOR and not player.cities.is_empty():
|
||||
var spawn_city: RefCounted = player.cities[0] as RefCounted
|
||||
var assault_id: String = _pick_buildable_military_unit_id(
|
||||
spawn_city, player
|
||||
)
|
||||
if not assault_id.is_empty() and player.gold >= 50:
|
||||
var nu: RefCounted = UnitScript.new(
|
||||
assault_id, player.index, spawn_city.position
|
||||
)
|
||||
nu.id = "dom_%d_%d" % [GameState.turn_number, push_count]
|
||||
nu.display_name = assault_id.capitalize()
|
||||
player.units.append(nu)
|
||||
GameState.get_primary_layer().get("units", []).append(nu)
|
||||
player.gold -= 50
|
||||
EventBus.unit_created.emit(nu, player.index)
|
||||
|
||||
# Units: founders first (expansion), then military.
|
||||
for idx: int in player.units.size():
|
||||
var unit: Variant = player.units[idx]
|
||||
|
|
@ -397,10 +450,15 @@ static func _decide_military_action(
|
|||
# Retreat if wounded — but not while committed to a capture push (enemy
|
||||
# city within 4). Letting p1 retreat from a stalled siege is why 0 captures
|
||||
# across 3 seeds despite 10x kill ratio in the field.
|
||||
# CAPITAL_SIEGE_NO_RETREAT_HP=0.0 fully suppresses retreat when adjacent
|
||||
# to objective (city_dist<=1) — unit fights to zero rather than healing.
|
||||
var city_dist: int = INF_DISTANCE
|
||||
if not enemy_city_positions.is_empty():
|
||||
city_dist = _min_distance(unit.position, enemy_city_positions)
|
||||
if hp_frac <= RETREAT_HP_FRACTION and nearest_enemy != null and city_dist > 4:
|
||||
var retreat_hp_threshold: float = (
|
||||
CAPITAL_SIEGE_NO_RETREAT_HP if city_dist <= 1 else RETREAT_HP_FRACTION
|
||||
)
|
||||
if hp_frac <= retreat_hp_threshold and nearest_enemy != null and city_dist > 4:
|
||||
return _move_action(
|
||||
idx,
|
||||
unit.position,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
extends GutTest
|
||||
## Tests for tile_info_panel collectibles display logic.
|
||||
## Uses the static build_collectibles_text helper — no scene tree required.
|
||||
## Tests for tile_info_panel: collectibles display logic + hover show/hide state.
|
||||
|
||||
const TileInfoPanelScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/tile_info_panel.gd"
|
||||
)
|
||||
const WorldMapHoverScript: GDScript = preload(
|
||||
"res://engine/scenes/world_map/world_map_hover.gd"
|
||||
)
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
|
|
@ -64,3 +66,43 @@ func test_dataloader_get_biome_collectibles_returns_array() -> void:
|
|||
func test_unknown_biome_returns_empty_array() -> void:
|
||||
var entries: Array = DataLoader.get_biome_collectibles("void_realm_does_not_exist")
|
||||
assert_eq(entries.size(), 0, "unknown biome must return empty array")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hover show/hide state tests — panel instantiated as a child for _ready().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func test_panel_starts_hidden() -> void:
|
||||
var panel: PanelContainer = TileInfoPanelScript.new()
|
||||
add_child(panel)
|
||||
assert_false(panel.visible, "tile_info_panel must start hidden")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_hide_panel_resets_current_axial() -> void:
|
||||
var panel: PanelContainer = TileInfoPanelScript.new()
|
||||
add_child(panel)
|
||||
panel._current_axial = Vector2i(3, 4)
|
||||
panel.hide_panel()
|
||||
assert_eq(
|
||||
panel._current_axial,
|
||||
Vector2i(-9999, -9999),
|
||||
"hide_panel must reset _current_axial sentinel"
|
||||
)
|
||||
assert_false(panel.visible, "panel must be hidden after hide_panel()")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_show_tile_same_axial_is_noop() -> void:
|
||||
var panel: PanelContainer = TileInfoPanelScript.new()
|
||||
add_child(panel)
|
||||
panel._current_axial = Vector2i(2, 5)
|
||||
panel.show_tile({}, Vector2i(2, 5))
|
||||
assert_false(panel.visible, "show_tile with duplicate axial must not make panel visible")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_hover_interval_constant_is_20hz() -> void:
|
||||
var interval: float = WorldMapHoverScript.HOVER_INTERVAL_SEC
|
||||
assert_almost_eq(interval, 0.05, 0.001, "HOVER_INTERVAL_SEC must be 0.05 s (20 Hz)")
|
||||
|
|
|
|||
|
|
@ -1748,6 +1748,8 @@ impl GdGameState {
|
|||
capital_position: Some((city_col, city_row)),
|
||||
culture_total: 0,
|
||||
arcane_lore_pop_deducted: false,
|
||||
traded_luxuries: Default::default(),
|
||||
relations: Default::default(),
|
||||
});
|
||||
pi as i64
|
||||
}
|
||||
|
|
@ -1855,6 +1857,8 @@ impl GdGameState {
|
|||
capital_position: None,
|
||||
culture_total: 0,
|
||||
arcane_lore_pop_deducted: false,
|
||||
traded_luxuries: Default::default(),
|
||||
relations: Default::default(),
|
||||
});
|
||||
pi as i64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ fn dense_bench_state(seed: u64, map_size: i32) -> GameState {
|
|||
capital_position: None,
|
||||
culture_total: 0,
|
||||
arcane_lore_pop_deducted: false,
|
||||
traded_luxuries: Default::default(),
|
||||
relations: Default::default(),
|
||||
}],
|
||||
grid: Some(grid),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
use mc_ai::evaluator::ScoringWeights;
|
||||
use mc_city::CityState;
|
||||
use mc_core::grid::GridState;
|
||||
use mc_trade::relation::RelationState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
/// Top-level headless game state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -56,6 +57,16 @@ pub struct PlayerState {
|
|||
pub culture_total: i64,
|
||||
/// One-time flag: has the arcane-lore population cost already been paid?
|
||||
pub arcane_lore_pop_deducted: bool,
|
||||
/// Luxury IDs received via active trade agreements this turn.
|
||||
/// Merged into `owned_luxuries` before happiness calculation.
|
||||
/// Cleared and rebuilt each turn by `process_trade_phase`.
|
||||
#[serde(default)]
|
||||
pub traded_luxuries: BTreeSet<String>,
|
||||
/// Diplomatic relation states keyed by canonical pair `(min_idx, max_idx)`.
|
||||
/// Shared across all players — only player_index 0 carries the authoritative
|
||||
/// copy; `process_trade_phase` syncs it from the ledger.
|
||||
#[serde(default)]
|
||||
pub relations: BTreeMap<(u8, u8), RelationState>,
|
||||
}
|
||||
|
||||
/// Ambient fauna-presence pressure accumulated per city.
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ use crate::spatial_index::LairIndex;
|
|||
use mc_city::CityState;
|
||||
use mc_combat::{CombatBonuses, CombatParams, CombatResolver, CombatType, UnitStats};
|
||||
use mc_combat::CombatOutcome;
|
||||
use mc_trade::{advance_relations, evaluate_trades, PlayerTradeInput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
// ── PvP / Siege Constants ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -179,6 +180,11 @@ impl TurnProcessor {
|
|||
state.turn += 1;
|
||||
let mut result = TurnResult::default();
|
||||
|
||||
// Phase 0: trade — evaluate luxury swaps and advance relation states.
|
||||
// Must run before economy/happiness so traded_luxuries are available
|
||||
// when GDScript calls happiness.gd this turn.
|
||||
self.process_trade_phase(state);
|
||||
|
||||
// Phase 1-4: per-player economy, production, founding, unit spawn,
|
||||
// culture accumulation, science/tech progression.
|
||||
let n_players = state.players.len();
|
||||
|
|
@ -261,6 +267,59 @@ impl TurnProcessor {
|
|||
result
|
||||
}
|
||||
|
||||
// ── Phase 0: Trade ────────────────────────────────────────────────────
|
||||
|
||||
/// Evaluate luxury trades and advance relation states for all player pairs.
|
||||
/// Populates each player's `traded_luxuries` so GDScript happiness.gd can
|
||||
/// include them when calling `calculate_happiness`.
|
||||
fn process_trade_phase(&self, state: &mut GameState) {
|
||||
// Collect per-player inputs. tile_luxuries from strategic_axes proxy
|
||||
// (real game populates this from owned tile collectibles via GDScript).
|
||||
let inputs: Vec<PlayerTradeInput> = state
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let willingness = *p.strategic_axes.get("trade_willingness").unwrap_or(&5);
|
||||
PlayerTradeInput {
|
||||
player_index: p.player_index,
|
||||
tile_luxuries: p.traded_luxuries.iter().cloned().collect(), // bench uses stub
|
||||
trade_willingness: willingness,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Pull relations from player 0 (canonical store).
|
||||
let mut relations = state
|
||||
.players
|
||||
.first()
|
||||
.map(|p| p.relations.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Collect combat pairs from the last turn's PvP log (none in bench stub).
|
||||
let combat_pairs: BTreeSet<(u8, u8)> = BTreeSet::new();
|
||||
|
||||
let all_pairs: Vec<(u8, u8)> = {
|
||||
let n = state.players.len() as u8;
|
||||
let mut pairs = Vec::new();
|
||||
for a in 0..n {
|
||||
for b in (a + 1)..n {
|
||||
pairs.push((a, b));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
};
|
||||
|
||||
// Advance relation states.
|
||||
let ledger = evaluate_trades(&inputs, &relations, state.turn);
|
||||
advance_relations(&mut relations, &ledger, &combat_pairs, &all_pairs);
|
||||
|
||||
// Write traded_luxuries per player from ledger.
|
||||
for player in &mut state.players {
|
||||
player.traded_luxuries = ledger.incoming_luxuries(player.player_index);
|
||||
player.relations = relations.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 1: Economy ───────────────────────────────────────────────────
|
||||
|
||||
fn process_economy(&self, state: &mut GameState, pi: usize) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue