From 748e7cdd14cdd97cb79ec30bf208cae158369b0a Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 03:06:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E2=9C=A8=20update=20chronicle=20?= =?UTF-8?q?coverage=20milestone=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/objectives/README.md | 6 +- .../objectives/p1-07-chronicle-coverage.md | 10 +- CLAUDE.md | 5 + .../engine/scenes/hud/turn_notification.gd | 205 +++++++++++++++--- src/game/engine/scenes/tests/auto_play.gd | 27 ++- src/game/engine/src/autoloads/event_bus.gd | 3 + src/game/export_presets.cfg | 4 +- 7 files changed, 218 insertions(+), 42 deletions(-) diff --git a/.project/objectives/README.md b/.project/objectives/README.md index fc3fe51b..14f27172 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -10,8 +10,8 @@ | Status | Count | |---|---| -| βœ… done | 24 | -| 🟑 partial | 17 | +| βœ… done | 23 | +| 🟑 partial | 18 | | πŸ”΄ stub | 0 | | ❌ missing | 0 | | ⚫ oos | 4 | @@ -52,7 +52,7 @@ | [p1-04](p1-04-sound-and-music.md) | 🟑 partial | Sound effects and music | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-05](p1-05-balance-tuning.md) | 🟑 partial | Balance tuning β€” pop_peak β‰₯30 median, worker improvements β‰₯8 min | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-06](p1-06-options-polish.md) | βœ… done | Options screen polish | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | -| [p1-07](p1-07-chronicle-coverage.md) | βœ… done | Chronicle notifications coverage | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | +| [p1-07](p1-07-chronicle-coverage.md) | 🟑 partial | Chronicle notifications coverage | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-08](p1-08-victory-screen-content.md) | βœ… done | Victory/defeat screen content β€” recap, banner, replay seed | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | | [p1-09](p1-09-determinism-gate.md) | 🟑 partial | Determinism gate β€” same seed produces byte-identical runs | [testwright](../team-leads/testwright.md) | 2026-04-17 | | [p1-10](p1-10-game-setup-ux.md) | βœ… done | Game setup UX β€” new-game dialog, difficulty, clan preview | [shipwright](../team-leads/shipwright.md) | 2026-04-17 | diff --git a/.project/objectives/p1-07-chronicle-coverage.md b/.project/objectives/p1-07-chronicle-coverage.md index e89de2b9..79d018f1 100644 --- a/.project/objectives/p1-07-chronicle-coverage.md +++ b/.project/objectives/p1-07-chronicle-coverage.md @@ -2,7 +2,7 @@ id: p1-07 title: Chronicle notifications coverage priority: p1 -status: done +status: partial scope: game1 owner: shipwright updated_at: 2026-04-17 @@ -22,9 +22,13 @@ All acceptance bullets now verifiable in repo: - Filter controls β€” `CATEGORY_COLORS` covers `combat`, `founding`, `tech`, `economy`, `magic`, `event`, `default`; every new handler tags its entry with one of these categories, so a filter pass is a trivial render-time gate (no change required for the coverage acceptance bullet). - GUT coverage β€” `test_chronicle_coverage.gd` spins up a real `turn_notification.tscn`, seeds `GameState.players` with a human+AI pair, emits each EventBus signal in turn, and asserts `get_entry_count()` increments by exactly one. Also asserts `city_captured` emitted for an AI-vs-AI fight does NOT add an entry for the human. Final test (`test_every_major_signal_has_a_handler`) walks the required signal list and asserts the panel is connected. **8/8 passing on apricot** (Godot 4.6.2, GUT 9.6.0). -## Non-goals +## Remaining to reach done -- Entry-click scroll-to-hex β€” kept as future polish; not part of the p1 coverage bullet. +- βœ“ Chronicle shows one entry per relevant event, oldestβ†’newest β€” 6 new handlers + 8/8 GUT test. +- βœ— Filter controls (All / Military / Research / City / Diplomacy) β€” `CATEGORY_COLORS` tags categories, but no user-facing filter UI toggles render. Needs actual UI buttons in `turn_notification.tscn` that hide/show entries by category. +- βœ— Entry click scrolls camera to relevant hex if applicable β€” not implemented. The spec requires this; moving it to "non-goals" was a reframing violation per CLAUDE.md integrity rule. Implement the click-to-scroll path OR explicitly cut the acceptance bullet with user sign-off before closing. + +## Non-goals ## Summary diff --git a/CLAUDE.md b/CLAUDE.md index 80e8f393..b715c2a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -478,8 +478,13 @@ Code exists β‰  code works. Tests pass β‰  features render. Lint clean β‰  visua Never set `status: done` if ANY of: - The file's own `## Summary` admits open gaps, missing wiring, or remaining work. - Any bullet in `## Acceptance` is not demonstrably true (test green, feature visible in a reviewed proof screenshot, data populated, build queue path invoked, etc.). +- **Any acceptance bullet carries a marker other than `βœ“`** β€” specifically `?`, `~`, `⚠`, `β—»`, `TODO`, "pending", "in-flight", "needs", "queued", "not yet", or any other hedging language. These markers mean "not verified" β€” which is the definition of not done. A bullet is either βœ“ with citation or βœ—/? (= not done). No third state. +- **A `## Remaining to reach done` section exists with any items in it.** If the file enumerates remaining work, the objective is by definition not done. Either close every remaining item first, or stay at 🟑 partial. - Any test listed under `evidence:` is failing, skipped, or does not exist on disk. - The underlying Rust crate is a stub (e.g. `mc-culture/src/lib.rs` = `// TODO`). +- A bullet reframes an uncompleted requirement as "not a gate for correctness", "cosmetic", or "nice to have" to justify closure. If the spec listed it, it's a gate. Either meet it or keep `partial` and update the spec first. + +**Counting rule.** Before setting `status: done`, count the acceptance bullets. Call the count N. Count bullets marked βœ“ (and only βœ“) with cited evidence that you have verified exists. Call this count K. If K < N, status MUST be `partial`. There are no exceptions for "close enough", "mostly done", "the remaining bullet is minor", or "the teammate is on break". Five out of five or it's partial. Allowed transitions: - πŸ”΄ stub β†’ 🟑 partial β†’ βœ… done. Never skip 🟑 partial. If you aren't sure every acceptance bullet passes, mark 🟑 partial. diff --git a/src/game/engine/scenes/hud/turn_notification.gd b/src/game/engine/scenes/hud/turn_notification.gd index 4404b9f5..9f7f0d35 100644 --- a/src/game/engine/scenes/hud/turn_notification.gd +++ b/src/game/engine/scenes/hud/turn_notification.gd @@ -19,14 +19,33 @@ const CATEGORY_COLORS: Dictionary = { "default": Color(0.9, 0.88, 0.78, 1.0), } +## Filter buckets map one or more entry categories to a single checkbox. "all" +## is a meta-bucket that flips every other filter in lockstep. +const FILTER_BUCKETS: Dictionary = { + "all": [], + "military": ["combat"], + "research": ["tech"], + "city": ["founding", "economy"], + "diplomacy": ["event", "magic"], +} +const FILTER_ORDER: Array[String] = [ + "all", "military", "research", "city", "diplomacy", +] + var _dim_rect: ColorRect = null var _processing_label: Label = null var _log_panel: PanelContainer = null var _log_vbox: VBoxContainer = null +var _filter_row: HBoxContainer = null var _dismiss_timer: Timer = null -## Each entry: {"text": String, "category": String} +## Each entry: {"text": String, "category": String, "hex_pos": Vector2i?} var _entries: Array[Dictionary] = [] var _is_processing: bool = false +## Per-bucket visibility. Default: everything shown. +var _filter_state: Dictionary = { + "military": true, "research": true, "city": true, "diplomacy": true, +} +var _filter_checks: Dictionary = {} func _ready() -> void: @@ -97,6 +116,12 @@ func _build_ui() -> void: var separator: HSeparator = HSeparator.new() outer_vbox.add_child(separator) + _filter_row = HBoxContainer.new() + _filter_row.name = "FilterRow" + _filter_row.add_theme_constant_override("separation", 12) + outer_vbox.add_child(_filter_row) + _build_filter_checkboxes() + var scroll: ScrollContainer = ScrollContainer.new() scroll.name = "LogScroll" scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -166,31 +191,120 @@ func _show_log() -> void: _processing_label.visible = false _dim_rect.color = Color(0.0, 0.0, 0.0, 0.5) + _rebuild_log_entries() + _log_panel.visible = true + _dismiss_timer.start(AUTO_DISMISS_SECONDS) + + +func _rebuild_log_entries() -> void: for child: Node in _log_vbox.get_children(): child.queue_free() - - var display_count: int = mini(_entries.size(), MAX_ENTRIES) - for i: int in range(display_count): + var visible_count: int = 0 + var hidden_count: int = 0 + for i: int in range(_entries.size()): + if visible_count >= MAX_ENTRIES: + hidden_count = _entries.size() - i + break var entry: Dictionary = _entries[i] - var category: String = entry.get("category", "default") - var color: Color = CATEGORY_COLORS.get(category, CATEGORY_COLORS["default"]) - var entry_label: Label = Label.new() - entry_label.text = entry.get("text", "") - entry_label.add_theme_font_size_override("font_size", 15) - entry_label.add_theme_color_override("font_color", color) - entry_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _log_vbox.add_child(entry_label) - - if _entries.size() > MAX_ENTRIES: + if not _entry_passes_filter(entry): + continue + _log_vbox.add_child(_build_entry_node(entry)) + visible_count += 1 + if hidden_count > 0: var more_label: Label = Label.new() - more_label.text = "... and %d more" % (_entries.size() - MAX_ENTRIES) + more_label.text = "... and %d more" % hidden_count more_label.add_theme_font_size_override("font_size", 13) more_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 0.8)) _log_vbox.add_child(more_label) - _log_panel.visible = true - _dismiss_timer.start(AUTO_DISMISS_SECONDS) + +func _build_entry_node(entry: Dictionary) -> Control: + var category: String = entry.get("category", "default") + var color: Color = CATEGORY_COLORS.get(category, CATEGORY_COLORS["default"]) + var text: String = entry.get("text", "") + if entry.has("hex_pos"): + var btn: Button = Button.new() + btn.text = text + btn.flat = true + btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + btn.add_theme_font_size_override("font_size", 15) + btn.add_theme_color_override("font_color", color) + btn.add_theme_color_override("font_hover_color", color.lightened(0.25)) + var hex_pos: Vector2i = entry["hex_pos"] + btn.pressed.connect(_on_entry_clicked.bind(hex_pos)) + return btn + var lbl: Label = Label.new() + lbl.text = text + lbl.add_theme_font_size_override("font_size", 15) + lbl.add_theme_color_override("font_color", color) + lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return lbl + + +func _on_entry_clicked(hex_pos: Vector2i) -> void: + EventBus.chronicle_entry_clicked.emit(hex_pos) + + +func _build_filter_checkboxes() -> void: + _filter_checks.clear() + for filter_id: String in FILTER_ORDER: + var cb: CheckBox = CheckBox.new() + cb.name = "Filter_%s" % filter_id + cb.text = filter_id.capitalize() + cb.button_pressed = true + cb.add_theme_font_size_override("font_size", 12) + cb.toggled.connect(_on_filter_toggled.bind(filter_id)) + _filter_row.add_child(cb) + _filter_checks[filter_id] = cb + + +func _on_filter_toggled(pressed: bool, filter_id: String) -> void: + if filter_id == "all": + for other_id: String in _filter_state: + _filter_state[other_id] = pressed + if _filter_checks.has(other_id): + var cb: CheckBox = _filter_checks[other_id] as CheckBox + cb.set_pressed_no_signal(pressed) + else: + _filter_state[filter_id] = pressed + var all_cb: CheckBox = _filter_checks.get("all") as CheckBox + if all_cb != null: + all_cb.set_pressed_no_signal(_all_filters_on()) + _apply_filter() + + +func _all_filters_on() -> bool: + for key: String in _filter_state: + if not bool(_filter_state[key]): + return false + return true + + +func _entry_passes_filter(entry: Dictionary) -> bool: + var category: String = entry.get("category", "default") + for filter_id: String in FILTER_BUCKETS: + if filter_id == "all": + continue + var members: Array = FILTER_BUCKETS[filter_id] + if members.has(category): + return bool(_filter_state.get(filter_id, true)) + # Uncategorized ("default") is always shown so debug entries survive filters. + return true + + +func _apply_filter() -> void: + if not _log_panel.visible: + return + _rebuild_log_entries() + + +func get_visible_entries() -> Array: + var out: Array = [] + for entry: Dictionary in _entries: + if _entry_passes_filter(entry): + out.append(entry) + return out func _dismiss() -> void: @@ -199,9 +313,16 @@ func _dismiss() -> void: _is_processing = false -func _add_entry(text: String, category: String = "default") -> void: +func _add_entry(text: String, category: String = "default", hex_pos: Variant = null) -> void: if _is_processing: - _entries.append({"text": text, "category": category}) + _entries.append(_build_entry_dict(text, category, hex_pos)) + + +func _build_entry_dict(text: String, category: String, hex_pos: Variant) -> Dictionary: + var entry: Dictionary = {"text": text, "category": category} + if hex_pos is Vector2i: + entry["hex_pos"] = hex_pos + return entry func _on_dim_clicked(event: InputEvent) -> void: @@ -461,6 +582,34 @@ func _human_player_index() -> int: return -1 +## Sentinel for "no hex position available". Callers should check via +## `_has_hex_pos(v2) before attaching to an entry. +const HEX_POS_NONE: Vector2i = Vector2i(-9999, -9999) + + +## Extract an axial hex coordinate from any entity object. Entities may be +## UnitScript / CityScript / Dictionary; fields we try in order: `position`, +## `hex_pos`, `tile`. Returns HEX_POS_NONE if no coordinate found. +func _extract_hex_pos(obj: Variant) -> Vector2i: + if obj == null: + return HEX_POS_NONE + if obj is Dictionary: + var d: Dictionary = obj as Dictionary + for key: String in ["position", "hex_pos", "tile"]: + if d.has(key) and d[key] is Vector2i: + return d[key] as Vector2i + return HEX_POS_NONE + if obj is Object: + for key: String in ["position", "hex_pos", "tile"]: + if obj.get(key) is Vector2i: + return obj.get(key) as Vector2i + return HEX_POS_NONE + + +func _has_hex_pos(v: Vector2i) -> bool: + return v != HEX_POS_NONE + + func _extract_owner(obj: Variant) -> int: if obj == null: return -1 @@ -487,20 +636,12 @@ func _extract_owner(obj: Variant) -> int: ## Adds an entry even when _is_processing is false. Used by out-of-turn events ## (combat that happens on the player's own turn, victory, elimination) that ## must still show up in the chronicle. -func _add_entry_now(text: String, category: String = "default") -> void: - _entries.append({"text": text, "category": category}) +func _add_entry_now(text: String, category: String = "default", hex_pos: Variant = null) -> void: + var entry: Dictionary = _build_entry_dict(text, category, hex_pos) + _entries.append(entry) if not _is_processing and visible and _log_panel.visible: - _append_live_entry(text, category) - - -func _append_live_entry(text: String, category: String) -> void: - var color: Color = CATEGORY_COLORS.get(category, CATEGORY_COLORS["default"]) - var entry_label: Label = Label.new() - entry_label.text = text - entry_label.add_theme_font_size_override("font_size", 15) - entry_label.add_theme_color_override("font_color", color) - entry_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _log_vbox.add_child(entry_label) + if _entry_passes_filter(entry): + _log_vbox.add_child(_build_entry_node(entry)) func get_entry_count() -> int: diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 72278b45..daca4f2f 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -945,8 +945,13 @@ func _play_turn() -> void: if u.type_id == "dwarf_scout" and _turn_count <= 20: _explore(u, player, game_map) else: - # Fortify at city β€” do NOT attack or chase enemies - if u.position != city_pos: + # Scouts and warriors seek low-tier lairs during build phase. + var lair_target: Vector2i = Vector2i(-1, -1) + var lair_max_tier: int = 2 if u.type_id == "dwarf_scout" else 3 + lair_target = _find_nearest_low_lair(u.position, lair_max_tier) + if lair_target != Vector2i(-1, -1): + _move_toward(u, lair_target, game_map) + elif u.position != city_pos: _move_toward(u, city_pos, game_map) elif not u.is_fortified: u.is_fortified = true @@ -1410,6 +1415,24 @@ func _nearest_city_to_target(player: RefCounted) -> RefCounted: return best_city +func _find_nearest_low_lair(from: Vector2i, max_tier: int) -> Vector2i: + var best: Vector2i = Vector2i(-1, -1) + var best_dist: int = 999 + var wilds_cfg: Dictionary = DataLoader.get_wilds_config() + var low_ids: Array[String] = [] + for lt: Dictionary in wilds_cfg.get("lair_types", []): + if lt.get("base_tier", 99) <= max_tier: + low_ids.append(lt.get("id", "")) + for b: Variant in GameState.npc_buildings: + if b.type_id not in low_ids: + continue + var d: int = HexUtilsScript.hex_distance(from, b.position) + if d < best_dist: + best_dist = d + best = b.position + return best + + func _find_attack_target(player: RefCounted) -> Vector2i: # Find nearest enemy city reachable by land path var game_map: RefCounted = GameState.get_game_map() diff --git a/src/game/engine/src/autoloads/event_bus.gd b/src/game/engine/src/autoloads/event_bus.gd index 637228dd..44c2594f 100644 --- a/src/game/engine/src/autoloads/event_bus.gd +++ b/src/game/engine/src/autoloads/event_bus.gd @@ -105,6 +105,9 @@ signal chronicle_closed(player_index: int) ## Emitted when a chronicle frame has been loaded from the observation store ## and is ready for the panel to render. Payload is the frame buffer dict. signal chronicle_frame_ready(frame_data: Dictionary) +## Emitted when the player clicks a chronicle log entry that has a hex_pos. +## world_map camera listens and pans to the tile. +signal chronicle_entry_clicked(hex_pos: Vector2i) # -- UI signals -- signal camera_moved(position: Vector2) diff --git a/src/game/export_presets.cfg b/src/game/export_presets.cfg index ef02e10e..4e4895a4 100644 --- a/src/game/export_presets.cfg +++ b/src/game/export_presets.cfg @@ -32,7 +32,7 @@ script_export_mode=2 custom_template/debug="" custom_template/release="" debug/export_console_wrapper=1 -binary_format/embed_pck=false +binary_format/embed_pck=true texture_format/bptc=true texture_format/s3tc=true texture_format/etc=false @@ -190,7 +190,7 @@ script_export_mode=2 custom_template/debug="" custom_template/release="" debug/export_console_wrapper=1 -binary_format/embed_pck=false +binary_format/embed_pck=true binary_format/architecture="x86_64" codesign/enable=false codesign/timestamp=true