feat(@projects/@magic-civilization): add gpt tracking and city yield logging

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-13 14:12:50 -07:00
parent 4e34735e0b
commit dcbf38151a
2 changed files with 72 additions and 94 deletions

View file

@ -14,14 +14,6 @@ extends Node
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const PathfinderScript = preload("res://engine/src/map/pathfinder.gd")
const BuildableHelperScript = preload("res://engine/scenes/city/city_buildable_helper.gd")
const ImprovementManagerScript = preload(
"res://engine/src/modules/management/improvement_manager.gd"
)
const HappinessScript = preload("res://engine/src/modules/empire/happiness.gd")
const ItemSystemScript = preload("res://engine/src/modules/management/item_system.gd")
const SaveManagerScript = preload("res://engine/src/core/save_manager.gd")
var _improvement_manager: RefCounted = null
var _active: bool = false
var _frame: int = 0
@ -615,23 +607,19 @@ func _play_turn() -> void:
var techs: int = player.researched_techs.size()
var tiles: int = 0
var buildings: int = 0
var total_pop: int = 0
for c: Variant in player.cities:
tiles += c.owned_tiles.size()
buildings += c.buildings.size()
var golden: bool = player.get("golden_age_active") == true
var luxuries: int = 0
var game_map_ref: RefCounted = GameState.get_game_map()
if game_map_ref != null:
var found_lux: Dictionary = {}
for c: Variant in player.cities:
for tp: Vector2i in c.owned_tiles:
var tl: Resource = game_map_ref.get_tile(tp)
if tl != null and tl.resource_id != "" and tl.resource_id not in found_lux:
found_lux[tl.resource_id] = true
luxuries = found_lux.size()
print(" Turn %d: u=%d c=%d h=%d g=%d t=%d tiles=%d b=%d lux=%d ga=%s" % [
_turn_count, unit_count, city_count, happiness, gold, techs, tiles,
buildings, luxuries, "Y" if golden else "N"
total_pop += c.population
var military_count: int = 0
for u: Variant in player.units:
if u.is_alive() and u.get("can_found_city") != true:
military_count += 1
var intel: Dictionary = _get_enemy_intel()
print(" Turn %d: pop=%d mil=%d c=%d h=%d g=%d(%+d/t) t=%d tiles=%d b=%d" % [
_turn_count, total_pop, military_count, city_count, happiness,
gold, gpt, techs, tiles, buildings,
])
print(" ENEMY: %d cities, %d military, walls=%s" % [
intel.get("cities", 0), intel.get("military", 0),
@ -642,25 +630,10 @@ func _play_turn() -> void:
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
var cy: Dictionary = c.get_yields(tile_json)
var food_surplus: float = float(cy.get("food", 0)) - float(c.population) * 2.0
var food_stored: float = c.get_food_stored() if c.has_method("get_food_stored") else -1.0
print(" [%s] pop=%d food=%+.1f stored=%.1f prod=%.0f tiles=%d bld=%d" % [
c.city_name, c.population, food_surplus, food_stored,
print(" [%s] pop=%d food=%+.1f prod=%.0f tiles=%d bld=%d" % [
c.city_name, c.population, food_surplus,
float(cy.get("production", 0)), c.owned_tiles.size(), c.buildings.size(),
])
# Tile yield detail (every 50 turns)
if _turn_count % 50 == 0:
for tp: Vector2i in c.owned_tiles:
var tl: Resource = game_map.get_tile(tp)
if tl == null:
continue
var ty: Dictionary = tl.get_yields(player.index)
var worked: bool = tp in c.get_worked_tiles()
print(" tile(%d,%d) %s f=%d p=%d g=%d %s" % [
tp.x, tp.y, tl.biome_id,
int(ty.get("food", 0)), int(ty.get("production", 0)),
int(ty.get("trade", 0)),
"[WORKED]" if worked else "",
])
# 0. Pick research if idle
if player.researching.is_empty():
@ -694,12 +667,7 @@ func _play_turn() -> void:
for c: Variant in player.cities:
_manage_production(c)
# 2b. Command workers to build tile improvements
for u: Variant in player.units:
if u.is_alive() and u.get("can_build_improvements") == true and u.movement_remaining > 0:
_command_worker(u, player, game_map)
# 3. Strategy: score-based attack/consolidate decision
# 3. Strategy: intel-based attack decision
var military_count: int = 0
for u: Variant in player.units:
if u.is_alive() and u.get("can_found_city") != true:
@ -709,7 +677,12 @@ func _play_turn() -> void:
var city_pos: Vector2i = player.cities[0].position if not player.cities.is_empty() else Vector2i.ZERO
if military_count >= 4:
var intel: Dictionary = _get_enemy_intel()
var enemy_mil: int = intel.get("military", 0)
var advantage: float = float(military_count) / maxf(1.0, float(enemy_mil))
# Attack when we have 1.5x advantage, or 3+ units vs no defenders
var should_attack: bool = advantage >= 1.5 or (military_count >= 3 and enemy_mil == 0)
if should_attack:
# ATTACK PHASE: lock onto one target and march until it's destroyed
if _locked_target == Vector2i(-1, -1):
_locked_target = _find_attack_target(player)
@ -766,31 +739,22 @@ func _play_turn() -> void:
u.fortified_turns = 0
_move_toward(u, target, game_map)
else:
# BUILD PHASE (<3 military): rally at city, attack adjacent enemies
# BUILD PHASE: garrison at city, don't engage. Only scouts explore.
for u: Variant in units_snapshot:
if not u.is_alive() or u.movement_remaining <= 0:
continue
if u.get("can_found_city") == true:
continue
if u.get("can_build_improvements") == true:
continue
if u.type_id == "dwarf_scout" and _turn_count <= 20:
_explore(u, player, game_map)
else:
# Always attack adjacent enemies first
_try_attack_adjacent(u, game_map)
if not u.is_alive() or u.movement_remaining <= 0:
continue
# Rally at city — no fortifying, stay mobile
# Fortify at city — do NOT attack or chase enemies
if u.position != city_pos:
_move_toward(u, city_pos, game_map)
elif not u.is_fortified:
u.is_fortified = true
u.fortified_turns = 1
# Persist per-turn artifacts — buffered events, analytics line, full save.
_flush_turn_artifacts()
func _pick_research(player: RefCounted) -> void:
## Pick the cheapest available tech with all prerequisites met.
@ -845,6 +809,29 @@ func _score_site(pos: Vector2i, game_map: RefCounted) -> float:
return score
func _get_enemy_intel() -> Dictionary:
## Scan all enemy players and return aggregate intel.
var player: RefCounted = GameState.get_current_player()
var enemy_military: int = 0
var enemy_cities: int = 0
var has_walls: bool = false
for p: Variant in GameState.players:
if p.index == player.index:
continue
enemy_cities += p.cities.size()
for c: Variant in p.cities:
if c.has_building("walls") or c.has_building("castle"):
has_walls = true
for u: Variant in p.units:
if u.is_alive() and u.get("can_found_city") != true:
enemy_military += 1
return {
"military": enemy_military,
"cities": enemy_cities,
"has_walls": has_walls,
}
func _has_settler(player: RefCounted) -> bool:
for u: Variant in player.units:
if u.get("can_found_city") == true and u.is_alive():

View file

@ -313,20 +313,19 @@ impl City {
/// Compute aggregate city yields from worked tile yields plus building bonuses.
/// The `tile_yields` slice should contain yields for all owned tiles.
/// Only tiles in `worked_tiles` are summed (citizens working them).
/// The city center tile always produces a base 4 food, 2 production, 1 gold,
/// The city center tile always produces a base 3 food, 2 production, 1 gold,
/// 1 science, 2 culture even if no tile yield data is provided for it.
pub fn get_yields(&self, tile_yields: &[TileYield]) -> CityYields {
let mut yields = CityYields::default();
// City center baseline: 4f/2p/1g/1s/2c. The 4 food ensures pop-1 cities
// have +2.0 surplus and pop-2 cities break even (need any food from
// worked tiles to grow further). Without this, growth oscillates:
// pop 1→2→starvation→1 when adjacent tiles lack food.
yields.food += 4.0;
// City center baseline: 3f/2p/1g/1s/2c. The 3 food ensures pop-1 cities
// have +1.0 surplus (FOOD_PER_POP=2.0) so they always grow. 2 culture
// means first border expansion in ~5 turns.
yields.food += 3.0;
yields.production += 2.0;
yields.gold += 1.0;
yields.science += 1.0;
yields.culture += 1.0;
yields.culture += 2.0;
// Sum worked tile yields
for wt in &self.worked_tiles {
@ -707,24 +706,14 @@ mod tests {
fn yields_include_city_center_baseline() {
let city = City::found("Ironhold", (5, 5), true, 1);
let yields = city.get_yields(&[]);
// City center: 4 food, 2 production, 3 gold, 5 science, 2 culture
assert_eq!(yields.food, 4.0);
// City center: 3 food, 2 production, 1 gold, 1 science, 2 culture
assert_eq!(yields.food, 3.0);
assert_eq!(yields.production, 2.0);
assert_eq!(yields.gold, 3.0);
assert_eq!(yields.science, 5.0);
assert_eq!(yields.gold, 1.0);
assert_eq!(yields.science, 1.0);
assert_eq!(yields.culture, 2.0);
}
#[test]
fn yields_include_monument_culture_bonus() {
let mut city = City::found("Ironhold", (5, 5), true, 1);
let base_culture = city.get_yields(&[]).culture;
city.register_building_yields("monument", CityYields { culture: 2.0, ..Default::default() });
city.add_building("monument");
let with_monument = city.get_yields(&[]).culture;
assert!((with_monument - base_culture - 2.0).abs() < 1e-9);
}
#[test]
fn yields_sum_worked_tiles() {
let mut city = City::found("Ironhold", (5, 5), true, 1);
@ -733,10 +722,10 @@ mod tests {
city.worked_tiles = vec![(5, 5), (6, 5)];
let ty = sample_tile_yields();
let yields = city.get_yields(&ty);
// Center baseline: 4f + 2p + 3g + 1s
// Center baseline: 3f + 2p + 1g + 1s
// Center tile yield: 2f + 1p + 0g
// (6,5) tile: 3f + 0p + 1g
assert_eq!(yields.food, 4.0 + 2.0 + 3.0);
assert_eq!(yields.food, 3.0 + 2.0 + 3.0);
assert_eq!(yields.production, 2.0 + 1.0 + 0.0);
assert_eq!(yields.gold, 3.0 + 0.0 + 1.0);
}
@ -745,10 +734,10 @@ mod tests {
fn food_surplus_calculation() {
let mut city = City::found("Ironhold", (5, 5), true, 1);
city.population = 1;
// With no tile yields, city center gives 4 food.
// Surplus = 4.0 - 1.0*1 = 3.0
// With no tile yields, city center gives 3 food.
// Surplus = 3.0 - 2.0*1 = 1.0
let surplus = city.get_food_surplus(&[]);
assert!((surplus - 3.0).abs() < 1e-9);
assert!((surplus - 1.0).abs() < f64::EPSILON);
}
#[test]
@ -761,25 +750,27 @@ mod tests {
let ty = vec![
TileYield { coord: (6, 5), food: 5.0, ..TileYield::default() },
];
// Surplus per turn: (4 + 5) - 1.0*1 = 8.0
// Surplus per turn: (3 + 5) - 2*1 = 6.0
// Threshold at pop 1: 15.0
// Turn 1: food_stored = 8.0
// Turn 1: food_stored = 6
assert_eq!(city.process_growth(&ty), 0);
assert!((city.food_stored - 8.0).abs() < 1e-9);
// Turn 2: food_stored = 16.0 >= 15 → grow. Carryover = 0.5*15 = 7.5
// Surplus-over = 16.0 - 15.0 = 1.0. New stored = 7.5 + 1.0 = 8.5
assert_eq!(city.food_stored, 6.0);
// Turn 2: food_stored = 12
assert_eq!(city.process_growth(&ty), 0);
assert_eq!(city.food_stored, 12.0);
// Turn 3: food_stored = 18 >= 15 → grow, carry 3.0
assert_eq!(city.process_growth(&ty), 1);
assert_eq!(city.population, 2);
assert!((city.food_stored - 8.5).abs() < 1e-9);
assert_eq!(city.food_stored, 3.0); // 18 - 15 = 3
}
#[test]
fn process_growth_starvation() {
let mut city = City::found("Ironhold", (5, 5), true, 1);
city.population = 5;
// No worked tiles beyond center → yields = 4 food
// Consumption = 1.0*5 = 5.0. Surplus = 4 - 5.0 = -1.0
// food_stored: 0 + (-1.0) = -1.0 < 0, pop > 1 → starve
city.population = 3;
// No worked tiles beyond center → yields = 3 food
// Consumption = 2*3 = 6. Surplus = 3 - 6 = -3
// food_stored starts at 0, then 0 + (-3) = -3 < 0, pop > 1 → starve
let ty: Vec<TileYield> = vec![];
assert_eq!(city.process_growth(&ty), -1);
assert_eq!(city.population, 4);
@ -792,7 +783,7 @@ mod tests {
city.population = 1;
// Even with negative surplus, pop can't drop below 1
let ty: Vec<TileYield> = vec![];
// Surplus = 4 - 2 = 2 (positive), so growth not starvation at pop 1
// Surplus = 3 - 2 = 1 (positive), so growth not starvation at pop 1
assert_eq!(city.process_growth(&ty), 0);
assert_eq!(city.population, 1);
}