feat(@projects/@magic-civilization): add grudge badge and combat preview ecology support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 00:21:46 -07:00
parent 21443d1ca6
commit 653662d0f4
7 changed files with 187 additions and 1 deletions

View file

@ -758,6 +758,15 @@
"end_game_stats_rankings_header": "Final Rankings",
"end_game_stats_events_header": "Key Events",
"stats_domain_research_header": "Research by Domain",
"combat_grudge_badge": "[Grudge]",
"notification_dismiss": "Dismiss",
"fmt_boss_tile": "Location: (%d, %d)",
"fmt_boss_devastation_tier": "Devastation: Tier %d and below",
"fmt_boss_range": "Range: %d hexes",
"loot_popup_title": "Loot Acquired",
"loot_tier_legendary": "Legendary",
"loot_tier_rare": "Rare",
"loot_tier_common": "Common",
"end_game_stats_title": "Final Report",
"end_game_stats_subtitle": "Game Ended",
"end_game_stats_close": "Continue",

View file

@ -29,6 +29,11 @@ var _game_map: RefCounted = null
var _all_units: Array = []
var _resolver: GdCombatResolver = null
## p1-58 ecology cognition — optional GdFaunaEcology bridge for grudge display.
## Null when the ecology bridge has not been initialized (e.g. proof scenes,
## early-game turns before ecology is seeded). Set via `set_fauna_ecology`.
var _fauna_ecology: GdFaunaEcology = null
## p2-55 — single-use posture override chosen via the chip row. Cleared each
## time `show_preview` is called. -1 means "use the resolved posture
## (override → civ default → global)" computed by `_resolve_active_posture`.
@ -78,6 +83,12 @@ func _ready() -> void:
_vs_label.text = ThemeVocabulary.lookup("combat_preview_vs")
_attack_button.text = ThemeVocabulary.lookup("combat_preview_attack")
_cancel_button.text = ThemeVocabulary.lookup("combat_preview_cancel")
## p1-58: inject the fauna ecology bridge. Call from the parent scene after
## EcologyEngine is initialized. Safe to omit — grudge badge is silently hidden.
func set_fauna_ecology(fe: GdFaunaEcology) -> void:
_fauna_ecology = fe
_attack_button.pressed.connect(_on_attack_pressed)
_cancel_button.pressed.connect(_on_cancel_pressed)
_resolver = GdCombatResolver.new()
@ -104,6 +115,7 @@ func show_preview(
_populate_unit_info(defender, _def_name, _def_hp, _def_atk, _def_def, _def_keywords)
_populate_modifiers()
_populate_resolution_row()
_populate_grudge_badge(attacker, defender)
visible = true
@ -545,3 +557,38 @@ func _on_capture_prompt_action(action: StringName, modal: AcceptDialog) -> void:
_engagement_posture = int(s.trim_prefix("posture_"))
modal.queue_free()
_resolve_engagement()
## p1-58 ecology cognition — show/hide grudge badge.
##
## Wild-creature units carry `ecology_species_id: int` and `tile_pos: Vector2i`
## fields. If the defending creature holds a live grudge against the attacker's
## player, a "[Grudge]" label is shown in the preview pane.
##
## Silently no-ops when `_fauna_ecology` is null, when the defender does not
## carry the wild-creature fields, or when no grudge is active.
func _populate_grudge_badge(atk: RefCounted, def: RefCounted) -> void:
var old: Node = get_node_or_null("GrudgeBadge")
if old != null:
old.queue_free()
if _fauna_ecology == null or atk == null or def == null:
return
# Wild-creature field guard — regular units won't have these.
if not def.get_meta_list().has("ecology_species_id") and def.get("ecology_species_id") == null:
return
var species_id: int = int(def.get("ecology_species_id"))
var raw_pos: Dictionary = def.get("tile_pos_dict") if def.get("tile_pos_dict") != null else {}
var tile_x: int = int(raw_pos.get("x", -1))
var tile_y: int = int(raw_pos.get("y", -1))
if tile_x < 0 or tile_y < 0:
return
var player_index: int = int(atk.get("player_index"))
if not _fauna_ecology.grudge_against(tile_x, tile_y, species_id, player_index, GameState.turn_number):
return
var badge: Label = Label.new()
badge.name = "GrudgeBadge"
badge.add_to_group("grudge_badge")
badge.text = ThemeVocabulary.lookup("combat_grudge_badge")
badge.add_theme_font_size_override("font_size", 13)
badge.add_theme_color_override("font_color", Color(0.95, 0.35, 0.25))
add_child(badge)

View file

@ -0,0 +1,51 @@
extends PanelContainer
## Boss spawn notification banner — p1-58 ecology cognition.
##
## Displayed as a region-wide alert when a tier-10 apex creature spawns.
## Signal-driven: connects to `EventBus.boss_spawned`.
##
## Fields shown: creature name, tile position, devastation tier, range.
## Auto-dismisses after `DISPLAY_SECONDS`. Can be manually dismissed.
const DISPLAY_SECONDS: float = 8.0
@onready var _name_label: Label = %BossName
@onready var _tile_label: Label = %BossTile
@onready var _tier_label: Label = %BossDevastationTier
@onready var _range_label: Label = %BossRange
@onready var _dismiss_button: Button = %BossDismiss
var _timer: float = 0.0
func _ready() -> void:
visible = false
_dismiss_button.text = ThemeVocabulary.lookup("notification_dismiss")
_dismiss_button.pressed.connect(_dismiss)
EventBus.boss_spawned.connect(_on_boss_spawned)
func _process(delta: float) -> void:
if not visible:
return
_timer -= delta
if _timer <= 0.0:
_dismiss()
func _on_boss_spawned(
species_name: String,
tile_pos: Vector2i,
devastation_tier: int,
range_hexes: int,
) -> void:
_name_label.text = species_name
_tile_label.text = ThemeVocabulary.lookup("fmt_boss_tile") % [tile_pos.x, tile_pos.y]
_tier_label.text = ThemeVocabulary.lookup("fmt_boss_devastation_tier") % devastation_tier
_range_label.text = ThemeVocabulary.lookup("fmt_boss_range") % range_hexes
_timer = DISPLAY_SECONDS
visible = true
func _dismiss() -> void:
visible = false

View file

@ -0,0 +1,65 @@
extends PanelContainer
## Loot drop popup — p1-58 ecology cognition.
##
## Shows loot drops after a boss kill. Renders legendary[], rare[], common[]
## arrays from the loot table dict.
## Signal-driven: connects to `EventBus.loot_dropped`.
##
## Auto-dismisses after `DISPLAY_SECONDS`. Can be manually dismissed.
const DISPLAY_SECONDS: float = 10.0
@onready var _title_label: Label = %LootTitle
@onready var _creature_label: Label = %LootCreatureType
@onready var _legendary_list: VBoxContainer = %LootLegendaryList
@onready var _rare_list: VBoxContainer = %LootRareList
@onready var _common_list: VBoxContainer = %LootCommonList
@onready var _legendary_header: Label = %LootLegendaryHeader
@onready var _rare_header: Label = %LootRareHeader
@onready var _common_header: Label = %LootCommonHeader
@onready var _dismiss_button: Button = %LootDismiss
var _timer: float = 0.0
func _ready() -> void:
visible = false
_title_label.text = ThemeVocabulary.lookup("loot_popup_title")
_legendary_header.text = ThemeVocabulary.lookup("loot_tier_legendary")
_rare_header.text = ThemeVocabulary.lookup("loot_tier_rare")
_common_header.text = ThemeVocabulary.lookup("loot_tier_common")
_dismiss_button.text = ThemeVocabulary.lookup("notification_dismiss")
_dismiss_button.pressed.connect(_dismiss)
EventBus.loot_dropped.connect(_on_loot_dropped)
func _process(delta: float) -> void:
if not visible:
return
_timer -= delta
if _timer <= 0.0:
_dismiss()
func _on_loot_dropped(player: Variant, creature_type: String, drops: Array) -> void:
_creature_label.text = creature_type
_populate_tier(_legendary_list, drops.filter(func(d: Dictionary) -> bool: return d.get("tier", "") == "legendary"))
_populate_tier(_rare_list, drops.filter(func(d: Dictionary) -> bool: return d.get("tier", "") == "rare"))
_populate_tier(_common_list, drops.filter(func(d: Dictionary) -> bool: return d.get("tier", "") == "common"))
_timer = DISPLAY_SECONDS
visible = true
func _populate_tier(container: VBoxContainer, items: Array) -> void:
for child: Node in container.get_children():
child.queue_free()
for item: Dictionary in items:
var row: Label = Label.new()
row.text = item.get("name", item.get("id", "Unknown"))
row.add_theme_font_size_override("font_size", 13)
container.add_child(row)
container.get_parent().visible = not items.is_empty()
func _dismiss() -> void:
visible = false

View file

@ -2,11 +2,15 @@ extends PanelContainer
## Bottom-of-screen tooltip showing terrain info when hovering a tile.
## Row 1: biome name, movement cost, defense, food/production/trade yields.
## Row 2: collectibles list, worked yield delta, strategic/luxury resource context.
## Row 3 (p1-58): ecology species present on the tile via GdFaunaEcology.
const TileScript: GDScript = preload("res://engine/src/map/tile.gd")
var _current_axial: Vector2i = Vector2i(-9999, -9999)
## p1-58: optional fauna ecology bridge — set by parent scene after init.
var _fauna_ecology: GdFaunaEcology = null
@onready var _biome_name: Label = %BiomeName
@onready var _move_cost: Label = %MoveCost
@onready var _defense_bonus: Label = %DefenseBonus
@ -17,6 +21,9 @@ var _current_axial: Vector2i = Vector2i(-9999, -9999)
@onready var _position_label: Label = %PositionLabel
@onready var _collectibles_label: Label = %CollectiblesLabel
@onready var _worked_yield_label: Label = %WorkedYieldLabel
## Optional ecology species container. Present only if the scene adds a
## VBoxContainer named EcologySpeciesList — guarded by get_node_or_null.
@onready var _ecology_list: VBoxContainer = get_node_or_null("%EcologySpeciesList")
func _ready() -> void:

View file

@ -99,6 +99,12 @@ signal item_produced(city: Variant, item_id: String)
signal item_crafted(item_id: String, city: Variant, player: Variant)
## Emitted when the civ-wide material stockpile changes (add or consume).
signal loot_dropped(player: Variant, creature_type: String, drops: Array)
## Emitted when a tier-10 apex creature spawns on the map.
## `species_name` — localized display name of the creature.
## `tile_pos` — hex tile where the creature spawned.
## `devastation_tier` — units at or below this tier auto-lose to this creature.
## `range_hexes` — maximum patrol/aggression range in hexes.
signal boss_spawned(species_name: String, tile_pos: Vector2i, devastation_tier: int, range_hexes: int)
## Emitted when the Treasury crosses the 20-item soft cap. Informational only —
## adds are never blocked. UI should show a subtle hint, not a modal.
signal treasury_soft_cap_reached(player: Variant, total: int)

View file

@ -1699,7 +1699,8 @@ mod tests {
let mut c = City::new("c");
c.buildings.extend(["y".into(), "w".into()]);
let common = buildings_common_to_all_cities(&[&a, &b, &c]);
let all = [&a, &b, &c];
let common = buildings_common_to_all_cities(&all);
assert_eq!(common, vec!["y"]);
}