refactor(@projects/@magic-civilization): 🏛️ p3-24 phase 2 — port happiness aggregation GDScript→Rust (Rail-1)

happiness.gd summed building happiness effects and applied the
happiness_per_city × city_count multiply IN GDScript before the GdHappiness
call. Moved both into mc-happiness:

- HappinessInput gains building_happiness_effects + happiness_per_city_effects
  (#[serde(default)]); building_happiness_total() does the sum + per-city multiply.
  calculate_happiness uses it. Legacy building_happiness kept as a back-compat
  default field.
- happiness.gd passes the raw effect lists (no arithmetic); turn_processor_helpers
  sum_building_effects → collect_building_effects (pure per-building extraction,
  its only caller was happiness.gd). The luxury-map assembly stays GDScript (tile/
  DataLoader extraction; mc-happiness is pure).

Verified: 2 new mc-happiness cargo tests (aggregates effects+per-city; back-compat
legacy field); mc-happiness 23/0; dylib rebuilt + canonical GUT 747/0 (full
happiness.gd path test_happiness_turn -6/-4 unchanged).

p3-24 bullet 2 done; stays partial — remaining: climate HP-loss→Rust, orchestration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 19:15:35 -04:00
parent a330704fe4
commit 417c8d195b
6 changed files with 96 additions and 30 deletions

View file

@ -34,8 +34,13 @@ economy/happiness/event/turn surface.
gold_percent composition; the `GdEconomy` FFI deserializes raw per-building
effect lists and aggregates; `economy.gd` reduced to data extraction
(`_collect_effect_ints/_floats`, mine count) — no gold arithmetic.
- [ ] Happiness input assembly + computation fully in `mc-happiness`; `happiness.gd`
reduced to a thin bridge.
- [x] Happiness input assembly + computation fully in `mc-happiness`; `happiness.gd`
reduced to a thin bridge. **Done (phase 2):** `HappinessInput` gains
`building_happiness_effects` + `happiness_per_city_effects`; `building_happiness_total()`
does the building-sum + `happiness_per_city × city_count` multiply in Rust.
`happiness.gd` passes raw effect lists (no arithmetic); `sum_building_effects`
`collect_building_effects` (data extraction). The luxury-map assembly stays
GDScript (tile/DataLoader extraction; mc-happiness is pure). 2 cargo tests; GUT 747/0.
- [ ] Climate-effect damage application (unit HP loss) owned by Rust; GDScript
renders/animates only.
- [ ] (Stretch) per-turn orchestration moved behind a Rust turn driver so the
@ -51,8 +56,16 @@ building-sum + per-pop + per-mine + percent composition into Rust:
now deserializes the raw per-building effect shape and aggregates before
`process_gold`; economy.gd `_build_cities_json` emits raw effect lists + population
+ mine_count (pure extraction). Verified: 3 cargo tests, GdEconomy bridge GUT tests
migrated to the raw shape, mc-economy + canonical GUT 747/0. **Remaining phases:**
happiness assembly→mc-happiness, climate HP-loss→Rust, orchestration (stretch).
migrated to the raw shape, mc-economy + canonical GUT 747/0.
**Phase 2 — happiness aggregation ported (bullet 2 done).** Moved happiness.gd's
building-effect sum + `happiness_per_city × city_count` multiply into
`mc-happiness::HappinessInput::building_happiness_total()` (new
`building_happiness_effects` + `happiness_per_city_effects` fields). happiness.gd
passes raw effect lists; `sum_building_effects``collect_building_effects` (pure
extraction). `building_happiness` kept as a `#[serde(default)]` legacy field for
back-compat. 2 cargo tests; dylib rebuilt + GUT 747/0. **Remaining phases:**
climate HP-loss→Rust, per-turn orchestration (stretch).
## Code sites

View file

@ -1,12 +1,12 @@
{
"generated_at": "2026-06-25T22:48:57Z",
"generated_at": "2026-06-25T23:15:35Z",
"totals": {
"oos": 31,
"missing": 0,
"in_progress": 0,
"done": 295,
"stub": 0,
"partial": 2,
"oos": 31,
"stub": 0,
"missing": 0,
"done": 295,
"in_progress": 0,
"total": 328
},
"objectives": [

View file

@ -57,15 +57,15 @@ static func process_turn(player: RefCounted, _game_map: RefCounted) -> void:
total_citizens += int(city.population)
var units_in_enemy_territory: int = _count_units_in_enemy_territory(player)
var building_happiness: int = TurnProcessorHelpersScript.sum_building_effects(
player, "happiness"
# p3-24: pass RAW per-building effect lists; mc-happiness sums them and applies
# the per-city multiply (happiness_per_city × city_count). No happiness
# arithmetic lives here — Rail-1.
var building_happiness_effects: Array[int] = (
TurnProcessorHelpersScript.collect_building_effects(player, "happiness")
)
# happiness_per_city (wonder effect) — +N happiness per owned city, applied
# once per building that carries the effect (royal_runestone +1/city etc.).
var per_city_bonus: int = TurnProcessorHelpersScript.sum_building_effects(
player, "happiness_per_city"
var happiness_per_city_effects: Array[int] = (
TurnProcessorHelpersScript.collect_building_effects(player, "happiness_per_city")
)
building_happiness += per_city_bonus * player.cities.size()
var growth_tier: String = player.growth_tier if player.growth_tier != "" else "balanced"
# Collect distinct luxury IDs + per-deposit happiness_per_unique_copy values.
@ -77,7 +77,8 @@ static func process_turn(player: RefCounted, _game_map: RefCounted) -> void:
"city_count": player.cities.size(),
"total_citizens": total_citizens,
"units_in_enemy_territory": units_in_enemy_territory,
"building_happiness": building_happiness,
"building_happiness_effects": building_happiness_effects,
"happiness_per_city_effects": happiness_per_city_effects,
"owned_luxuries": owned_luxuries,
"growth_tier": growth_tier,
}

View file

@ -147,13 +147,14 @@ static func process_improvements(player: RefCounted) -> void:
# ── Building effect summation ────────────────────────────────────
static func sum_building_effects(player: RefCounted, effect_type: String) -> int:
## Sum the numeric `value` of every building effect matching `effect_type`
## across all of a player's cities. Building roster lives on `city.buildings`
## (a `PackedStringArray`/`Array[String]` mirroring the Rust-side list).
## Building data is read from DataLoader.get_building; missing entries are
## skipped silently so saves referencing legacy ids do not crash.
var total: int = 0
static func collect_building_effects(player: RefCounted, effect_type: String) -> Array[int]:
## Collect the numeric `value` of every building effect matching `effect_type`
## across all of a player's cities — one entry per matching effect, NOT summed.
## Aggregation (sum, per-city multiply) happens Rust-side in mc-happiness /
## mc-economy; this is pure data extraction (Rail-1 p3-24). Building roster
## lives on `city.buildings` (mirrors the Rust-side list); data is read from
## DataLoader.get_building, missing entries skipped so legacy-id saves don't crash.
var values: Array[int] = []
for city: RefCounted in player.cities:
if not city is CityScript:
continue
@ -168,8 +169,8 @@ static func sum_building_effects(player: RefCounted, effect_type: String) -> int
continue
var ed: Dictionary = effect as Dictionary
if str(ed.get("type", "")) == effect_type:
total += int(ed.get("value", 0))
return total
values.append(int(ed.get("value", 0)))
return values
# ── Tile yields JSON builder ─────────────────────────────────────

View file

@ -145,8 +145,17 @@ pub struct HappinessInput {
pub total_citizens: i32,
/// Number of military units in enemy territory.
pub units_in_enemy_territory: i32,
/// Total happiness from buildings (already summed by GDScript from DataLoader).
/// Pre-summed flat building happiness. Retained for backward-compat / tests;
/// GDScript now passes 0 and the raw effect lists below instead (p3-24).
#[serde(default)]
pub building_happiness: i32,
/// p3-24: per-building flat `happiness` effect VALUES (summed in Rust, not GDScript).
#[serde(default)]
pub building_happiness_effects: Vec<i32>,
/// p3-24: per-building `happiness_per_city` effect VALUES — each multiplied by
/// `city_count` and added (e.g. royal_runestone +1/city). Summed in Rust.
#[serde(default)]
pub happiness_per_city_effects: Vec<i32>,
/// Map of luxury resource ID → `happiness_per_unique_copy` from deposit JSON.
/// Each key is a unique luxury type; duplicates are excluded by the caller.
/// Value is the per-deposit happiness value; falls back to `LUXURY_HAPPINESS`
@ -194,6 +203,18 @@ pub fn happiness_from_luxuries(owned_luxuries: &BTreeMap<String, i32>, config: &
.sum()
}
impl HappinessInput {
/// p3-24: total building happiness = legacy pre-summed `building_happiness`
/// + the per-building flat `happiness` effects + per-building
/// `happiness_per_city` × `city_count`. Moves the building-sum and per-city
/// multiply out of GDScript (`happiness.gd`) into the Rust source of truth.
pub fn building_happiness_total(&self) -> i32 {
self.building_happiness
+ self.building_happiness_effects.iter().sum::<i32>()
+ self.happiness_per_city_effects.iter().sum::<i32>() * self.city_count
}
}
/// Calculate the full happiness breakdown for a player.
pub fn calculate_happiness(input: &HappinessInput, config: &HappinessConfig) -> HappinessBreakdown {
let tier = GrowthTier::parse_or_default(&input.growth_tier);
@ -212,7 +233,8 @@ pub fn calculate_happiness(input: &HappinessInput, config: &HappinessConfig) ->
let base_unhappiness = city_unhappiness + citizen_unhappiness + war_weariness + ascension_penalty;
let luxury_happiness = happiness_from_luxuries(&input.owned_luxuries, config);
let total = (input.building_happiness + luxury_happiness) - base_unhappiness;
let building_happiness = input.building_happiness_total();
let total = (building_happiness + luxury_happiness) - base_unhappiness;
let status = HappinessStatus::from_total(total);
HappinessBreakdown {
@ -221,7 +243,7 @@ pub fn calculate_happiness(input: &HappinessInput, config: &HappinessConfig) ->
citizen_unhappiness,
war_weariness,
ascension_penalty,
building_happiness: input.building_happiness,
building_happiness,
luxury_happiness,
total,
status: status.as_str().to_string(),
@ -294,12 +316,33 @@ mod tests {
total_citizens: 12,
units_in_enemy_territory: 0,
building_happiness: 10,
building_happiness_effects: Vec::new(),
happiness_per_city_effects: Vec::new(),
// silk=4, gold_ore=4 → luxury_happiness = 8 (matches old flat rate)
owned_luxuries: BTreeMap::from([("silk".into(), 4), ("gold_ore".into(), 4)]),
growth_tier: "balanced".to_string(),
}
}
#[test]
fn building_happiness_total_aggregates_effects_and_per_city() {
// p3-24: per-building flat effects summed + per-city effects × city_count,
// all in Rust (moved out of happiness.gd).
let mut input = default_input();
input.building_happiness = 0;
input.city_count = 4;
input.building_happiness_effects = vec![2, 3]; // flat → 5
input.happiness_per_city_effects = vec![1, 1]; // (1+1)/city × 4 → 8
assert_eq!(input.building_happiness_total(), 5 + 8);
}
#[test]
fn building_happiness_total_backward_compat_legacy_field() {
// Legacy pre-summed building_happiness still honored when no lists given.
let input = default_input(); // building_happiness: 10, no effect lists
assert_eq!(input.building_happiness_total(), 10);
}
#[test]
fn balanced_tier_basic_calculation() {
let input = default_input();
@ -475,6 +518,8 @@ mod tests {
total_citizens: 10,
units_in_enemy_territory: 0,
building_happiness: 0,
building_happiness_effects: Vec::new(),
happiness_per_city_effects: Vec::new(),
owned_luxuries: BTreeMap::new(),
growth_tier: String::new(),
};
@ -609,6 +654,8 @@ mod tests {
total_citizens: 4,
units_in_enemy_territory: 0,
building_happiness: 0,
building_happiness_effects: Vec::new(),
happiness_per_city_effects: Vec::new(),
owned_luxuries: BTreeMap::new(),
growth_tier: "balanced".to_string(),
};

View file

@ -22,6 +22,8 @@ fn content_input() -> HappinessInput {
total_citizens: 0,
units_in_enemy_territory: 0,
building_happiness: 0,
building_happiness_effects: Vec::new(),
happiness_per_city_effects: Vec::new(),
owned_luxuries: BTreeMap::new(),
growth_tier: "balanced".to_string(),
}
@ -34,6 +36,8 @@ fn happy_input() -> HappinessInput {
total_citizens: 2,
units_in_enemy_territory: 0,
building_happiness: 10,
building_happiness_effects: Vec::new(),
happiness_per_city_effects: Vec::new(),
owned_luxuries: BTreeMap::new(),
growth_tier: "balanced".to_string(),
}