feat(game): ✨ update chronicle coverage milestone status
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
862f239b8f
commit
748e7cdd14
7 changed files with 218 additions and 42 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue