feat(@projects/@magic-civilization): add menu flow and happiness system integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-16 15:32:38 -07:00
parent 8f30d4d389
commit 8b44d3bdca
12 changed files with 698 additions and 3 deletions

View file

@ -52,3 +52,9 @@
2026-04-16 14:47 Task #18 PLAYER GUIDE UPDATE complete: new Personality Axes page at /empire/personality in guide web app. PersonalityAxesPage.tsx renders 6-axis explainer + race strategic axes grid. Pulls from @resources/races/strategic_axes.json (no hardcoded data). Wired through lazy-pages.ts, App.tsx route, nav.tsx sidebar. pnpm typecheck clean. Visual verification blocked by WASM not built on macOS (environment, not code). (guide-dev)
2026-04-16 15:06 BATCH 9 (post ttv-v2 heal_suppress 5): 10 PASS / 4 FAIL. Seeds T145/T182/T157 victory. Median TTV 157 (below 200 target, WORSE than batch 8's 166). victories 3/3=100% (above 80% target). Heal-suppress extension had WRONG direction — longer suppress made cities die faster because damage accumulates. Need to REVERT 5→3 or raise BASE_CITY_HP to push TTV up. Seed 1 has imp=0 (task #29 worker fix didn't cascade here — some seed still lacks worker). Batch baselines: checklist stayed at 10/4 pre→post.
2026-04-16 15:15 BATCH 10 (BASE_CITY_HP 320 + revert suppress 3 + HP 320): 11 PASS / 3 FAIL. Victories 2/3 in range (67%), median TTV 194 (6 below 200 target), combats 221, pop 20, tiles 76, techs 28, loot 1. Fails: TTV, worker/seed, T100-both-players. ttv-v2-dev's math model predicted TTV 160-170, empirical 194 — model under-predicted by 30 turns (field-buildup phase longer than math assumed). Next: ttv-v2-dev applying melee_fraction 0.30 + wall tier 2 for +15-25 turns. Expected batch 11 median TTV ~210-220 → should PASS TTV target.
2026-04-16 15:26 BATCH 11 (melee_frac 0.35 + HP 260 revert): **12 PASS / 2 FAIL — BEST YET**.
- victories 2/3 (67%, IN RANGE)
- **median TTV 280 (IN 200-350 RANGE — FIRST TIME)**
- pop_peak 26, combats 401, techs 38, tiles 74
- worker improvements min 8 (was 0 — task #29 + #46 fixes cascaded)
Remaining 2 FAILS: loot_dropped 0 (wilds not engaging this batch — variance), both-players-T100 1/3 (persistent structural). Stop criterion needs FULL 14/14 for 2 consecutive batches — we're 2 short.

View file

@ -0,0 +1,120 @@
extends Control
## Encyclopedia overlay: searchable reference for units, buildings, techs, spells.
## Read-only display — tabbed entry list + detail pane.
const CATEGORIES: Array[Dictionary] = [
{"key": "units", "label": "Units"}, {"key": "buildings", "label": "Buildings"},
{"key": "techs", "label": "Techs"}, {"key": "spells", "label": "Spells"},
]
var _current_category: int = 0
var _search_text: String = ""
@onready var _tab_bar: TabBar = %TabBar
@onready var _search_field: LineEdit = %SearchField
@onready var _entry_list: ItemList = %EntryList
@onready var _detail_name: Label = %DetailName
@onready var _detail_meta: Label = %DetailMeta
@onready var _detail_body: RichTextLabel = %DetailBody
@onready var _close_button: Button = %CloseButton
func _ready() -> void:
for cat: Dictionary in CATEGORIES:
_tab_bar.add_tab(cat.label)
_tab_bar.tab_changed.connect(_on_tab_changed)
_search_field.text_changed.connect(_on_search_changed)
_entry_list.item_selected.connect(_on_entry_selected)
_close_button.pressed.connect(_on_close)
_clear_detail()
_refresh_list()
func _on_close() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("pop_overlay"):
main.pop_overlay()
else:
queue_free()
func _on_tab_changed(tab: int) -> void:
_current_category = tab
_clear_detail()
_refresh_list()
func _on_search_changed(text: String) -> void:
_search_text = text.to_lower().strip_edges()
_refresh_list()
func _on_entry_selected(index: int) -> void:
var entry: Dictionary = _entry_list.get_item_metadata(index)
if not entry.is_empty():
_show_detail(entry)
func _refresh_list() -> void:
_entry_list.clear()
var entries: Array = _load_entries(CATEGORIES[_current_category].key)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return String(a.get("name", a.get("id", ""))) < String(b.get("name", b.get("id", "")))
)
for entry: Dictionary in entries:
var name_str: String = entry.get("name", entry.get("id", ""))
if _search_text != "" and not name_str.to_lower().contains(_search_text):
continue
_entry_list.set_item_metadata(_entry_list.add_item(name_str), entry)
func _load_entries(category: String) -> Array:
match category:
"units": return DataLoader.get_all_units()
"buildings": return DataLoader.get_all_buildings()
"techs": return DataLoader.get_all_techs()
"spells": return DataLoader.get_all_spells()
return []
func _show_detail(entry: Dictionary) -> void:
_detail_name.text = entry.get("name", entry.get("id", ""))
var bits: Array[String] = []
if entry.has("tier"): bits.append("Tier %d" % int(entry.tier))
var school: String = String(entry.get("school", ""))
if school != "" and school != "<null>": bits.append(school.capitalize())
var pillar: String = String(entry.get("pillar", ""))
if pillar != "": bits.append(pillar.capitalize())
if entry.get("cost", null) != null: bits.append("Cost: %s" % str(entry.cost))
if entry.get("mana_cost", null) != null: bits.append("Mana: %s" % str(entry.mana_cost))
_detail_meta.text = " | ".join(bits)
var parts: Array[String] = []
var desc: String = String(entry.get("description", ""))
if desc != "": parts.append(desc)
var req: String = String(entry.get("tech_required", ""))
if req != "" and req != "<null>": parts.append("[b]Requires:[/b] %s" % req)
var reqs: Array = entry.get("requires", [])
if reqs is Array and not reqs.is_empty():
parts.append("[b]Requires:[/b] %s" % ", ".join(reqs))
var unlocks: Dictionary = entry.get("unlocks", {})
if unlocks is Dictionary and not unlocks.is_empty():
var unlocked: Array[String] = []
for key: String in unlocks:
var arr: Array = unlocks[key]
if arr is Array and not arr.is_empty():
unlocked.append("%s: %s" % [key, ", ".join(arr)])
if not unlocked.is_empty():
parts.append("[b]Unlocks:[/b] %s" % "; ".join(unlocked))
_detail_body.text = "\n\n".join(parts)
func _clear_detail() -> void:
_detail_name.text = "Select an entry"
_detail_meta.text = ""
_detail_body.text = ""
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
get_viewport().set_input_as_handled()
_on_close()

