diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx
index 0ab5a7f7..e2d4f6a3 100644
--- a/.project/designs/app/src/App.tsx
+++ b/.project/designs/app/src/App.tsx
@@ -5,6 +5,7 @@ import { CombatCalculatorPage } from "./pages/CombatCalculator";
import { PermutationsPage } from "./pages/Permutations";
import { HexFormationPage } from "./pages/HexFormation";
import { AudioSystemPage } from "./pages/AudioSystem";
+import { CreditsPage } from "./pages/Credits";
// Remaining sketch pages are stubs pending port from HTML
function Stub({ name }: { name: string }): React.ReactElement {
@@ -30,6 +31,7 @@ export function App(): React.ReactElement {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/.project/designs/app/src/pages/Credits.tsx b/.project/designs/app/src/pages/Credits.tsx
new file mode 100644
index 00000000..ff5913b2
--- /dev/null
+++ b/.project/designs/app/src/pages/Credits.tsx
@@ -0,0 +1,247 @@
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+import { t } from "../theme";
+import creditsData from "@game-data/credits.json";
+import audioSourcesCsv from "@game-assets/audio/sources.csv?raw";
+
+// ── Types: mirror credits.json + sources.csv ─────────────────────────────────
+
+interface CreditEntry {
+ name: string;
+ role?: string;
+ url?: string;
+}
+interface CreditSection {
+ heading: string;
+ entries: CreditEntry[];
+}
+interface AutoSection {
+ heading: string;
+ source: string;
+}
+interface CreditsFile {
+ sections: CreditSection[];
+ auto_sections?: AutoSection[];
+}
+
+// ── CSV parsing — mirrors credits.gd::_audio_credit_entries ─────────────────
+//
+// Parses sources.csv (header + rows, '#'-comment lines), groups by attribution,
+// and emits one entry per pack with its file-count + licence. The single
+// source of truth for the resulting list is the CSV: nothing is hand-authored
+// on the design side.
+
+function parseSourcesCsv(text: string): CreditEntry[] {
+ const lines: string[] = text.split("\n").map((s: string): string => s.trim());
+ let headers: string[] = [];
+ type Bucket = { count: number; licences: Set; url: string };
+ const packs: Map = new Map();
+ for (const line of lines) {
+ if (!line || line.startsWith("#")) continue;
+ const fields: string[] = line.split(",");
+ if (headers.length === 0) {
+ headers = fields;
+ continue;
+ }
+ if (fields.length < headers.length) continue;
+ const row: Record = {};
+ for (let i: number = 0; i < headers.length; i++) {
+ row[headers[i]] = fields[i];
+ }
+ const attribution: string = (row["attribution"] ?? "").trim();
+ if (!attribution || attribution === "—") continue;
+ const licence: string = (row["license"] ?? "").trim();
+ const url: string = (row["source_url"] ?? "").split("#")[0];
+ let bucket: Bucket | undefined = packs.get(attribution);
+ if (!bucket) {
+ bucket = { count: 0, licences: new Set(), url: "" };
+ packs.set(attribution, bucket);
+ }
+ bucket.count += 1;
+ bucket.licences.add(licence);
+ if (!bucket.url) bucket.url = url;
+ }
+ const entries: CreditEntry[] = [];
+ const sortedNames: string[] = Array.from(packs.keys()).sort(
+ (a: string, b: string): number => {
+ const ba: Bucket = packs.get(a)!;
+ const bb: Bucket = packs.get(b)!;
+ if (ba.count !== bb.count) return bb.count - ba.count;
+ return a.localeCompare(b);
+ },
+ );
+ for (const name of sortedNames) {
+ const b: Bucket = packs.get(name)!;
+ const lic: string = Array.from(b.licences).sort().join(", ");
+ const e: CreditEntry = { name, role: `${b.count} file(s) — ${lic}` };
+ if (b.url) e.url = b.url;
+ entries.push(e);
+ }
+ return entries;
+}
+
+function resolveAutoSection(spec: AutoSection): CreditSection | null {
+ let entries: CreditEntry[] = [];
+ switch (spec.source) {
+ case "audio_sources_csv":
+ entries = parseSourcesCsv(audioSourcesCsv);
+ break;
+ default:
+ throw new Error(`Credits: unknown auto_section source '${spec.source}'`);
+ }
+ if (entries.length === 0) return null;
+ return { heading: spec.heading, entries };
+}
+
+// ── Styling ──────────────────────────────────────────────────────────────────
+
+const Page = styled.div`
+ max-width: 760px;
+ margin: 0 auto;
+ padding: 48px 32px 96px;
+ color: ${t.text.primary};
+`;
+
+const Title = styled.h1`
+ font-family: ${t.font.heading};
+ font-size: 36px;
+ color: ${t.text.title};
+ margin-bottom: 6px;
+`;
+
+const Sub = styled.p`
+ color: ${t.text.muted};
+ font-size: 13px;
+ margin-bottom: 32px;
+ font-family: ${t.font.mono};
+`;
+
+const Back = styled(Link)`
+ display: inline-block;
+ margin-bottom: 28px;
+ color: ${t.accent.gold};
+ text-decoration: none;
+ font-size: 13px;
+ &:hover { color: ${t.accent.goldBright}; }
+`;
+
+const Section = styled.section`
+ margin-bottom: 28px;
+`;
+
+const Heading = styled.h2`
+ font-family: ${t.font.heading};
+ font-size: 16px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: ${t.accent.goldBright};
+ margin: 0 0 6px 0;
+ padding-bottom: 4px;
+ border-bottom: 1px solid ${t.border.divider};
+`;
+
+const Entry = styled.div`
+ font-size: 14px;
+ line-height: 1.6;
+ color: ${t.text.secondary};
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
+`;
+
+const Name = styled.span`
+ color: ${t.text.primary};
+ font-weight: 500;
+`;
+
+const Role = styled.span`
+ color: ${t.text.muted};
+ &::before { content: "— "; color: ${t.text.muted}; }
+`;
+
+const Url = styled.a`
+ color: ${t.accent.science};
+ text-decoration: none;
+ font-family: ${t.font.mono};
+ font-size: 12px;
+ &:hover { text-decoration: underline; }
+`;
+
+const SourceTag = styled.span<{ $auto: boolean }>`
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 6px;
+ border-radius: 2px;
+ font-size: 10px;
+ font-family: ${t.font.mono};
+ letter-spacing: 0.05em;
+ color: ${(p): string => (p.$auto ? "#9bbfe0" : t.text.muted)};
+ background: ${(p): string => (p.$auto ? "#1a2533" : "transparent")};
+ border: 1px solid ${(p): string => (p.$auto ? "#9bbfe055" : "transparent")};
+ text-transform: uppercase;
+`;
+
+// ── Page ────────────────────────────────────────────────────────────────────
+
+export function CreditsPage(): React.ReactElement {
+ const data: CreditsFile = creditsData as CreditsFile;
+ const handAuthored: CreditSection[] = data.sections;
+ const auto: CreditSection[] = (data.auto_sections ?? [])
+ .map((spec: AutoSection): CreditSection | null => resolveAutoSection(spec))
+ .filter((s: CreditSection | null): s is CreditSection => s !== null);
+
+ return (
+
+ ← Back to design index
+ Credits
+
+ Hand-authored sections from credits.json; Audio & Music
+ is generated from assets/audio/sources.csv at load time.
+
+
+ {handAuthored.map((s: CreditSection): React.ReactElement => (
+
+ ))}
+ {auto.map((s: CreditSection): React.ReactElement => (
+
+ ))}
+
+ );
+}
+
+function CreditSectionView({
+ section,
+ isAuto,
+}: {
+ section: CreditSection;
+ isAuto: boolean;
+}): React.ReactElement {
+ return (
+
+
+ {section.heading}
+ {isAuto && auto · sources.csv}
+
+ {section.entries.map((e: CreditEntry, i: number): React.ReactElement => (
+
+ {e.name}
+ {e.role && {e.role}}
+ {e.url && (
+
+ {hostnameOf(e.url)}
+
+ )}
+
+ ))}
+
+ );
+}
+
+function hostnameOf(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return url;
+ }
+}
diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx
index 6f7034f8..a6b982c1 100644
--- a/.project/designs/app/src/pages/Index.tsx
+++ b/.project/designs/app/src/pages/Index.tsx
@@ -54,6 +54,7 @@ const routes = [
{ path: "/combat", label: "📋 Combat Preview — static scenarios with real stats" },
{ path: "/hex", label: "⬢ Hex Centre + 6 Edge Slots — ecotone blends, the differentiator" },
{ path: "/audio", label: "♪ Audio System — manifest browser, fallback ladder, mixer" },
+ { path: "/credits", label: "✦ Credits — hand-authored + audio auto-generated from sources.csv" },
{ path: "/gallery", label: "🖼 Design Gallery — colors, type, components" },
{ path: "/hud", label: "🗺 World Map HUD — top bar, unit panel, minimap" },
{ path: "/city", label: "🏛 City Screen — production queue, buildings, citizens" },
diff --git a/public/games/age-of-dwarves/data/credits.json b/public/games/age-of-dwarves/data/credits.json
index 624fb616..33f3c4bb 100644
--- a/public/games/age-of-dwarves/data/credits.json
+++ b/public/games/age-of-dwarves/data/credits.json
@@ -58,5 +58,8 @@
{ "name": "Magic: The Gathering", "role": "Color pie inspiration" }
]
}
+ ],
+ "auto_sections": [
+ { "heading": "Audio & Music", "source": "audio_sources_csv" }
]
}
diff --git a/src/game/engine/scenes/menus/credits.gd b/src/game/engine/scenes/menus/credits.gd
index b75971f7..9eed7080 100644
--- a/src/game/engine/scenes/menus/credits.gd
+++ b/src/game/engine/scenes/menus/credits.gd
@@ -4,6 +4,7 @@ extends Control
## headings, each with (name — role) rows.
const CREDITS_PATH: String = "res://public/games/age-of-dwarves/data/credits.json"
+const AUDIO_SOURCES_CSV: String = "res://public/games/age-of-dwarves/assets/audio/sources.csv"
@onready var _title_label: Label = %TitleLabel
@onready var _content_vbox: VBoxContainer = %ContentVBox
@@ -35,6 +36,14 @@ func _populate() -> void:
continue
var section: Dictionary = section_variant
_content_vbox.add_child(_build_section(section))
+ for auto_variant: Variant in _load_auto_sections():
+ if not auto_variant is Dictionary:
+ continue
+ var auto: Dictionary = auto_variant
+ var resolved: Dictionary = _resolve_auto_section(auto)
+ if resolved.is_empty():
+ continue
+ _content_vbox.add_child(_build_section(resolved))
func _load_sections() -> Array:
@@ -59,6 +68,101 @@ func _load_sections() -> Array:
return sections
+func _load_auto_sections() -> Array:
+ if not FileAccess.file_exists(CREDITS_PATH):
+ return []
+ var file: FileAccess = FileAccess.open(CREDITS_PATH, FileAccess.READ)
+ if file == null:
+ return []
+ var json: JSON = JSON.new()
+ if json.parse(file.get_as_text()) != OK:
+ file.close()
+ return []
+ file.close()
+ var data: Dictionary = json.data if json.data is Dictionary else {}
+ return data.get("auto_sections", []) as Array
+
+
+## Dispatch on `source` to build a section dict from external data.
+## Returns {} when the source is unknown or yields no entries — caller
+## then skips the section so the page never shows a blank header.
+func _resolve_auto_section(spec: Dictionary) -> Dictionary:
+ var source: String = String(spec.get("source", ""))
+ var heading: String = String(spec.get("heading", ""))
+ var entries: Array = []
+ match source:
+ "audio_sources_csv":
+ entries = _audio_credit_entries()
+ _:
+ push_warning("credits.gd: unknown auto_section source '%s'" % source)
+ return {}
+ if entries.is_empty():
+ return {}
+ return {"heading": heading, "entries": entries}
+
+
+## Group sources.csv rows by attribution; one entry per pack with the
+## file count and the licence in the role string.
+func _audio_credit_entries() -> Array:
+ if not FileAccess.file_exists(AUDIO_SOURCES_CSV):
+ return []
+ var file: FileAccess = FileAccess.open(AUDIO_SOURCES_CSV, FileAccess.READ)
+ if file == null:
+ return []
+ var text: String = file.get_as_text()
+ file.close()
+ # Per-pack accumulators: attribution -> {count, licences:set, urls:set}.
+ var packs: Dictionary = {}
+ var headers: PackedStringArray = []
+ for raw_line: String in text.split("\n"):
+ var line: String = raw_line.strip_edges()
+ if line.is_empty() or line.begins_with("#"):
+ continue
+ var fields: PackedStringArray = line.split(",", false)
+ if headers.is_empty():
+ headers = fields
+ continue
+ if fields.size() < headers.size():
+ continue
+ var row: Dictionary = {}
+ for i: int in range(headers.size()):
+ row[headers[i]] = fields[i]
+ var attribution: String = String(row.get("attribution", "")).strip_edges()
+ if attribution.is_empty() or attribution == "—":
+ continue
+ var licence: String = String(row.get("license", "")).strip_edges()
+ var url: String = String(row.get("source_url", "")).split("#")[0]
+ var bucket: Dictionary = packs.get(attribution, {"count": 0, "licences": {}, "url": ""})
+ bucket["count"] = int(bucket.get("count", 0)) + 1
+ (bucket["licences"] as Dictionary)[licence] = true
+ if String(bucket.get("url", "")).is_empty():
+ bucket["url"] = url
+ packs[attribution] = bucket
+ # Stable order: descending by count, then alphabetical.
+ var names: Array[String] = []
+ for k: String in packs:
+ names.append(k)
+ names.sort_custom(func(a: String, b: String) -> bool:
+ var ca: int = int((packs[a] as Dictionary).get("count", 0))
+ var cb: int = int((packs[b] as Dictionary).get("count", 0))
+ if ca == cb:
+ return a < b
+ return ca > cb)
+ var entries: Array = []
+ for name: String in names:
+ var bucket: Dictionary = packs[name]
+ var lic_keys: Array = (bucket["licences"] as Dictionary).keys()
+ lic_keys.sort()
+ var lic_str: String = ", ".join(PackedStringArray(lic_keys))
+ var role: String = "%d file(s) — %s" % [int(bucket["count"]), lic_str]
+ var entry: Dictionary = {"name": name, "role": role}
+ var url: String = String(bucket.get("url", ""))
+ if not url.is_empty():
+ entry["url"] = url
+ entries.append(entry)
+ return entries
+
+
func _build_section(section: Dictionary) -> Control:
var box: VBoxContainer = VBoxContainer.new()
box.add_theme_constant_override("separation", 4)
@@ -76,19 +180,40 @@ func _build_section(section: Dictionary) -> Control:
if not entry_variant is Dictionary:
continue
var entry: Dictionary = entry_variant
- var row: Label = Label.new()
- var name: String = str(entry.get("name", ""))
- var role: String = str(entry.get("role", ""))
- if role.is_empty():
- row.text = name
- else:
- row.text = ThemeVocabulary.lookup("fmt_credits_entry") % [name, role]
- row.add_theme_font_size_override("font_size", 13)
- row.add_theme_color_override("font_color", Color(0.85, 0.82, 0.72))
- box.add_child(row)
+ box.add_child(_build_entry_row(entry))
return box
+## One credit row. If the entry carries a `url`, render the row as a
+## RichTextLabel with [url] BBCode so users can ctrl-click out — keeps
+## the page browsable instead of merely informational.
+func _build_entry_row(entry: Dictionary) -> Control:
+ var name: String = str(entry.get("name", ""))
+ var role: String = str(entry.get("role", ""))
+ var url: String = str(entry.get("url", ""))
+ var line: String
+ if role.is_empty():
+ line = name
+ else:
+ line = ThemeVocabulary.lookup("fmt_credits_entry") % [name, role]
+ if url.is_empty():
+ var lbl: Label = Label.new()
+ lbl.text = line
+ lbl.add_theme_font_size_override("font_size", 13)
+ lbl.add_theme_color_override("font_color", Color(0.85, 0.82, 0.72))
+ return lbl
+ var rich: RichTextLabel = RichTextLabel.new()
+ rich.bbcode_enabled = true
+ rich.fit_content = true
+ rich.scroll_active = false
+ rich.add_theme_font_size_override("normal_font_size", 13)
+ rich.add_theme_color_override("default_color", Color(0.85, 0.82, 0.72))
+ # Escape-friendly: `line` is author-controlled JSON / CSV, not BBCode.
+ rich.text = "%s [color=#9bbfe0][url=%s]%s[/url][/color]" % [line, url, url]
+ rich.meta_clicked.connect(func(meta: Variant) -> void: OS.shell_open(str(meta)))
+ return rich
+
+
func _on_back() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("change_scene"):
diff --git a/src/game/engine/tests/unit/test_credits_screen.gd b/src/game/engine/tests/unit/test_credits_screen.gd
index 3d872188..173dadca 100644
--- a/src/game/engine/tests/unit/test_credits_screen.gd
+++ b/src/game/engine/tests/unit/test_credits_screen.gd
@@ -60,6 +60,44 @@ func test_credits_script_loads_sections_list() -> void:
instance.free()
+# ── Audio auto-section: groups sources.csv by attribution into credit rows ─
+
+
+func test_credits_declares_audio_auto_section() -> void:
+ var data: Dictionary = _load_credits()
+ var auto: Array = data.get("auto_sections", []) as Array
+ assert_gt(auto.size(), 0, "credits.json must declare at least one auto_section")
+ var sources: Array[String] = []
+ for entry_variant: Variant in auto:
+ if entry_variant is Dictionary:
+ sources.append(String((entry_variant as Dictionary).get("source", "")))
+ assert_true(sources.has("audio_sources_csv"),
+ "credits.json must list audio_sources_csv as an auto-section source")
+
+
+func test_audio_credit_entries_grouped_by_pack() -> void:
+ var instance: Node = CreditsScript.new()
+ var entries: Array = instance.call("_audio_credit_entries") as Array
+ instance.free()
+ assert_gte(entries.size(), 2,
+ "audio credits must surface at least two distinct packs (we ship Kenney + rubberduck/Junkala)")
+ var names: Array[String] = []
+ for e_variant: Variant in entries:
+ assert_true(e_variant is Dictionary, "entry must be a dict")
+ var e: Dictionary = e_variant
+ assert_true(e.has("name") and not String(e["name"]).is_empty(),
+ "audio credit entry must have a non-empty name")
+ var role: String = String(e.get("role", ""))
+ assert_true(role.contains("file") and role.contains("CC"),
+ "role must announce the file count and the CC licence — got '%s'" % role)
+ names.append(String(e["name"]))
+ # No duplicates — the grouping is the whole point.
+ var dedup: Dictionary = {}
+ for n: String in names:
+ assert_false(dedup.has(n), "pack '%s' appears twice — grouping is broken" % n)
+ dedup[n] = true
+
+
func test_vocabulary_has_credits_key() -> void:
assert_true(ThemeVocabulary.has_key("credits"),
"vocabulary must expose a 'credits' label for the button")