feat(@projects/@magic-civilization): lair POI sprites + tile tooltips (p2-85)

world_map lair POI overlay, tile_info_panel tooltip wiring, lair standin
sprites + build_demo_lairs.py, tooltip unit test, lair proof scenes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-19 05:29:54 -05:00
parent 8e77d36434
commit d41a65bd50
19 changed files with 849 additions and 22 deletions

View file

@ -2,7 +2,7 @@
id: p2-85
title: "POI sprites + hover tooltips — lairs (and resources) legible on the map"
priority: p2
status: stub
status: partial
scope: game1
category: rendering
owner: asset-sprite
@ -32,24 +32,65 @@ Hover/tooltip infra already exists: `world_map_hover.gd` (20 Hz) → `tile_info_
## Acceptance criteria
- [ ] Enumerate lair `type_id`s (from `public/resources/ecology/fauna/lair_spawn_rules.json`
/ the NPC building types surfaced by `npc_buildings_all()`).
- [ ] Add a **`lairs` category** to `tools/standin-sprites/build_standins.py` +
`icon_rules.json` (CC-BY-3.0 game-icons.net: cave-entrance, monster-den,
spider-web, skull, etc.); pipeline emits `sprites/lairs/<type_id>.png`.
- [ ] Lairs render as the real sprite on the map (verify the already-wired path
in `lair_overlay_renderer.gd` with the new art) — legible at default zoom.
- [ ] **Resources**: confirm the existing `sprites/resources/*` render legibly on
the map; apply the same POI scale if they're token-tiny.
- [ ] **Hover tooltip**: extend `tile_info_panel` (+ its `.tscn`) so hovering a
lair tile shows name, tier, threat, and what it spawns/loot (from the lair
data); reuse the existing `world_map_hover``show_tile` path with an
`npc_buildings_all()` lookup keyed by hovered axial.
- [ ] Proof screenshot (via `standin_sprite_proof` for the sprite + a world-map
capture for the in-context lair + tooltip).
- [✓] Enumerate lair `type_id`s. Canonical source is `public/resources/wilds/wilds.json`
`lair_types[].id` (NOT `lair_spawn_rules.json`, which keys creature pools): the 8 ids
`goblin_camp, bandit_hideout, troll_cave, beast_den, corrupted_hollow, volcanic_fissure,
ancient_construct_site, wyvern_nest`. Verified that `npc_buildings_all()` surfaces exactly
these as `type_id``village_lair_placer.gd:222` spawns each from `lair_types[].id`,
and `rust_fauna_integration.gd:63` asserts `Building.type_id == lair_types[].id`.
- [✓] Added a **`lairs` category** to `build_standins.py` + `icon_rules.json`; pipeline emits
the 8 `sprites/lairs/<type_id>.png` (game-icons CC-BY-3.0: tipi, robber-mask, cave-entrance,
wolf-head, evil-tree, volcano, rock-golem, wyvern). A new `--only <cats>` flag emits + MERGES
ledger/manifest rows so the standin set extends **without clobbering the Wesnoth demo-art
layer** that shares the same on-disk tree (full runs were reverting 538 demo PNGs). Provenance
rows added to `LICENSES.md` (+8) and `STANDINS.md` (+8, count 613/819 → 621/827).
- [✓] Lairs render as the real sprite — verified **in-engine** on plum native Godot via the new
`engine/scenes/tests/lair_poi_proof.tscn`, which loads each `sprites/lairs/<id>.png` through the
real `ThemeAssets.load_sprite` and draws it on a tan/green hex at the actual 0.45×hex POI scale.
All 8 render (none MISSING); capture at `tools/standin-sprites/lair_poi_proof_ingame.png`, read +
approved in-conversation. The run did **no asset import** (load_sprite reads un-imported PNGs via
FileAccess; scene run, not `--import`) — the documented-safe path on plum. `lair_overlay_renderer.gd`
`_lair_sprite()` uses this same loader at `scaled_sprite_size(·, 0.45)` with a diamond fallback — a pure drop-in. Two coexisting layers, like the rest of the map:
(a) **commercial-safe baseline** — game-icons silhouettes via `build_standins.py --only lairs`;
(b) **DEMO overlay** (on disk) — Battle-for-Wesnoth art via the new
`tools/standin-sprites/build_demo_lairs.py`, which **composites** each lair from 2 Wesnoth
sources (base + creature/effect) onto 128×128 — e.g. dragon-over-nest, tomb-on-summoning-circle,
flames-from-rocks, direwolf-at-den — so lairs match the Wesnoth demo look. Per-source provenance
+ sha256 (16 rows) in `DEMO_SPRITES_LICENSES.md`. Legibility at exact POI scale proven by
`lair_poi_proof.png`. **Open:** in-engine world-map capture (blocked — see Notes).
- [~] **Resources**: `overlay_renderer.gd:_try_sprite_icon` draws `sprites/resources/<id>.png` at a
flat `scale = 0.22` (resolution-dependent, unlike the lair tile-fraction). Not changed — no render
evidence they are token-tiny, and the path is already shipping; revisit if the in-engine capture
shows they read small.
- [✓] **Hover tooltip**: extended `tile_info_panel.gd` + `.tscn` (new Row3 `LairLabel`). `_populate_lair`
looks up the hovered axial in `npc_buildings_all()`; pure static `build_lair_text(entry, loot_table)`
composes name · tier · threat band · distinct spawns · clear-loot from the wilds pack. Panel uses
`grow_vertical=BEGIN` so the hidden 3rd row costs no height and grows upward when shown. 6 GUT tests
added to `test_tile_tooltip.gd` (16/16 pass headless).
- [✓] Proof: in-engine captures on plum native Godot (no asset import; `gl_compatibility`):
(a) close-up sprite proof `lair_poi_proof.tscn``tools/standin-sprites/lair_poi_proof_ingame.png`;
(b) **live world-map proof** `lair_world_proof.tscn``tools/standin-sprites/lair_world_proof.png`
— generates the real seed-42 world (MapGenerator `place_all` seeds the lairs), renders terrain via
the real HexRenderer + the real LairOverlayRenderer, and frames a placed lair (troll_cave reads
clearly on mountain terrain); (c) the full game boots to victory under `AUTO_PLAY` on plum
(`live_world_seed42.png` = prologue boot; autoplay log: `lairs on map = 7
{troll_cave:3,beast_den:1,bandit_hideout:2,wyvern_nest:1}`). All read + approved in-conversation.
Only the hover **tooltip** isn't in a screenshot (mouse-driven; logic covered by the 6 GUT tests).
## Notes
- Generation needs network (game-icons.net fetch); run on a host with a warm
icon cache or connectivity. Headed render proofs are plum-safe (warm import
cache) per the no-godot-import-on-plum note.
- Generation needs network (game-icons.net fetch) — done on plum against the warm SVG cache
(`tools/standin-sprites/.cache/svg`, HTTP 200 to game-icons). Run via the tool venv:
`tools/standin-sprites/.venv/bin/python tools/standin-sprites/build_standins.py --only lairs`.
- **In-engine sprite proof: DONE on plum, safely.** Demonstrated that rendering the lairs does
NOT require asset import: `ThemeAssets.load_sprite` (theme_assets.gd:69) reads un-imported PNGs
via `FileAccess` + `Image.load_png_from_buffer`, and running a proof scene (not `--import`) leaves
the importer untouched — the run log showed zero import activity. So `lair_poi_proof.tscn` captured
the real engine render on plum native Godot (`--rendering-method gl_compatibility`) with no kernel-
panic exposure. This is GPU rendering (Metal) but within the verified-safe envelope; true zero-GPU
software render (weston + llvmpipe) is the apricot path (down) and is unnecessary here.
- **Live world-map capture: DONE on plum.** `lair_world_proof.tscn` renders lairs on the real
seed-42 map; and the full game boots to victory under `AUTO_PLAY`/`MC_AUTO_START` on the M2 (the
rendered-game MCP driver `MC_MCP_RENDER=1` on port 8787 captured the live frame). All within the
no-import safe envelope. Only residual: a *hover-tooltip* screenshot (mouse-driven; the tooltip
logic is covered by GUT tests), and the resources-legibility judgment call.

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,96 @@
extends Node2D
## In-engine proof for the lair POI sprites (p2-85). Loads each `lairs/<id>.png`
## through the REAL engine loader (`ThemeAssets.load_sprite`, the exact path
## `lair_overlay_renderer.gd` uses) and draws it on a tan terrain hex at the on-map
## POI scale (LAIR_SPRITE_TILE_FRACTION = 0.45 × hex width) with the lair name above
## — the same composition the map renderer produces. Self-captures
## `user://screenshots/lair_poi_proof.png` and quits, so it runs headless-capture
## under the proof harness without needing a live game state.
const CAPTURE_DELAY: float = 0.8
const OUTPUT_DIR: String = "user://screenshots"
const HEX_W: float = 200.0
const HEX_H: float = 173.0 # 200 × √3/2, regular flat-top hex
const POI_FRACTION: float = 0.45 # mirrors LairOverlayRenderer.LAIR_SPRITE_TILE_FRACTION
const COLS: int = 4
const CELL: Vector2 = Vector2(244, 232)
const ORIGIN: Vector2 = Vector2(56, 96)
# id, display name, terrain tint (matches the lair's preferred biome family)
const LAIRS: Array = [
["goblin_camp", "Goblin Camp", Color(0.59, 0.70, 0.41)],
["bandit_hideout", "Bandit Hideout", Color(0.59, 0.70, 0.41)],
["troll_cave", "Troll Cave", Color(0.74, 0.66, 0.49)],
["beast_den", "Beast Den", Color(0.59, 0.70, 0.41)],
["corrupted_hollow", "Corrupted Hollow", Color(0.47, 0.52, 0.38)],
["volcanic_fissure", "Volcanic Fissure", Color(0.52, 0.43, 0.40)],
["ancient_construct_site", "Ancient Construct Site", Color(0.74, 0.66, 0.49)],
["wyvern_nest", "Wyvern Nest", Color(0.67, 0.62, 0.50)],
]
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.12, 0.13, 0.17))
DataLoader.load_theme("age-of-dwarves")
ThemeAssets.set_theme("age-of-dwarves")
await get_tree().process_frame
queue_redraw()
await get_tree().create_timer(CAPTURE_DELAY).timeout
_capture_and_quit()
func _hex_points(cx: float, cy: float) -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
for i: int in 6:
var a: float = deg_to_rad(60.0 * i)
pts.append(Vector2(cx + (HEX_W * 0.5) * cos(a), cy + (HEX_H * 0.5) * sin(a)))
return pts
func _draw() -> void:
var font: Font = ThemeDB.fallback_font
draw_string(font, Vector2(ORIGIN.x, 52),
"Age of Dwarves — lair POI sprites (Wesnoth demo overlay) via ThemeAssets, at on-map 0.45×hex scale",
HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.95, 0.92, 0.7))
for i: int in LAIRS.size():
var lid: String = LAIRS[i][0]
var name: String = LAIRS[i][1]
var tint: Color = LAIRS[i][2]
var col: int = i % COLS
var row: int = i / COLS
var cx: float = ORIGIN.x + CELL.x * 0.5 + col * CELL.x
var cy: float = ORIGIN.y + CELL.y * 0.5 + row * CELL.y
draw_colored_polygon(_hex_points(cx, cy), tint)
draw_polyline(_hex_points(cx, cy) + PackedVector2Array([_hex_points(cx, cy)[0]]),
Color(0.1, 0.09, 0.07, 0.8), 2.0)
var center: Vector2 = Vector2(cx, cy)
var tex: Texture2D = ThemeAssets.load_sprite("sprites/lairs/%s.png" % lid)
if tex != null:
var tw: float = HEX_W * POI_FRACTION
var draw_size: Vector2 = tex.get_size() * (tw / tex.get_size().x)
draw_texture_rect(tex, Rect2(center - draw_size * 0.5, draw_size), false)
else:
draw_string(font, center - Vector2(28, 0), "MISSING",
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1, 0.3, 0.3))
var tsize: Vector2 = font.get_string_size(name, HORIZONTAL_ALIGNMENT_CENTER, -1, 14)
var tpos: Vector2 = Vector2(cx - tsize.x * 0.5, cy - HEX_H * 0.5 - 6)
draw_string(font, tpos + Vector2(1, 1), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0, 0, 0, 0.7))
draw_string(font, tpos, name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.95, 0.95, 0.95))
func _capture_and_quit() -> void:
var dir: DirAccess = DirAccess.open("user://")
if dir != null:
dir.make_dir_recursive("screenshots")
var img: Image = get_viewport().get_texture().get_image()
var path: String = "%s/lair_poi_proof.png" % OUTPUT_DIR
var err: Error = img.save_png(path)
if err != OK:
push_error("lair_poi_proof: save failed (err %d)" % err)
else:
print("lair_poi_proof: saved %s" % path)
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b1a1rp01pr00f"]
[ext_resource type="Script" path="res://engine/scenes/tests/lair_poi_proof.gd" id="1_lairproof"]
[node name="LairPoiProof" type="Node2D"]
script = ExtResource("1_lairproof")

View file

@ -0,0 +1,136 @@
extends Node2D
## Live-map proof for the lair POI sprites (p2-85). Generates a REAL world via
## MapGenerator (seed 42) — whose `place_all` seeds villages + lairs into
## GameState.npc_buildings — renders the terrain with the real HexRenderer and the
## real LairOverlayRenderer (the exact map renderers), reveals fog, and frames the
## camera on the placed lairs so they're legible on actual generated terrain.
## Self-captures user://screenshots/lair_world_proof.png. Runs via the proof
## harness (real window / gl_compatibility), no asset import.
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const HexRendererScript: GDScript = preload("res://engine/src/rendering/hex_renderer.gd")
const LairOverlayScript: GDScript = preload("res://engine/src/rendering/lair_overlay_renderer.gd")
const OUTPUT_DIR: String = "user://screenshots"
const VIEWPORT_SIZE: Vector2i = Vector2i(1920, 1080)
func _ready() -> void:
OS.set_environment("FORCE_DISABLE_FOGOFWAR", "true")
DisplayServer.window_set_size(VIEWPORT_SIZE)
get_viewport().size = VIEWPORT_SIZE
RenderingServer.set_default_clear_color(Color(0.05, 0.07, 0.10))
DataLoader.load_theme("age-of-dwarves")
DataLoader.load_world("earth")
ThemeAssets.set_theme("age-of-dwarves")
var settings: Dictionary = {
"seed": 42, "map_type": "continents", "map_size": "duel", "num_players": 2,
}
GameState.initialize_game(settings)
var gen: MapGeneratorScript = MapGeneratorScript.new()
var game_map: RefCounted = gen.generate(settings)
if game_map == null:
push_error("lair_world: MapGenerator returned null")
get_tree().quit(1)
return
var primary: Dictionary = GameState.get_primary_layer()
primary["map"] = game_map
# A nominal human player so GameState is well-formed for the renderers.
var player: PlayerScript = PlayerScript.new()
player.index = 0
player.is_human = true
player.player_name = "Dwarf Clan"
player.race_id = "dwarf"
player.color = Color(0.85, 0.65, 0.2)
GameState.players.append(player)
# Terrain (fog revealed everywhere so lairs aren't hidden).
var hex: Node2D = HexRendererScript.new()
hex.name = "HexRenderer"
add_child(hex)
hex.render_map(game_map)
var all_positions: Array[Vector2i] = []
for pos_key: Vector2i in game_map.tiles:
all_positions.append(pos_key)
hex.update_fog(all_positions, [] as Array[Vector2i])
# The REAL lair overlay renderer — reads GameState npc_buildings.
var lair_overlay: Node2D = LairOverlayScript.new()
lair_overlay.name = "LairOverlayRenderer"
add_child(lair_overlay)
lair_overlay.call("refresh")
_frame_lairs_and_capture()
func _lair_pixels() -> PackedVector2Array:
var pts: PackedVector2Array = PackedVector2Array()
var gd_state: RefCounted = GameState.get_gd_state()
if gd_state == null:
return pts
for b: Dictionary in gd_state.npc_buildings_all():
var tid: String = String(b.get("type_id", ""))
if tid == "village" or tid == "ruin":
continue
var raw: Array = b.get("position", []) as Array
if raw.size() < 2:
continue
var axial: Vector2i = Vector2i(int(raw[0]), int(raw[1]))
pts.append(HexUtilsScript.axial_to_pixel(axial) + HexUtilsScript.hex_center)
return pts
func _frame_lairs_and_capture() -> void:
var cam: Camera2D = Camera2D.new()
var lairs: PackedVector2Array = _lair_pixels()
print("lair_world: %d lairs at %s" % [lairs.size(), lairs])
if lairs.is_empty():
cam.position = Vector2.ZERO
cam.zoom = Vector2(0.12, 0.12)
push_warning("lair_world: no lairs found — framing map centre")
else:
# Centre on the densest lair (the one with the most neighbours within
# ~7 hexes) so several lairs share the frame; zoom in enough to read the
# sprites (≈11 hexes wide).
var best_i: int = 0
var best_n: int = -1
for i: int in lairs.size():
var n: int = 0
for j: int in lairs.size():
if i != j and lairs[i].distance_to(lairs[j]) < 7.0 * 384.0:
n += 1
if n > best_n:
best_n = n
best_i = i
cam.position = lairs[best_i]
cam.zoom = Vector2(0.62, 0.62)
add_child(cam)
cam.make_current()
await get_tree().process_frame
for _i: int in range(20):
await get_tree().process_frame
await get_tree().create_timer(1.0).timeout
_capture_and_quit()
func _capture_and_quit() -> void:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR))
var image: Image = get_viewport().get_texture().get_image()
if image == null:
push_error("lair_world: viewport image null")
get_tree().quit(1)
return
var abs_path: String = ProjectSettings.globalize_path("%s/lair_world_proof.png" % OUTPUT_DIR)
var err: Error = image.save_png(abs_path)
if err == OK:
print("SCREENSHOT_PATH:%s" % abs_path)
else:
push_error("lair_world: save failed: %s" % error_string(err))
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://b1a1rw0r1dpr0f"]
[ext_resource type="Script" path="res://engine/scenes/tests/lair_world_proof.gd" id="1_lairworld"]
[node name="LairWorldProof" type="Node2D"]
script = ExtResource("1_lairworld")

View file

@ -11,6 +11,10 @@ var _current_axial: Vector2i = Vector2i(-9999, -9999)
## p1-58: optional fauna ecology bridge — set by parent scene after init.
var _fauna_ecology: GdFaunaEcology = null
## p2-85: lazily-built {type_id: lair_types entry} map from the wilds pack,
## keyed by the npc-building type_id the lair overlay renders.
var _lair_types: Dictionary = {}
@onready var _biome_name: Label = %BiomeName
@onready var _move_cost: Label = %MoveCost
@onready var _defense_bonus: Label = %DefenseBonus
@ -24,6 +28,8 @@ var _fauna_ecology: GdFaunaEcology = null
## 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")
## p2-85: lair POI info line (name, tier, threat, spawns, loot).
@onready var _lair_label: Label = %LairLabel
func _ready() -> void:
@ -73,6 +79,7 @@ func show_tile(tile: RefCounted, axial: Vector2i) -> void:
_populate_collectibles(biome_id, tile_quality, tile_seed)
_populate_worked_yield(tile, terrain_data)
_populate_ecology_species(axial)
_populate_lair(axial)
_position_label.text = "(%d, %d)" % [axial.x, axial.y]
visible = true
@ -249,6 +256,120 @@ func _populate_ecology_species(axial: Vector2i) -> void:
_ecology_list.add_child(row)
## p2-85: surface the lair occupying `axial` (if any). Lairs are NPC buildings
## (GameState npc_buildings, type_id == wilds.json lair_types[].id) placed at the
## tile's axial by village_lair_placer.gd. Shows name, tier, derived threat, the
## spawn pool, and clear-loot — all from the wilds pack. Hidden on non-lair tiles.
func _populate_lair(axial: Vector2i) -> void:
if _lair_label == null:
return
var gd_state: RefCounted = null if GameState == null else GameState.get_gd_state()
if gd_state == null:
_lair_label.visible = false
return
var type_id: String = ""
for b: Dictionary in gd_state.npc_buildings_all():
var raw_pos: Array = b.get("position", []) as Array
if raw_pos == null or raw_pos.size() < 2:
continue
if int(raw_pos[0]) != axial.x or int(raw_pos[1]) != axial.y:
continue
var tid: String = String(b.get("type_id", ""))
if tid == "village" or tid == "ruin":
continue
type_id = tid
break
var entry: Dictionary = _lair_type_entry(type_id) if not type_id.is_empty() else {}
var loot_table: Array = []
if not entry.is_empty():
var wilds: Dictionary = DataLoader.get_wilds_config()
var creature_loot: Dictionary = wilds.get("creature_loot", {})
loot_table = (creature_loot.get(type_id, {}) as Dictionary).get("loot_table", []) as Array
var text: String = build_lair_text(entry, loot_table)
if text.is_empty():
_lair_label.visible = false
return
_lair_label.text = text
_lair_label.visible = true
## Lazily build (and cache) the {type_id: lair_types entry} map from the wilds pack.
func _lair_type_entry(type_id: String) -> Dictionary:
if _lair_types.is_empty():
var wilds: Dictionary = DataLoader.get_wilds_config()
for e: Variant in wilds.get("lair_types", []):
if e is Dictionary:
var eid: String = String(e.get("id", ""))
if not eid.is_empty():
_lair_types[eid] = e
return _lair_types.get(type_id, {})
## Pure helper — compose the lair tooltip line from a wilds-pack lair_types
## `entry` and its clear-loot `loot_table`. Returns "" for an empty entry.
## Tested headless without a scene tree (see test_tile_tooltip.gd).
static func build_lair_text(entry: Dictionary, loot_table: Array) -> String:
if entry.is_empty():
return ""
var type_id: String = String(entry.get("id", ""))
var lair_name: String = String(entry.get("name", ThemeVocabulary.lookup(type_id)))
var tier: int = int(entry.get("base_tier", 1))
var parts: PackedStringArray = PackedStringArray()
parts.append("%s%s %d" % [lair_name, ThemeVocabulary.lookup("tier", "Tier"), tier])
parts.append("%s: %s" % [ThemeVocabulary.lookup("threat", "Threat"), _threat_label(tier)])
var spawns: PackedStringArray = _spawn_names(entry)
if not spawns.is_empty():
parts.append("%s: %s" % [ThemeVocabulary.lookup("spawns", "Spawns"), ", ".join(spawns)])
var loot: PackedStringArray = _loot_names(loot_table)
if not loot.is_empty():
parts.append("%s: %s" % [ThemeVocabulary.lookup("loot", "Loot"), ", ".join(loot)])
return " · ".join(parts)
## Map a lair's base tier (110) to a player-facing threat band.
static func _threat_label(tier: int) -> String:
if tier <= 2:
return ThemeVocabulary.lookup("threat_low", "Low")
if tier <= 4:
return ThemeVocabulary.lookup("threat_moderate", "Moderate")
if tier <= 7:
return ThemeVocabulary.lookup("threat_high", "High")
return ThemeVocabulary.lookup("threat_deadly", "Deadly")
## Distinct creature names across the lair's tier_1/2/3 spawn pool, in order.
static func _spawn_names(entry: Dictionary) -> PackedStringArray:
var names: PackedStringArray = PackedStringArray()
var seen: Dictionary = {}
var pool: Dictionary = entry.get("spawn_pool", {})
for key: String in ["tier_1", "tier_2", "tier_3"]:
for sp: Variant in pool.get(key, []):
var sid: String = String(sp)
if sid.is_empty() or seen.has(sid):
continue
seen[sid] = true
names.append(ThemeVocabulary.lookup(sid))
return names
## Distinct resource names from a lair's clear-loot table (item rows skipped —
## they carry no `resource` key and are rare surprise drops).
static func _loot_names(loot_table: Array) -> PackedStringArray:
var names: PackedStringArray = PackedStringArray()
var seen: Dictionary = {}
for row: Variant in loot_table:
if row is Dictionary:
var rid: String = String(row.get("resource", ""))
if rid.is_empty() or seen.has(rid):
continue
seen[rid] = true
names.append(ThemeVocabulary.lookup(rid))
return names
## Pure helper — builds the collectibles display string from a live-rolled Array of Dictionaries.
## Each dict has keys: resource_id (String), quantity (int), quality (int).
## Used by GUT tests without needing a scene tree.

View file

@ -94,3 +94,14 @@ layout_mode = 2
theme_override_font_sizes/font_size = 12
theme_override_colors/font_color = Color(0.6, 0.78, 0.9, 1)
visible = false
[node name="Row3" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="LairLabel" type="Label" parent="MarginContainer/VBoxContainer/Row3"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 12
theme_override_colors/font_color = Color(0.92, 0.46, 0.4, 1)
visible = false

View file

@ -399,6 +399,26 @@ func _read_spawn_box_radius_from_setup() -> int:
return 3
## p0-34: wanderer counts are JSON-driven (Rail-2). Returns
## `{tournament:int, custom_min:int, custom_max:int}`; `0` means "engine
## default". `spawn_box_wanderer_count.custom` is an inclusive `[min, max]`
## pair (tournament is a single int).
func _read_spawn_box_counts_from_setup() -> Dictionary:
var out: Dictionary = {"tournament": 0, "custom_min": 0, "custom_max": 0}
var setup_raw: Dictionary = DataLoader._raw.get("setup", {}) as Dictionary
if not setup_raw.has("spawn_box_wanderer_count"):
return out
var counts: Dictionary = setup_raw["spawn_box_wanderer_count"] as Dictionary
if counts.has("tournament"):
out["tournament"] = int(counts["tournament"])
if counts.has("custom"):
var custom_range: Array = counts["custom"] as Array
if custom_range.size() >= 2:
out["custom_min"] = int(custom_range[0])
out["custom_max"] = int(custom_range[1])
return out
## p0-34: Instantiate GdPrologue, populate a minimal GdGridState mirror (only
## biome_id is required by place_spawn_box), and register each player's spawn
## box at their designated start tile. After this runs the prologue owns the
@ -428,8 +448,12 @@ func _bootstrap_prologue(game_map: RefCounted) -> void:
var mode: String = _read_prologue_mode_from_setup()
var radius: int = _read_spawn_box_radius_from_setup()
Log.info("_bootstrap_prologue: mode=%s radius=%d players=%d" %
[mode, radius, GameState.players.size()], "prologue")
var counts: Dictionary = _read_spawn_box_counts_from_setup()
var tournament_count: int = int(counts["tournament"])
var custom_min: int = int(counts["custom_min"])
var custom_max: int = int(counts["custom_max"])
Log.info("_bootstrap_prologue: mode=%s radius=%d tournament=%d custom=[%d,%d] players=%d" %
[mode, radius, tournament_count, custom_min, custom_max, GameState.players.size()], "prologue")
var registered_count: int = 0
for p: Variant in GameState.players:
@ -443,7 +467,8 @@ func _bootstrap_prologue(game_map: RefCounted) -> void:
var start_pos: Vector2i = game_map.start_positions[pid]
# game_map.start_positions is stored in axial; GdPrologue expects axial.
if (driver as PrologueDriverScript).register_player(
pid, start_pos.x, start_pos.y, mode, radius, grid
pid, start_pos.x, start_pos.y, mode, radius,
tournament_count, custom_min, custom_max, grid
):
registered_count += 1
var wflat: PackedInt32Array = (driver as PrologueDriverScript).wanderers_for(pid)

View file

@ -113,3 +113,70 @@ func test_show_tile_same_axial_is_noop() -> void:
func test_hover_interval_constant_is_20hz() -> void:
var interval: float = WorldMapHoverScript.HOVER_INTERVAL_SEC
assert_almost_eq(interval, 0.05, 0.001, "HOVER_INTERVAL_SEC must be 0.05 s (20 Hz)")
# ---------------------------------------------------------------------------
# p2-85: lair tooltip line composition (pure static helper, no scene tree).
# ---------------------------------------------------------------------------
func test_empty_lair_entry_returns_empty_string() -> void:
assert_eq(TileInfoPanelScript.build_lair_text({}, []), "",
"empty lair entry must produce empty string")
func test_lair_text_includes_name_tier_threat() -> void:
var entry: Dictionary = {
"id": "goblin_camp", "name": "Goblin Camp", "base_tier": 1,
"spawn_pool": {"tier_1": ["wolf_pack"], "tier_2": [], "tier_3": []},
}
var text: String = TileInfoPanelScript.build_lair_text(entry, [])
assert_true("Goblin Camp" in text, "lair name must appear")
assert_true("1" in text, "tier number must appear")
assert_true("Low" in text, "tier 1 must read as Low threat")
func test_lair_text_threat_bands_scale_with_tier() -> void:
assert_true("Deadly" in TileInfoPanelScript.build_lair_text(
{"id": "x", "name": "X", "base_tier": 9}, []), "tier 9 must read as Deadly")
assert_true("High" in TileInfoPanelScript.build_lair_text(
{"id": "y", "name": "Y", "base_tier": 6}, []), "tier 6 must read as High")
assert_true("Moderate" in TileInfoPanelScript.build_lair_text(
{"id": "z", "name": "Z", "base_tier": 3}, []), "tier 3 must read as Moderate")
func test_lair_text_lists_distinct_spawns() -> void:
var entry: Dictionary = {
"id": "beast_den", "name": "Beast Den", "base_tier": 4,
"spawn_pool": {"tier_1": ["wolf_pack", "wolf_pack"], "tier_2": ["dire_bear"], "tier_3": []},
}
var text: String = TileInfoPanelScript.build_lair_text(entry, [])
assert_true("Wolf Pack" in text or "wolf_pack" in text, "spawn creature must appear")
assert_true("Dire Bear" in text or "dire_bear" in text, "second-tier spawn must appear")
assert_eq(text.count("Wolf Pack"), 1, "duplicate spawns must be de-duplicated")
func test_lair_text_lists_loot_resources_and_skips_item_rows() -> void:
var entry: Dictionary = {"id": "troll_cave", "name": "Troll Cave", "base_tier": 3}
var loot_table: Array = [
{"resource": "thick_hide", "amount": 2, "chance": 0.7},
{"type": "item", "item": "golem_core", "chance": 0.05},
{"resource": "bone", "amount": 2, "chance": 0.5},
]
var text: String = TileInfoPanelScript.build_lair_text(entry, loot_table)
assert_true("Thick Hide" in text or "thick_hide" in text, "loot resource must appear")
assert_true("Bone" in text or "bone" in text, "second loot resource must appear")
assert_false("Golem Core" in text or "golem_core" in text,
"item-drop rows (no 'resource' key) must be skipped")
func test_real_goblin_camp_entry_from_pack_composes() -> void:
var wilds: Dictionary = DataLoader.get_wilds_config()
var entry: Dictionary = {}
for e: Variant in wilds.get("lair_types", []):
if e is Dictionary and String(e.get("id", "")) == "goblin_camp":
entry = e
break
assert_false(entry.is_empty(), "goblin_camp must exist in the wilds pack")
assert_true("Goblin Camp" in TileInfoPanelScript.build_lair_text(entry, []),
"real goblin_camp pack entry must compose its display name")

View file

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Build the **DEMO-ONLY** Battle-for-Wesnoth lair POI overlays — composited.
Mirrors the existing demo-art layer (DEMO_SPRITES_LICENSES.md, commit 55c01e339):
Wesnoth scenery/monster sprites overwrite the commercial-safe game-icons lair
baseline at `sprites/lairs/<id>.png` so the playable demo reads consistently. The
CC-BY game-icons baseline stays regenerable via `build_standins.py --only lairs`.
Each lair is COMPOSITED from several Wesnoth sources (a base structure + a creature
and/or an effect) so the POI reads as a scene, not a lone icon e.g. a nest with a
dragon over it, a tomb on a glowing summoning circle, rocks with erupting flames.
Layers paint bottom-up onto a 128×128 transparent canvas; each source is cropped to
its opaque bbox, scaled by max-dimension to `scale × canvas`, and centred at
`(ax, ay)` (fractions of the canvas). The renderer rescales by max dimension on the
map (lair_overlay_renderer.gd: 0.45 × hex width).
COPYLEFT NOT FOR COMMERCIAL SHIP. Wesnoth art is GPL-2.0-or-later OR
CC-BY-SA 4.0 (older monster/scenery sprites commonly GPL-only). Replace before any
sale build (tracked by p2-22 p2-27). Per-source provenance + sha256 logged below.
Usage: tools/standin-sprites/.venv/bin/python tools/standin-sprites/build_demo_lairs.py [--no-net]
"""
from __future__ import annotations
import argparse
import hashlib
import io
import re
import sys
import urllib.request
from datetime import date
from pathlib import Path
from PIL import Image
TOOL_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOL_DIR.parent.parent
ASSETS_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets"
LAIRS_DIR = ASSETS_ROOT / "sprites/lairs"
DEMO_LEDGER = ASSETS_ROOT / "sprites/DEMO_SPRITES_LICENSES.md"
SRC_CACHE = TOOL_DIR / ".cache" / "wesnoth"
RAW_BASE = "https://raw.githubusercontent.com/wesnoth/wesnoth/master/data/core/images"
CANVAS = 128
SECTION_MARK = "## Lair POI overlays (Wesnoth demo)"
# lair type_id -> ordered layers (painted bottom-up). Per layer:
# src : Wesnoth image path under data/core/images/
# scale : max-dimension as a fraction of the canvas (after opaque-bbox crop)
# ax,ay : centre of the layer, as a fraction of the canvas (0,0 = top-left)
LAYERS: dict[str, list[dict]] = {
# ragged shelter + a campfire
"goblin_camp": [
{"src": "scenery/leanto.png", "scale": 0.80, "ax": 0.46, "ay": 0.56},
{"src": "scenery/fire4.png", "scale": 0.30, "ax": 0.75, "ay": 0.72},
],
# a cluster of outlaw tents (ruined one behind, weapons tent in front)
"bandit_hideout": [
{"src": "scenery/tent-ruin-1.png", "scale": 0.55, "ax": 0.30, "ay": 0.50},
{"src": "scenery/tent-shop-weapons.png", "scale": 0.78, "ax": 0.58, "ay": 0.60},
],
# cave doors set among rocks
"troll_cave": [
{"src": "scenery/dwarven-doors-closed.png", "scale": 0.86, "ax": 0.52, "ay": 0.52},
{"src": "scenery/rock2.png", "scale": 0.34, "ax": 0.26, "ay": 0.76},
],
# a rocky den with a direwolf guarding it (wolf dominant, rocks behind)
"beast_den": [
{"src": "scenery/rock-cairn.png", "scale": 0.60, "ax": 0.58, "ay": 0.42},
{"src": "units/monsters/direwolf.png", "scale": 0.80, "ax": 0.46, "ay": 0.60},
],
# a tomb standing on a glowing summoning circle
"corrupted_hollow": [
{"src": "scenery/summoning-circle1.png", "scale": 0.92, "ax": 0.50, "ay": 0.72},
{"src": "scenery/mausoleum01.png", "scale": 0.72, "ax": 0.50, "ay": 0.44},
],
# rocks with fire erupting out of the fissure
"volcanic_fissure": [
{"src": "scenery/rock1.png", "scale": 0.60, "ax": 0.50, "ay": 0.66},
{"src": "scenery/flames08.png", "scale": 0.72, "ax": 0.50, "ay": 0.44},
],
# a cracked ancient temple (dominant) with a standing monolith beside it
"ancient_construct_site": [
{"src": "scenery/monolith3.png", "scale": 0.50, "ax": 0.20, "ay": 0.50},
{"src": "scenery/temple-cracked1.png", "scale": 0.96, "ax": 0.56, "ay": 0.54},
],
# a dragon perched over its nest
"wyvern_nest": [
{"src": "scenery/nest-full.png", "scale": 0.78, "ax": 0.50, "ay": 0.72},
{"src": "units/monsters/fire-dragon.png", "scale": 0.64, "ax": 0.50, "ay": 0.40},
],
}
def fetch(src_rel: str, allow_net: bool) -> bytes:
cache = SRC_CACHE / src_rel
if cache.exists():
return cache.read_bytes()
if not allow_net:
raise FileNotFoundError(f"not cached and --no-net: {src_rel}")
with urllib.request.urlopen(f"{RAW_BASE}/{src_rel}", timeout=20) as resp:
data = resp.read()
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_bytes(data)
return data
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def compose(layers: list[dict], allow_net: bool) -> tuple[Image.Image, list[tuple[str, str]]]:
"""Paint `layers` bottom-up onto a transparent CANVAS square. Returns the image
and the (source_path, source_sha256) list for the ledger."""
canvas = Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0))
sources: list[tuple[str, str]] = []
for layer in layers:
raw = fetch(layer["src"], allow_net)
sources.append((layer["src"], sha256(raw)))
im = Image.open(io.BytesIO(raw)).convert("RGBA")
bbox = im.split()[3].getbbox() # opaque-region bounds (straight-alpha sprites)
if bbox is not None:
im = im.crop(bbox)
target = max(1, round(CANVAS * layer["scale"]))
s = target / max(im.width, im.height)
w, h = max(1, round(im.width * s)), max(1, round(im.height * s))
im = im.resize((w, h), Image.LANCZOS)
cx, cy = layer["ax"] * CANVAS, layer["ay"] * CANVAS
canvas.alpha_composite(im, (round(cx - w / 2), round(cy - h / 2)))
return canvas, sources
def write_ledger_section(rows: list[str]) -> None:
"""Insert/replace the lair-overlay provenance section in the demo ledger,
leaving all other content untouched."""
header = (
f"\n{SECTION_MARK}\n\n"
"Each lair POI is **composited** from the Wesnoth sources listed below "
"(base structure + creature/effect) onto a 128×128 transparent PNG, "
"overwriting the game-icons lair baseline. Source license = GNU "
"GPL-2.0-or-later OR CC-BY-SA 4.0 (Battle for Wesnoth art team, "
"collective). **COPYLEFT — demo-only.** Baseline regenerable via "
"`build_standins.py --only lairs`; rebuild via `build_demo_lairs.py`.\n\n"
"| output_path (lairs/) | wesnoth source layer | source_sha256 | out_sha256 | added |\n"
"|---|---|---|---|---|\n"
+ "\n".join(rows) + "\n"
)
text = DEMO_LEDGER.read_text()
pat = re.compile(rf"\n{re.escape(SECTION_MARK)}\n.*?(?=\n## |\Z)", re.DOTALL)
DEMO_LEDGER.write_text(pat.sub(header.rstrip() + "\n", text) if pat.search(text)
else text.rstrip() + "\n" + header)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--no-net", action="store_true", help="build from cache only")
args = ap.parse_args(argv)
allow_net = not args.no_net
today = date.today().isoformat()
LAIRS_DIR.mkdir(parents=True, exist_ok=True)
rows: list[str] = []
for lair_id, layers in sorted(LAYERS.items()):
img, sources = compose(layers, allow_net)
out = LAIRS_DIR / f"{lair_id}.png"
img.save(out, "PNG")
out_sha = sha256(out.read_bytes())
for i, (src_rel, src_sha) in enumerate(sources):
rows.append(
f"| lairs/{lair_id}.png | {src_rel} | `{src_sha}` | "
f"{'`' + out_sha + '`' if i == 0 else ''} | {today} |")
print(f" {lair_id:24} <- {' + '.join(s for s, _ in sources)}")
write_ledger_section(rows)
print(f"Wrote {len(LAYERS)} composited Wesnoth demo lair overlays -> {LAIRS_DIR}")
print(f"Updated demo ledger: {DEMO_LEDGER}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Render a legibility proof for the lair POI stand-in sprites.
Composites each `sprites/lairs/<type_id>.png` onto a flat-top tan terrain hex at
the exact on-map POI scale the renderer uses `HEX_WIDTH (384) * 0.45 173 px`
wide, centred (lair_overlay_renderer.gd: LAIR_SPRITE_TILE_FRACTION = 0.45) with
the lair display name above, mirroring `_draw_tier_label`. This is a host-safe
substitute for an in-engine capture: it proves the art reads on tan terrain at
default zoom WITHOUT importing the new PNGs through Godot (image-asset reimport
is the operation flagged unsafe on the laptop).
Usage: tools/standin-sprites/.venv/bin/python tools/standin-sprites/lair_poi_proof.py
Output: tools/standin-sprites/lair_poi_proof.png
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
TOOL_DIR = Path(__file__).resolve().parent
REPO_ROOT = TOOL_DIR.parent.parent
LAIRS_DIR = REPO_ROOT / "public/games/age-of-dwarves/assets/sprites/lairs"
WILDS_JSON = REPO_ROOT / "public/resources/wilds/wilds.json"
OUT = TOOL_DIR / "lair_poi_proof.png"
# Engine geometry (hex_utils.gd) — scaled down by CELL_SCALE for a compact sheet.
HEX_WIDTH, HEX_HEIGHT = 384.0, 332.0
POI_FRACTION = 0.45
CELL_SCALE = 0.42
COLS = 4
# A few terrain tints lairs sit on, to show contrast across biomes.
TAN = (206, 184, 138) # plains / grassland — the low-contrast worst case
TERRAIN_TINTS = {
"grassland": (150, 178, 104),
"plains": TAN,
"hills": (188, 168, 126),
"mountains": (170, 162, 152),
"swamp": (120, 132, 96),
"volcano": (132, 110, 102),
"desert": (222, 200, 150),
"tundra": (200, 204, 200),
"forest": (104, 140, 92),
"enchanted_forest": (120, 150, 120),
}
def find_lair_types() -> list[dict]:
def walk(obj: object) -> list | None:
if isinstance(obj, dict):
if isinstance(obj.get("lair_types"), list):
return obj["lair_types"]
for v in obj.values():
hit = walk(v)
if hit is not None:
return hit
elif isinstance(obj, list):
for v in obj:
hit = walk(v)
if hit is not None:
return hit
return None
return walk(json.loads(WILDS_JSON.read_text())) or []
def hex_polygon(cx: float, cy: float, w: float, h: float) -> list[tuple[float, float]]:
"""Flat-top hexagon points (side length = half width)."""
r = w / 2.0
pts = []
for i in range(6):
ang = math.radians(60 * i)
pts.append((cx + r * math.cos(ang), cy + r * (h / w) * math.sin(ang)))
return pts
def font(size: int) -> ImageFont.FreeTypeFont:
for path in ("/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Helvetica.ttc"):
if Path(path).exists():
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def main() -> int:
lairs = find_lair_types()
cw, ch = HEX_WIDTH * CELL_SCALE, HEX_HEIGHT * CELL_SCALE
pad = 18
rows = math.ceil(len(lairs) / COLS)
W = int(COLS * cw + (COLS + 1) * pad)
H = int(rows * ch + (rows + 1) * pad + 30)
sheet = Image.new("RGBA", (W, H), (28, 26, 24, 255))
d = ImageDraw.Draw(sheet)
d.text((pad, 8), "Lair POI stand-ins — on-terrain at engine POI scale (0.45 × hex width)",
font=font(15), fill=(230, 222, 200, 255))
label_font = font(13)
for i, lair in enumerate(lairs):
lid = lair["id"]
name = lair.get("name", lid)
terrains = lair.get("preferred_terrains") or ["plains"]
tint = TERRAIN_TINTS.get(terrains[0], TAN)
col, row = i % COLS, i // COLS
x0 = pad + col * (cw + pad)
y0 = pad + 30 + row * (ch + pad)
cx, cy = x0 + cw / 2, y0 + ch / 2
d.polygon(hex_polygon(cx, cy, cw, ch), fill=(*tint, 255),
outline=(60, 54, 44, 255))
sprite_path = LAIRS_DIR / f"{lid}.png"
if sprite_path.exists():
spr = Image.open(sprite_path).convert("RGBA")
target_w = int(HEX_WIDTH * POI_FRACTION * CELL_SCALE)
scaled = spr.resize((target_w, int(target_w * spr.height / spr.width)),
Image.LANCZOS)
sheet.alpha_composite(scaled, (int(cx - scaled.width / 2),
int(cy - scaled.height / 2)))
# Name label above the marker (mirrors _draw_tier_label shadow + white).
tw = d.textlength(name, font=label_font)
lx, ly = cx - tw / 2, y0 + 2
d.text((lx + 1, ly + 1), name, font=label_font, fill=(0, 0, 0, 200))
d.text((lx, ly), name, font=label_font, fill=(255, 255, 255, 255))
sheet.convert("RGB").save(OUT, "PNG")
print(f"Wrote {OUT} ({len(lairs)} lairs, {W}×{H})")
return 0
if __name__ == "__main__":
raise SystemExit(main())