View file

@ -0,0 +1,105 @@
[gd_scene load_steps=2 format=3 uid="uid://cenpdy0encc01"]
[ext_resource type="Script" path="res://engine/scenes/encyclopedia/encyclopedia_panel.gd" id="1_encyc"]
[node name="EncyclopediaPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
script = ExtResource("1_encyc")
[node name="Backdrop" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0, 0, 0, 0.55)
[node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -440.0
offset_top = -300.0
offset_right = 440.0
offset_bottom = 300.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
[node name="VBox" type="VBoxContainer" parent="PanelContainer/Margin"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TopBar" type="HBoxContainer" parent="PanelContainer/Margin/VBox"]
layout_mode = 2
[node name="Title" type="Label" parent="PanelContainer/Margin/VBox/TopBar"]
layout_mode = 2
size_flags_horizontal = 3
text = "Encyclopedia"
theme_override_font_sizes/font_size = 20
[node name="CloseButton" type="Button" parent="PanelContainer/Margin/VBox/TopBar"]
unique_name_in_owner = true
layout_mode = 2
text = "Close"
[node name="TabBar" type="TabBar" parent="PanelContainer/Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
[node name="SearchField" type="LineEdit" parent="PanelContainer/Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
placeholder_text = "Search..."
clear_button_enabled = true
[node name="Split" type="HSplitContainer" parent="PanelContainer/Margin/VBox"]
layout_mode = 2
size_flags_vertical = 3
split_offset = 300
[node name="EntryList" type="ItemList" parent="PanelContainer/Margin/VBox/Split"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="DetailPane" type="VBoxContainer" parent="PanelContainer/Margin/VBox/Split"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="DetailName" type="Label" parent="PanelContainer/Margin/VBox/Split/DetailPane"]
unique_name_in_owner = true
layout_mode = 2
text = "Select an entry"
theme_override_font_sizes/font_size = 18
[node name="DetailMeta" type="Label" parent="PanelContainer/Margin/VBox/Split/DetailPane"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 12
theme_override_colors/font_color = Color(0.75, 0.72, 0.65, 1)
[node name="DetailBody" type="RichTextLabel" parent="PanelContainer/Margin/VBox/Split/DetailPane"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
bbcode_enabled = true
fit_content = true
scroll_active = true

View file

@ -190,6 +190,29 @@ func _draw_fog(game_map: RefCounted, player_index: int) -> void:
)
func _draw_owner_tiles(game_map: RefCounted) -> void:
## Tint each owned tile with the controlling player's color (subtle overlay).
var player_colors: Dictionary = {}
for p: Variant in GameState.players:
if p is PlayerScript:
player_colors[(p as PlayerScript).index] = (p as PlayerScript).color
for axial: Vector2i in game_map.tiles:
var raw_tile: RefCounted = game_map.get_tile(axial) as RefCounted
if raw_tile == null:
continue
var owner_idx: int = int(raw_tile.get("owner"))
if owner_idx < 0 or not player_colors.has(owner_idx):
continue
var tint: Color = player_colors[owner_idx]
tint.a = OWNER_TINT_ALPHA
var pixel_pos: Vector2 = _world_to_mini(
HexUtilsScript.axial_to_pixel(axial) + Vector2(
HexUtilsScript.HEX_WIDTH * 0.5, HexUtilsScript.HEX_HEIGHT * 0.5
)
)
_overlay_rect.draw_rect(Rect2(pixel_pos - OWNER_TILE_SIZE * 0.5, OWNER_TILE_SIZE), tint)
func _get_tile_visibility(game_map: RefCounted, axial: Vector2i, player_index: int) -> int:
var raw_tile: RefCounted = game_map.get_tile(axial) as RefCounted
if raw_tile == null:

View file

@ -32,6 +32,7 @@ func _ready() -> void:
%BugReportButton.pressed.connect(_on_bug_report_pressed)
%StatsButton.pressed.connect(_on_stats_pressed)
%EncyclopediaButton.pressed.connect(_on_encyclopedia_pressed)
_refresh_all()
@ -74,7 +75,12 @@ func _refresh_all() -> void:
func _update_turn() -> void:
%TurnLabel.text = "%s %d" % [ThemeVocabulary.lookup("turn"), GameState.turn_number]
var turn_word: String = ThemeVocabulary.lookup("turn")
var limit: int = int(GameState.game_settings.get("turn_limit", 0))
if limit > 0:
%TurnLabel.text = "%s %d / %d" % [turn_word, GameState.turn_number, limit]
else:
%TurnLabel.text = "%s %d" % [turn_word, GameState.turn_number]
func _update_era() -> void:
@ -158,6 +164,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
if key.pressed and not key.echo and key.keycode == KEY_F9:
get_viewport().set_input_as_handled()
_on_stats_pressed()
elif key.pressed and not key.echo and key.keycode == KEY_F1:
get_viewport().set_input_as_handled()
_on_encyclopedia_pressed()
func _format_signed(value: int) -> String:
@ -190,3 +199,9 @@ func _on_bug_report_pressed() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("push_overlay"):
main.push_overlay("res://engine/scenes/ui/bug_report.tscn")
func _on_encyclopedia_pressed() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("push_overlay"):
main.push_overlay("res://engine/scenes/encyclopedia/encyclopedia_panel.tscn")

View file

@ -135,6 +135,15 @@ text = "S"
theme_override_font_sizes/font_size = 14
theme_override_colors/font_color = Color(0.5, 0.8, 0.9, 1)
[node name="EncyclopediaButton" type="Button" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(32, 32)
tooltip_text = "Encyclopedia (F1)"
text = "?"
theme_override_font_sizes/font_size = 14
theme_override_colors/font_color = Color(0.85, 0.75, 0.45, 1)
[node name="BugReportButton" type="Button" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2

View file

@ -0,0 +1,87 @@
extends Control
## Focused settings screen: volume sliders, resolution, language stub.
## Persists through SettingsManager; resolution applied via DisplayServer.
const RESOLUTIONS: Array[Vector2i] = [
Vector2i(1280, 720),
Vector2i(1600, 900),
Vector2i(1920, 1080),
Vector2i(2560, 1440),
Vector2i(3840, 2160),
]
@onready var _master: HSlider = %MasterSlider
@onready var _master_val: Label = %MasterValue
@onready var _music: HSlider = %MusicSlider
@onready var _music_val: Label = %MusicValue
@onready var _sfx: HSlider = %SfxSlider
@onready var _sfx_val: Label = %SfxValue
@onready var _resolution: OptionButton = %ResolutionOption
@onready var _language: OptionButton = %LanguageOption
@onready var _back: Button = %BackButton
func _ready() -> void:
for res in RESOLUTIONS:
_resolution.add_item("%d x %d" % [res.x, res.y])
_language.add_item("English")
_language.disabled = true
_refresh()
_master.value_changed.connect(_on_master)
_music.value_changed.connect(_on_music)
_sfx.value_changed.connect(_on_sfx)
_resolution.item_selected.connect(_on_resolution)
_back.pressed.connect(_on_back)
_back.grab_focus()
func _refresh() -> void:
var m: int = int(SettingsManager.get_setting("audio", "master_volume"))
var mu: int = int(SettingsManager.get_setting("audio", "music_volume"))
var s: int = int(SettingsManager.get_setting("audio", "sfx_volume"))
_master.value = m
_master_val.text = "%d%%" % m
_music.value = mu
_music_val.text = "%d%%" % mu
_sfx.value = s
_sfx_val.text = "%d%%" % s
var current: Vector2i = DisplayServer.window_get_size()
var idx: int = RESOLUTIONS.find(current)
_resolution.select(idx if idx >= 0 else 2)
_language.select(0)
func _on_master(v: float) -> void:
var iv: int = int(v)
SettingsManager.set_setting("audio", "master_volume", iv)
_master_val.text = "%d%%" % iv
func _on_music(v: float) -> void:
var iv: int = int(v)
SettingsManager.set_setting("audio", "music_volume", iv)
_music_val.text = "%d%%" % iv
func _on_sfx(v: float) -> void:
var iv: int = int(v)
SettingsManager.set_setting("audio", "sfx_volume", iv)
_sfx_val.text = "%d%%" % iv
func _on_resolution(idx: int) -> void:
if idx < 0 or idx >= RESOLUTIONS.size():
return
DisplayServer.window_set_size(RESOLUTIONS[idx])
func _on_back() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("change_scene"):
main.change_scene("res://engine/scenes/menus/main_menu.tscn")
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
get_viewport().set_input_as_handled()
_on_back()

View file

@ -0,0 +1,183 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://engine/scenes/menus/settings.gd" id="1"]
[node name="Settings" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.055, 0.04, 0.09, 1)
[node name="Margin" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 240
theme_override_constants/margin_top = 60
theme_override_constants/margin_right = 240
theme_override_constants/margin_bottom = 60
[node name="VBox" type="VBoxContainer" parent="Margin"]
layout_mode = 2
theme_override_constants/separation = 18
[node name="Title" type="Label" parent="Margin/VBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 34
theme_override_colors/font_color = Color(0.95, 0.82, 0.3, 1)
text = "Settings"
horizontal_alignment = 1
[node name="Rule" type="ColorRect" parent="Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 1)
color = Color(0.6, 0.45, 0.12, 0.6)
[node name="MasterRow" type="HBoxContainer" parent="Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 40)
theme_override_constants/separation = 12
[node name="MasterLabel" type="Label" parent="Margin/VBox/MasterRow"]
layout_mode = 2
custom_minimum_size = Vector2(220, 0)
theme_override_font_sizes/font_size = 15
text = "Master Volume"
vertical_alignment = 1
[node name="MasterSlider" type="HSlider" parent="Margin/VBox/MasterRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
min_value = 0.0
max_value = 100.0
step = 1.0
value = 80.0
[node name="MasterValue" type="Label" parent="Margin/VBox/MasterRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(60, 0)
theme_override_font_sizes/font_size = 15
text = "80%"
horizontal_alignment = 2
vertical_alignment = 1
[node name="MusicRow" type="HBoxContainer" parent="Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 40)
theme_override_constants/separation = 12
[node name="MusicLabel" type="Label" parent="Margin/VBox/MusicRow"]
layout_mode = 2
custom_minimum_size = Vector2(220, 0)
theme_override_font_sizes/font_size = 15
text = "Music Volume"
vertical_alignment = 1
[node name="MusicSlider" type="HSlider" parent="Margin/VBox/MusicRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
min_value = 0.0
max_value = 100.0
step = 1.0
value = 70.0
[node name="MusicValue" type="Label" parent="Margin/VBox/MusicRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(60, 0)
theme_override_font_sizes/font_size = 15
text = "70%"
horizontal_alignment = 2
vertical_alignment = 1
[node name="SfxRow" type="HBoxContainer" parent="Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 40)
theme_override_constants/separation = 12
[node name="SfxLabel" type="Label" parent="Margin/VBox/SfxRow"]
layout_mode = 2
custom_minimum_size = Vector2(220, 0)
theme_override_font_sizes/font_size = 15
text = "SFX Volume"
vertical_alignment = 1
[node name="SfxSlider" type="HSlider" parent="Margin/VBox/SfxRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
min_value = 0.0
max_value = 100.0
step = 1.0
value = 70.0
[node name="SfxValue" type="Label" parent="Margin/VBox/SfxRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(60, 0)
theme_override_font_sizes/font_size = 15
text = "70%"
horizontal_alignment = 2
vertical_alignment = 1
[node name="ResolutionRow" type="HBoxContainer" parent="Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 40)
theme_override_constants/separation = 12
[node name="ResolutionLabel" type="Label" parent="Margin/VBox/ResolutionRow"]
layout_mode = 2
custom_minimum_size = Vector2(220, 0)
theme_override_font_sizes/font_size = 15
text = "Resolution"
vertical_alignment = 1
[node name="ResolutionOption" type="OptionButton" parent="Margin/VBox/ResolutionRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="LanguageRow" type="HBoxContainer" parent="Margin/VBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 40)
theme_override_constants/separation = 12
[node name="LanguageLabel" type="Label" parent="Margin/VBox/LanguageRow"]
layout_mode = 2
custom_minimum_size = Vector2(220, 0)
theme_override_font_sizes/font_size = 15
text = "Language"
vertical_alignment = 1
[node name="LanguageOption" type="OptionButton" parent="Margin/VBox/LanguageRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="Spacer" type="Control" parent="Margin/VBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="BackButton" type="Button" parent="Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(200, 48)
size_flags_horizontal = 4
theme_override_colors/font_color = Color(1.0, 0.92, 0.4, 1)
theme_override_font_sizes/font_size = 17
text = "Back to Menu"

View file

@ -0,0 +1,56 @@
extends Node
## HOW TO PLAY PROOF: navigates main_menu → how_to_play, captures screenshot
## showing clan personality panel + Back/Guide buttons.
const MainMenuScene: PackedScene = preload("res://engine/scenes/menus/main_menu.tscn")
const HowToPlayScene: PackedScene = preload("res://engine/scenes/menus/how_to_play.tscn")
var _captured: bool = false
var _screenshot_name: String = "menu_flow_proof"
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.06, 0.05, 0.04))
get_viewport().size = Vector2i(1920, 1080)
DisplayServer.window_set_size(Vector2i(1920, 1080))
var env_name: String = OS.get_environment("SCREENSHOT_NAME")
if not env_name.is_empty():
_screenshot_name = env_name
DataLoader.load_theme("age-of-dwarves")
await get_tree().process_frame
var menu: Control = MainMenuScene.instantiate()
add_child(menu)
for _i: int in range(4):
await get_tree().process_frame
print("MainMenu buttons visible: %s" % str(menu.get_node("%HowToPlayButton").text))
menu.queue_free()
await get_tree().process_frame
var htp: Control = HowToPlayScene.instantiate()
add_child(htp)
for _i: int in range(12):
await get_tree().process_frame
print("HowToPlay clans populated: %d" % htp.get_node("%ClansContainer").get_child_count())
_capture_and_quit()
func _capture_and_quit() -> void:
if _captured:
return
_captured = true
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://screenshots"))
var image: Image = get_viewport().get_texture().get_image()
if image == null:
get_tree().quit(1)
return
var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
var rel_path: String = "user://screenshots/%s_%s.png" % [_screenshot_name, timestamp]
var abs_path: String = ProjectSettings.globalize_path(rel_path)
if image.save_png(abs_path) == OK:
print("SCREENSHOT_PATH:%s" % abs_path)
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bmenuflow0proof"]
[ext_resource type="Script" path="res://engine/scenes/tests/menu_flow_proof.gd" id="1_script"]
[node name="MenuFlowProof" type="Node"]
script = ExtResource("1_script")

View file

@ -51,6 +51,12 @@ static func process_turn(player: RefCounted, _game_map: RefCounted) -> void:
var building_happiness: int = TurnProcessorHelpersScript.sum_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"
)
building_happiness += per_city_bonus * player.cities.size()
var ascension_active: bool = bool(player.ascension_active)
var growth_tier: String = player.growth_tier if player.growth_tier != "" else "balanced"

View file

@ -79,7 +79,17 @@ func _process_production(player: RefCounted) -> void: # Player
var yields: Dictionary = c.get_yields(tile_json)
# Add building production bonuses (forge +2, barracks +1, etc.)
var building_prod: int = _sum_city_building_effect(c, "production")
var prod: int = int((yields.get("production", 1) + building_prod) * prod_modifier)
# production_from_hills — +N prod per worked hills tile (first_mineshaft etc.).
var prod_hills: int = _sum_city_building_effect(c, "production_from_hills")
if prod_hills > 0:
for tile_pos: Vector2i in c.get_worked_tiles():
var tile: Resource = game_map.get_tile(tile_pos)
if tile != null and tile.biome_id == "hills":
building_prod += prod_hills
var prod_pct: float = _sum_city_building_effect_float(c, "production_percent")
var prod: int = int(
(yields.get("production", 1) + building_prod) * (1.0 + prod_pct) * prod_modifier
)
# Capture current item before apply_production pops it on completion.
var current: Dictionary = (
c.production_queue.front() as Dictionary
@ -145,7 +155,12 @@ func _process_research(player: RefCounted) -> void: # Player
)
var yields: Dictionary = city.get_yields(tile_json)
var building_sci: int = _sum_city_building_effect(city as CityScript, "science")
player.research_progress += int((yields.get("science", 0) + building_sci) * sci_modifier)
var sci_pct: float = _sum_city_building_effect_float(
city as CityScript, "science_percent"
)
player.research_progress += int(
(yields.get("science", 0) + building_sci) * (1.0 + sci_pct) * sci_modifier
)
# Check if researching a spell (not a tech)
var spell_data: Dictionary = DataLoader.get_spell(player.researching)
@ -262,12 +277,52 @@ func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> f
func _apply_building_bonuses(city: CityScript, building_id: String) -> void:
var bdata: Dictionary = DataLoader.get_building(building_id)
var effects: Array = bdata.get("effects", [])
var owner_player: RefCounted = GameState.get_player(city.owner) if city.owner >= 0 else null
for effect: Dictionary in effects:
var etype: String = effect.get("type", "")
var value: int = int(effect.get("value", 0))
if etype == "hp_bonus" and value > 0:
city.set_max_hp(city.max_hp + value)
city.heal(value)
elif etype == "city_hp" and value > 0 and owner_player != null:
# Empire-wide max HP bump from mundane wonder (iron_bulwark +100).
for other_ref: Variant in owner_player.cities:
if other_ref is CityScript:
var other: CityScript = other_ref as CityScript
other.set_max_hp(other.max_hp + value)
other.heal(value)
elif etype == "free_tech" and value > 0 and owner_player != null:
_grant_free_tech(owner_player, value)
elif etype == "free_golden_age_on_build" and value > 0 and owner_player != null:
owner_player.golden_age_active = true
owner_player.golden_age_turns = HappinessScript.GOLDEN_AGE_DURATION
EventBus.golden_age_started.emit(owner_player.index)
func _grant_free_tech(player: RefCounted, count: int) -> void:
## Pick cheapest unresearched techs the player can currently access and
## grant them instantly. Wonder flavor: "instant shortcut" rather than a
## free high-tier tech so pacing stays intact.
var researched: Array = player.researched_techs if player.researched_techs != null else []
var candidates: Array[Dictionary] = []
for t: Dictionary in DataLoader.get_all_techs():
var tid: String = str(t.get("id", ""))
if tid == "" or tid in researched:
continue
var prereqs: Array = t.get("prerequisites", []) as Array
var all_met: bool = true
for pr: Variant in prereqs:
if not (str(pr) in researched):
all_met = false
break
if all_met:
candidates.append(t)
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("cost", 999999)) < int(b.get("cost", 999999))
)
for i: int in range(mini(count, candidates.size())):
player.add_tech(str(candidates[i].get("id", "")))
EventBus.tech_researched.emit(str(candidates[i].get("id", "")), player.index)
func _process_city_healing(player: RefCounted) -> void:
@ -369,6 +424,17 @@ func _process_economy(player: RefCounted, game_map: RefCounted) -> void: # Play
var yields: Dictionary = c.get_yields(tile_json)
var building_gold: int = _sum_city_building_effect(c, "gold")
var city_gold: int = int(yields.get("gold", 0)) + building_gold
# gold_per_city_pop (wonder effect) — flat gold per pop point.
var gold_per_pop: int = _sum_city_building_effect(c, "gold_per_city_pop")
if gold_per_pop > 0:
city_gold += gold_per_pop * int(c.population)
# gold_from_mines — +N gold per mine improvement in owned tiles.
var gold_mine: int = _sum_city_building_effect(c, "gold_from_mines")
if gold_mine > 0:
for tile_pos: Vector2i in c.owned_tiles:
var tile: Resource = game_map.get_tile(tile_pos)
if tile != null and tile.improvement == "mine":
city_gold += gold_mine
# Apply percentage bonuses (marketplace +25% = 0.25)
var gold_pct: float = _sum_city_building_effect_float(c, "gold_percent")
if gold_pct > 0.0:
@ -416,7 +482,20 @@ func _process_culture(player: RefCounted, game_map: RefCounted) -> void:
continue
var c: CityScript = city_ref as CityScript
var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map)
var pre_culture: float = c.get_culture_stored()
var can_expand: bool = c.process_culture(tile_json)
# Apply culture_percent and border_growth_percent bonuses to the
# culture gained this turn. process_culture already added raw culture;
# we top up the stockpile by (raw_gain * total_pct) so wonders scale.
var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent")
var border_pct: float = _sum_city_building_effect_float(c, "border_growth_percent")
var total_pct: float = cult_pct + border_pct
if total_pct > 0.0:
var post_culture: float = c.get_culture_stored()
var gained: float = post_culture - pre_culture
if gained > 0.0:
c.set_culture_stored(post_culture + gained * total_pct)
can_expand = c.get_can_expand()
if not can_expand:
continue
# Build candidates JSON for Rust border expansion