+
+ Forest Lab
+
+ Building up the visual language for forested terrain — starting from the
+ single tree primitive (shadow → canopy radial gradient → highlight dot)
+ through four arrangement strategies. The density gradient is the target
+ pattern: core trees are larger old growth; edge trees are smaller scrub.
+ This models how real forests work and will drive per-hex rendering.
+
+
+
+
+
+ Single tree anatomy — each
+ tree is three layers: a squashed ground shadow offset down-right (depth),
+ a canopy with radial gradient lit from top-left (volume), and a small
+ highlight dot (sun catch). Canopy radius varies by tree size; this is
+ the same primitive used in all arrangements below.
+
+
+ {([12, 18, 26] as const).map((r) => (
+
+ {r === 12 ? "Small (r=12)" : r === 18 ? "Medium (r=18)" : "Large (r=26)"}
+ {
+ ctx.fillStyle = "rgb(118,185,72)";
+ ctx.fillRect(0, 0, W, H);
+ drawTree(ctx, W / 2, H / 2, r);
+ }} />
+
+ ))}
+
+
+
+
+
+ Arrangements — four
+ strategies for placing trees within a patch of grassland.
+
+
+ {ARRANGEMENTS.map((a) => (
+
+ {a.label}
+
+ {a.sub}
+
+ ))}
+
+
+
+ );
+}
diff --git a/tools/audit-id-refs.py b/tools/audit-id-refs.py
new file mode 100644
index 00000000..43d2e289
--- /dev/null
+++ b/tools/audit-id-refs.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Find every JSON field across public/resources that holds a unit/building id reference."""
+from __future__ import annotations
+import json, glob, collections, sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+
+
+def load_known_ids() -> set[str]:
+ ids: set[str] = set()
+ for sub in ("units", "buildings", "improvements", "items", "techs"):
+ for fp in (REPO / "public" / "resources" / sub).glob("*.json"):
+ if fp.name.endswith(".schema.json"):
+ continue
+ try:
+ d = json.loads(fp.read_text())
+ except Exception:
+ continue
+ items = d if isinstance(d, list) else [d]
+ for it in items:
+ if isinstance(it, dict) and isinstance(it.get("id"), str):
+ ids.add(it["id"])
+ return ids
+
+
+def main() -> int:
+ known_ids = load_known_ids()
+ print(f"loaded {len(known_ids)} ids from units/buildings/improvements/items/techs")
+
+ field_count: collections.Counter[str] = collections.Counter()
+ by_field_examples: dict[str, set[str]] = collections.defaultdict(set)
+
+ for fp in sorted(glob.glob(str(REPO / "public" / "**" / "*.json"), recursive=True)):
+ if fp.endswith(".schema.json"):
+ continue
+ try:
+ data = json.loads(Path(fp).read_text())
+ except Exception:
+ continue
+ rel = Path(fp).relative_to(REPO).as_posix()
+ # Skip the resource files themselves to focus on cross-refs
+ if rel.startswith("public/resources/units/") or rel.startswith("public/resources/buildings/"):
+ continue
+
+ def walk(o):
+ if isinstance(o, dict):
+ for k, v in o.items():
+ if isinstance(v, str) and v in known_ids:
+ field_count[k] += 1
+ if len(by_field_examples[k]) < 3:
+ by_field_examples[k].add(v)
+ elif isinstance(v, list):
+ for x in v:
+ if isinstance(x, str) and x in known_ids:
+ field_count[f"{k}[]"] += 1
+ if len(by_field_examples[f"{k}[]"]) < 3:
+ by_field_examples[f"{k}[]"].add(x)
+ else:
+ walk(x)
+ elif isinstance(v, dict):
+ walk(v)
+
+ walk(data)
+
+ print("\n=== id-bearing fields (top 30, across non-units/buildings JSON) ===")
+ for field, n in field_count.most_common(30):
+ examples = ", ".join(sorted(by_field_examples[field])[:3])
+ print(f" {n:5d} {field:32s} e.g. {examples}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())