feat(data): album data for all 9 sagas and 40 songs

Odyssey links point at Butcher & Lang (#1728), whose Greek names the
keywords were checked against.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 04:56:32 +01:00
co-authored by Claude Opus 5.5
parent 3235df8bea
commit 288425501d
8 changed files with 611 additions and 11 deletions
+11 -11
View File
@@ -63,14 +63,14 @@ export function sagaOf(song: AlbumSong): AlbumSaga;
export function songsWithMotif(motifId: string): AlbumSong[];
export function prevNextWithMotif(song: AlbumSong, motifId: string): { prev: AlbumSong | null; next: AlbumSong | null };
```
Values pinned by the spec: titles and order exactly as the content folder (Troy: The Horse and the Infant, Just a Man, Full Speed Ahead, Open Arms, Warrior of the Mind · Cyclops: Polyphemus, Survive, Remember Them, My Goodbye · Ocean: Storm, Luck Runs Out, Keep Your Friends Close, Ruthlessness · Circe: Puppeteer, Wouldn’t You Like, Done For, There Are Other Ways · Underworld: The Underworld, No Longer You, Monster · Thunder: Suffering, Different Beast, Scylla, Mutiny, Thunder Bringer · Wisdom: Legendary, Little Wolf, We'll Be Fine, Love in Paradise, God Games · Vengeance: Not Sorry for Loving You, Dangerous, Charybdis, Get in the Water, Six Hundred Strike · Ithaca: The Challenge, Hold Them Down, Odysseus, I Can't Help but Wonder, Would You Fall in Love with Me Again). Landings: `cave` = Polyphemus, Survive; `arena` = God Games; `hall` = all five Ithaca songs. Ids are kebab-case titles (`wouldnt-you-like`). Motif colours: reuse `src/data/sample.ts` colours for motifs that exist there. Odyssey `url`: `https://www.gutenberg.org/files/1727/1727-h/1727-h.htm#chap{NN}` (two digits).
Values pinned by the spec: titles and order exactly as the content folder (Troy: The Horse and the Infant, Just a Man, Full Speed Ahead, Open Arms, Warrior of the Mind · Cyclops: Polyphemus, Survive, Remember Them, My Goodbye · Ocean: Storm, Luck Runs Out, Keep Your Friends Close, Ruthlessness · Circe: Puppeteer, Wouldn’t You Like, Done For, There Are Other Ways · Underworld: The Underworld, No Longer You, Monster · Thunder: Suffering, Different Beast, Scylla, Mutiny, Thunder Bringer · Wisdom: Legendary, Little Wolf, We'll Be Fine, Love in Paradise, God Games · Vengeance: Not Sorry for Loving You, Dangerous, Charybdis, Get in the Water, Six Hundred Strike · Ithaca: The Challenge, Hold Them Down, Odysseus, I Can't Help but Wonder, Would You Fall in Love with Me Again). Landings: `cave` = Polyphemus, Survive; `arena` = God Games; `hall` = all five Ithaca songs. Ids are kebab-case titles (`wouldnt-you-like`). Motif colours: reuse `src/data/sample.ts` colours for motifs that exist there. Odyssey `url`: `https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap{NN}` (Butcher & Lang) (two digits).
Content guidance: use what is well established about EPIC (motifs such as Warrior of the Mind, Open Arms, Just a Man/My Goodbye, Ruthlessness, Monster, Little Wolf, Love in Paradise/Penelope's theme, Thunder Bringer, character themes for Odysseus, Athena, Poseidon, Zeus, Penelope, Telemachus, Polyphemus, Circe, Hermes…). The `.lrc` files in `/home/user/epic-the-musical-content` may be *read* to check where phrases recur, but no lyric text may be written anywhere. Mark every uncertain value `// TODO(verify)`.
- [ ] **Step 1: Write failing tests** in `tests/album.test.ts`: 9 sagas ordered troy…ithaca; `songs.length === 40`; tracks 1..40 contiguous; `songs.map(s=>s.title)` equals the pinned list; each saga's `sagaTrack` 1..n; every `motifIds`/part motif exists in `motifs` and every motif is used ≥2 songs; `storyTime.yearsAfterTroy` non-decreasing by track; landing mapping as pinned; `fanWorks` is `[]`; `prevNextWithMotif` on a motif's middle occurrence returns both neighbours; if `/home/user/epic-the-musical-content/Songs and Lyrics` exists, `scanLibrary` titles (normalised: lowercase, strip non-alphanumerics) equal the album titles in order.
- [ ] **Step 2:** `bun test tests/album.test.ts` → FAIL (modules missing).
- [ ] **Step 3:** Implement the files above.
- [ ] **Step 4:** `bun test` and `bun run typecheck` → all pass.
- [ ] **Step 5:** Commit `feat(data): album data for all 9 sagas and 40 songs`.
- [x] **Step 1: Write failing tests** in `tests/album.test.ts`: 9 sagas ordered troy…ithaca; `songs.length === 40`; tracks 1..40 contiguous; `songs.map(s=>s.title)` equals the pinned list; each saga's `sagaTrack` 1..n; every `motifIds`/part motif exists in `motifs` and every motif is used ≥2 songs; `storyTime.yearsAfterTroy` non-decreasing by track; landing mapping as pinned; `fanWorks` is `[]`; `prevNextWithMotif` on a motif's middle occurrence returns both neighbours; if `/home/user/epic-the-musical-content/Songs and Lyrics` exists, `scanLibrary` titles (normalised: lowercase, strip non-alphanumerics) equal the album titles in order.
- [x] **Step 2:** `bun test tests/album.test.ts` → FAIL (modules missing).
- [x] **Step 3:** Implement the files above.
- [x] **Step 4:** `bun test` and `bun run typecheck` → all pass.
- [x] **Step 5:** Commit `feat(data): album data for all 9 sagas and 40 songs`.
### Task 2: App shell, routes and URL state
@@ -93,11 +93,11 @@ export function createStore(initial: AppState): { get(): AppState; set(p: Partia
```
`index.html` has `<main id="voyage-app">` with regions `#stage` (canvas + frieze layer + caption), `#panel`, `#timeline`, and the credits footer.
- [ ] **Step 1:** Failing tests: `parseState("?song=survive&tab=listen&motif=polyphemus&timeline=closed&ribbons=1&read=1")` round-trips via `stateToSearch`; `parseState("?song=nope&tab=x")` equals `DEFAULT_STATE`; `stateToSearch(DEFAULT_STATE) === ""`; server tests as above.
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3:** Implement; `main.ts` boots the store, mounts credits, renders a placeholder stage showing the current song title (replaced in Task 7).
- [ ] **Step 4:** `bun test`, `bun run typecheck`, `bun run build` pass; `bun run dev` → `/` and `/mockups/` load.
- [ ] **Step 5:** Commit `feat(app): app shell at /, gallery at /mockups/, URL state`.
- [x] **Step 1:** Failing tests: `parseState("?song=survive&tab=listen&motif=polyphemus&timeline=closed&ribbons=1&read=1")` round-trips via `stateToSearch`; `parseState("?song=nope&tab=x")` equals `DEFAULT_STATE`; `stateToSearch(DEFAULT_STATE) === ""`; server tests as above.
- [x] **Step 2:** Run → FAIL.
- [x] **Step 3:** Implement; `main.ts` boots the store, mounts credits, renders a placeholder stage showing the current song title (replaced in Task 7).
- [x] **Step 4:** `bun test`, `bun run typecheck`, `bun run build` pass; `bun run dev` → `/` and `/mockups/` load.
- [x] **Step 5:** Commit `feat(app): app shell at /, gallery at /mockups/, URL state`.
### Task 3: Content client
+10
View File
@@ -0,0 +1,10 @@
import type { FanWork } from "./types";
export type * from "./types";
export { sagas } from "./sagas";
export { songs } from "./songs";
export { motifs } from "./motifs";
export { songById, songsInSaga, sagaOf, songsWithMotif, prevNextWithMotif } from "./query";
/** Credited fan works per song. Empty in v1: no invented works. */
export const fanWorks: FanWork[] = [];
+42
View File
@@ -0,0 +1,42 @@
import type { Motif } from "./types";
// Curated motif set. Colours for warrior, open-arms, just-a-man, odysseus, athena and polyphemus are
// reused from src/data/sample.ts. Musical motifs list the songs where the melody or its signature phrase
// returns; character themes follow the songs where the character sings (see songs.ts).
export const motifs: Motif[] = [
// Musical motifs
{ id: "warrior", name: "Warrior of the Mind", kind: "motif", color: "#d9a441", description: "Athena's lesson in strategy over strength; it returns whenever wit is put to the test." }, // TODO(verify) recurrences
{ id: "open-arms", name: "Open Arms", kind: "motif", color: "#e8836b", description: "Polites' melody of kindness and trust, remembered long after he is gone." }, // TODO(verify) recurrences
{ id: "just-a-man", name: "Just a Man", kind: "motif", color: "#7aa6c9", description: "Odysseus's plea that he is only mortal, set against what the gods demand of him." }, // TODO(verify) recurrences
{ id: "full-speed", name: "Full Speed Ahead", kind: "motif", color: "#4f9fa6", description: "The crew's rallying cry for the voyage home, which sours as the losses mount." }, // TODO(verify) recurrences
{ id: "six-hundred", name: "Six Hundred Men", kind: "motif", color: "#9c7a54", description: "The count of the men Odysseus vowed to bring home, a number that keeps falling." }, // TODO(verify) recurrences
{ id: "ruthlessness", name: "Ruthlessness", kind: "motif", color: "#2f5d8a", description: "Poseidon's creed of mercy as weakness, which Odysseus slowly takes on as his own." }, // TODO(verify) recurrences
{ id: "monster", name: "Monster", kind: "motif", color: "#7d2e3a", description: "The question of whether surviving has made Odysseus into the thing he fears." }, // TODO(verify) recurrences
{ id: "friends-close", name: "Keep Your Friends Close", kind: "motif", color: "#b5a33a", description: "Aeolus's warning about trust that echoes on Calypso's island." }, // TODO(verify) recurrences
{ id: "in-the-water", name: "In the Water", kind: "motif", color: "#3d8fc2", description: "The sea as a threat and a battlefield, from the Sirens to the duel with Poseidon." }, // TODO(verify) recurrences
{ id: "thunder-bringer", name: "Thunder Bringer", kind: "motif", color: "#e2c84a", description: "Zeus's theme of judgement from the sky." }, // TODO(verify) recurrences
{ id: "little-wolf", name: "Little Wolf", kind: "motif", color: "#8a8f99", description: "Athena's name and melody for Telemachus as he learns to fight." }, // TODO(verify) recurrences
{ id: "fall-in-love", name: "Fall in Love Again", kind: "motif", color: "#d46f9a", description: "The question of love tested by time and temptation, answered at the very end." }, // TODO(verify) recurrences
// Character themes
{ id: "odysseus", name: "Odysseus", kind: "character", color: "#f2e3b3", description: "Theme of the king of Ithaca." },
{ id: "athena", name: "Athena", kind: "character", color: "#b69bd6", description: "Theme of the goddess of wisdom." },
{ id: "polyphemus", name: "Polyphemus", kind: "character", color: "#c0504d", description: "Theme of the Cyclops, Poseidon's son." },
{ id: "poseidon", name: "Poseidon", kind: "character", color: "#1f7a7a", description: "Theme of the god of the sea." },
{ id: "zeus", name: "Zeus", kind: "character", color: "#f0b429", description: "Theme of the king of the gods." },
{ id: "penelope", name: "Penelope", kind: "character", color: "#c98bb9", description: "Theme of the queen of Ithaca, waiting at home." },
{ id: "telemachus", name: "Telemachus", kind: "character", color: "#6c9a5a", description: "Theme of Odysseus's son, the infant left behind who grows up." },
{ id: "circe", name: "Circe", kind: "character", color: "#9b5fc0", description: "Theme of the sorceress of Aeaea." },
{ id: "hermes", name: "Hermes", kind: "character", color: "#5fb3a1", description: "Theme of the messenger god." },
{ id: "calypso", name: "Calypso", kind: "character", color: "#e39b6a", description: "Theme of the nymph of Ogygia." },
{ id: "eurylochus", name: "Eurylochus", kind: "character", color: "#a35f3b", description: "Theme of Odysseus's second-in-command." },
{ id: "polites", name: "Polites", kind: "character", color: "#f0a679", description: "Theme of Odysseus's optimistic friend." },
{ id: "aeolus", name: "Aeolus", kind: "character", color: "#a8c7d8", description: "Theme of the keeper of the winds." },
{ id: "antinous", name: "Antinous", kind: "character", color: "#5a4a6b", description: "Theme of the leader of the suitors." },
];
/** Singer name → character motif id, used to derive character themes from a song's singers. */
export const characterMotifBySinger: Record<string, string> = {
Odysseus: "odysseus", Athena: "athena", Polyphemus: "polyphemus", Poseidon: "poseidon", Zeus: "zeus",
Penelope: "penelope", Telemachus: "telemachus", Circe: "circe", Hermes: "hermes", Calypso: "calypso",
Eurylochus: "eurylochus", Polites: "polites", Aeolus: "aeolus", Antinous: "antinous",
};
+33
View File
@@ -0,0 +1,33 @@
import { sagas } from "./sagas";
import { songs } from "./songs";
import type { AlbumSaga, AlbumSong, PlaceId } from "./types";
const byId = new Map(songs.map((s) => [s.id, s]));
const sagaById = new Map(sagas.map((s) => [s.id, s]));
export function songById(id: string): AlbumSong | undefined {
return byId.get(id);
}
/** Songs of one saga in album order. */
export function songsInSaga(sagaId: PlaceId): AlbumSong[] {
return songs.filter((s) => s.sagaId === sagaId);
}
export function sagaOf(song: AlbumSong): AlbumSaga {
return sagaById.get(song.sagaId)!;
}
/** Songs that use a motif (song-level or in a part), in album order. */
export function songsWithMotif(motifId: string): AlbumSong[] {
return songs.filter((s) => s.motifIds.includes(motifId) || s.parts.some((p) => p.motifIds.includes(motifId)));
}
/** The previous and next songs in album order that share a motif with `song`. */
export function prevNextWithMotif(song: AlbumSong, motifId: string): { prev: AlbumSong | null; next: AlbumSong | null } {
const withIt = songsWithMotif(motifId);
return {
prev: withIt.filter((s) => s.track < song.track).at(-1) ?? null,
next: withIt.find((s) => s.track > song.track) ?? null,
};
}
+14
View File
@@ -0,0 +1,14 @@
import type { AlbumSaga } from "./types";
// Release years follow the content folder names ("EPIC - The Troy Saga (2022)").
export const sagas: AlbumSaga[] = [
{ id: "troy", name: "The Troy Saga", order: 1, year: 2022, place: "Troy", blurb: "The war ends, and Odysseus must decide what kind of leader he will be on the way home." },
{ id: "cyclops", name: "The Cyclops Saga", order: 2, year: 2023, place: "The island of the Cyclopes", blurb: "A cave full of food turns into a trap, and Odysseus makes an enemy of a god." },
{ id: "ocean", name: "The Ocean Saga", order: 3, year: 2023, place: "The open sea", blurb: "Storms, the wind-god Aeolus and the wrath of Poseidon cost Odysseus most of his fleet." },
{ id: "circe", name: "The Circe Saga", order: 4, year: 2024, place: "Aeaea, Circe's island", blurb: "A sorceress turns the crew into pigs, and Odysseus finds that there are other ways home." },
{ id: "underworld", name: "The Underworld Saga", order: 5, year: 2024, place: "The Underworld", blurb: "Among the dead, Odysseus hears a prophecy and resolves to become someone harder." },
{ id: "thunder", name: "The Thunder Saga", order: 6, year: 2024, place: "Scylla's strait and the thunder sea", blurb: "Sirens, Scylla and a mutiny lead to Zeus's judgement on the last ship." },
{ id: "wisdom", name: "The Wisdom Saga", order: 7, year: 2024, place: "Ithaca, Ogygia and Olympus", blurb: "Telemachus grows up, Calypso holds Odysseus, and Athena plays the gods' games for his freedom." }, // TODO(verify) place grouping
{ id: "vengeance", name: "The Vengeance Saga", order: 8, year: 2024, place: "Charybdis and Poseidon's sea", blurb: "Freed at last, Odysseus turns the gods' own weapons against Poseidon." }, // TODO(verify) place
{ id: "ithaca", name: "The Ithaca Saga", order: 9, year: 2024, place: "Ithaca, the palace hall", blurb: "Home at last, Odysseus faces the suitors and the question of whether Penelope still knows him." },
];
+322
View File
@@ -0,0 +1,322 @@
import { characterMotifBySinger } from "./motifs";
import type { AlbumPart, AlbumSong, LandingId, OdysseyRef, PlaceId } from "./types";
// Album data for all 40 songs. No lyric text here, ever. Odyssey keywords occur in that book of the
// Butcher & Lang translation (checked by tests/album.test.ts when the content folder is mounted).
// Character themes are derived from `singers`; `motifs` lists the musical motifs only.
const od = (book: number, ...keywords: string[]): OdysseyRef => ({
book, keywords, url: `https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap${String(book).padStart(2, "0")}`,
});
const yt = (q: string) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`;
const at = (yearsAfterTroy: number, label: string) => ({ yearsAfterTroy, label });
interface Draft {
title: string; saga: PlaceId; time: AlbumSong["storyTime"]; odyssey: OdysseyRef | null;
singers: string[]; motifs: string[]; parts?: AlbumPart[]; facts: string[]; landing?: LandingId;
}
const drafts: Draft[] = [
// ── The Troy Saga
{
title: "The Horse and the Infant", saga: "troy", time: at(0, "Year 0 · the fall of Troy"),
odyssey: od(8, "horse", "Epeius", "Ilios"),
singers: ["Odysseus", "Zeus", "Soldiers"], motifs: [],
facts: ["Opens the musical at the end of the Trojan War, where Odysseus's wooden horse has won the city.", "Zeus warns Odysseus about the infant son of Hector, setting up the choice of the next song."],
},
{
title: "Just a Man", saga: "troy", time: at(0, "Year 0 · the walls of Troy"),
odyssey: null, // the fate of Hector's son is not told in the Odyssey
singers: ["Odysseus", "Soldiers"], motifs: ["just-a-man", "monster"], // TODO(verify) monster phrase as motif
facts: ["Odysseus wrestles with the choice Zeus put to him and thinks of his own wife and son."],
},
{
title: "Full Speed Ahead", saga: "troy", time: at(0, "Year 0 · setting sail from Troy"),
odyssey: od(9, "Cicones", "Ismarus"), // TODO(verify) closest Homer episode
singers: ["Odysseus", "Eurylochus", "Polites", "Soldiers"], motifs: ["full-speed", "six-hundred"],
facts: ["Introduces Eurylochus and Polites and the crew's eagerness to get home.", "The fleet runs short of food and lands on an unknown island."], // TODO(verify) second fact
},
{
title: "Open Arms", saga: "troy", time: at(0, "Year 0 · the island of the lotus-eaters"), // TODO(verify) label
odyssey: od(9, "lotus", "lotus-eaters"), // TODO(verify)
singers: ["Polites", "Odysseus"], motifs: ["open-arms", "six-hundred"],
parts: [
{ name: "Polites", singers: ["Polites"], motifIds: ["open-arms"] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
facts: ["Polites urges Odysseus to meet the world with kindness.", "Its melody returns later as a memory of Polites."], // TODO(verify) second fact
},
{
title: "Warrior of the Mind", saga: "troy", time: at(0, "Year 0 · Athena's counsel"),
odyssey: od(13, "Athene", "crafty", "guile"), // TODO(verify) Homer's Athene praises his craft on Ithaca
singers: ["Athena", "Odysseus"], motifs: ["warrior"],
parts: [
{ name: "Athena", singers: ["Athena"], motifIds: ["warrior"] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
facts: ["Athena, Odysseus's patron goddess, explains why she chose him.", "Gives the musical one of its central themes: strategy over strength."],
},
// ── The Cyclops Saga
{
title: "Polyphemus", saga: "cyclops", time: at(1, "Year 1 · the island of the Cyclopes"), // TODO(verify) year
odyssey: od(9, "Polyphemus", "Cyclops", "cave"),
singers: ["Polyphemus", "Odysseus"], motifs: [], landing: "cave",
facts: ["The crew feasts in a cave stocked with food and meets its owner, a Cyclops.", "As in Homer, Odysseus hides his real name from the Cyclops."], // TODO(verify) second fact placement
},
{
title: "Survive", saga: "cyclops", time: at(1, "Year 1 · inside the Cyclops' cave"), // TODO(verify) year
odyssey: od(9, "Noman", "stake", "wine"),
singers: ["Odysseus", "Polyphemus", "Soldiers"], motifs: ["six-hundred"], landing: "cave",
facts: ["The crew fights its way out of the cave.", "Polites dies in this song."], // TODO(verify) Polites' death here
},
{
title: "Remember Them", saga: "cyclops", time: at(1, "Year 1 · escaping the island"), // TODO(verify) year
odyssey: od(9, "ram", "Cyclops"),
singers: ["Odysseus", "Soldiers"], motifs: ["warrior"], // TODO(verify) singers
facts: ["Odysseus must decide what to do with the blinded Cyclops.", "He reveals his true name, as Homer's Odysseus does."], // TODO(verify)
},
{
title: "My Goodbye", saga: "cyclops", time: at(1, "Year 1 · leaving the island"), // TODO(verify) year
odyssey: od(13, "Athene", "Poseidon"), // TODO(verify) Homer's Athene explains why she kept away
singers: ["Athena", "Odysseus"], motifs: ["just-a-man", "warrior"], // TODO(verify) warrior motif
facts: ["Athena and Odysseus part ways over his choices on the island.", "Ends the Cyclops Saga."],
},
// ── The Ocean Saga
{
title: "Storm", saga: "ocean", time: at(1, "Year 1 · a storm at sea"), // TODO(verify) year
odyssey: null, // TODO(verify) no single Homer storm matches
singers: ["Odysseus", "Eurylochus", "Soldiers"], motifs: ["full-speed"],
facts: ["Opens the Ocean Saga with the fleet caught in a storm."],
},
{
title: "Luck Runs Out", saga: "ocean", time: at(1, "Year 1 · adrift after the storm"), // TODO(verify) year and label
odyssey: od(10, "Aeolus"), // TODO(verify)
singers: ["Odysseus", "Eurylochus", "Soldiers"], motifs: [], // TODO(verify) singers
facts: ["The crew's luck turns as Poseidon's anger follows them."], // TODO(verify)
},
{
title: "Keep Your Friends Close", saga: "ocean", time: at(1, "Year 1 · the island of Aeolus"), // TODO(verify) year
odyssey: od(10, "Aeolus", "winds", "wallet"),
singers: ["Aeolus", "Odysseus", "Eurylochus", "Soldiers"], motifs: ["friends-close", "full-speed"],
facts: ["Aeolus, keeper of the winds, gives Odysseus a bag that holds the winds.", "The bag is opened within sight of home, as in Homer."], // TODO(verify) second fact
},
{
title: "Ruthlessness", saga: "ocean", time: at(1, "Year 1 · Poseidon's reckoning"), // TODO(verify) year
odyssey: od(10, "Laestrygons", "Antiphates"), // TODO(verify) Homer loses the ships to the Laestrygons instead
singers: ["Poseidon", "Odysseus"], motifs: ["ruthlessness"],
facts: ["Poseidon takes revenge for his son Polyphemus.", "Most of the fleet is lost; in Homer, the Laestrygons destroy the ships."], // TODO(verify)
},
// ── The Circe Saga
{
title: "Puppeteer", saga: "circe", time: at(1, "Year 1 · Aeaea, Circe's island"), // TODO(verify) year
odyssey: od(10, "Circe", "swine", "Eurylochus"),
singers: ["Circe", "Eurylochus", "Odysseus"], motifs: [], // TODO(verify) singers
facts: ["Introduces the sorceress Circe.", "Eurylochus escapes to warn Odysseus, as in Homer."], // TODO(verify) second fact
},
{
title: "Wouldn’t You Like", saga: "circe", time: at(1, "Year 1 · Aeaea, Hermes appears"), // TODO(verify) year
odyssey: od(10, "Hermes", "Moly", "herb"),
singers: ["Hermes", "Odysseus"], motifs: ["fall-in-love"], // TODO(verify) motif
parts: [
{ name: "Hermes", singers: ["Hermes"], motifIds: [] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
facts: ["Hermes gives Odysseus a magic herb that protects him from Circe's spells (Homer's moly)."],
},
{
title: "Done For", saga: "circe", time: at(1, "Year 1 · Circe's hall"), // TODO(verify) year
odyssey: od(10, "Circe", "sword"),
singers: ["Circe", "Odysseus"], motifs: [],
facts: ["Odysseus confronts Circe to free his crew."],
},
{
title: "There Are Other Ways", saga: "circe", time: at(2, "Year 2 · leaving Aeaea"), // TODO(verify) year
odyssey: od(10, "Teiresias", "Hades", "Persephone"),
singers: ["Circe", "Odysseus"], motifs: ["just-a-man"],
facts: ["Circe points Odysseus towards the Underworld and the prophet Tiresias.", "In Homer, Odysseus stays a year on Aeaea before leaving."],
},
// ── The Underworld Saga
{
title: "The Underworld", saga: "underworld", time: at(2, "Year 2 · the land of the dead"), // TODO(verify) year
odyssey: od(11, "Elpenor", "Anticleia", "ghosts"),
singers: ["Odysseus", "Polites", "Anticlea", "Soldiers"], motifs: ["open-arms", "full-speed", "ruthlessness"], // TODO(verify) singers
facts: ["Odysseus meets the ghosts of those he has lost.", "His mother Anticlea is among them, as in Homer's Book 11."], // TODO(verify) Anticlea sings here
},
{
title: "No Longer You", saga: "underworld", time: at(2, "Year 2 · the prophet Tiresias"), // TODO(verify) year
odyssey: od(11, "Teiresias", "Thebes"),
singers: ["Tiresias"], motifs: [], // TODO(verify) singers
facts: ["The blind prophet Tiresias foretells what the way home will cost."],
},
{
title: "Monster", saga: "underworld", time: at(2, "Year 2 · leaving the Underworld"), // TODO(verify) year
odyssey: null, // EPIC's own turning point
singers: ["Odysseus", "Soldiers"], motifs: ["monster", "ruthlessness"], // TODO(verify) singers
facts: ["Odysseus decides to become ruthless to get home.", "Ends the Underworld Saga."],
},
// ── The Thunder Saga
{
title: "Suffering", saga: "thunder", time: at(2, "Year 2 · the Sirens' sea"), // TODO(verify) year
odyssey: od(12, "Sirens"),
singers: ["Sirens", "Odysseus", "Eurylochus"], motifs: ["in-the-water"], // TODO(verify) singers
facts: ["The Sirens try to lure the crew into the water."], // TODO(verify)
},
{
title: "Different Beast", saga: "thunder", time: at(2, "Year 2 · the Sirens' sea"), // TODO(verify) year
odyssey: od(12, "Sirens", "wax"),
singers: ["Odysseus", "Sirens"], motifs: ["in-the-water", "monster"], // TODO(verify) singers
facts: ["Odysseus shows how far he has changed since the Troy Saga."],
},
{
title: "Scylla", saga: "thunder", time: at(2, "Year 2 · Scylla's strait"), // TODO(verify) year
odyssey: od(12, "Scylla", "six"),
singers: ["Scylla", "Odysseus"], motifs: [], // TODO(verify) singers
facts: ["The six-headed monster Scylla guards the strait.", "As in Homer, Odysseus chooses to sail past Scylla and lose some men."], // TODO(verify) second fact
},
{
title: "Mutiny", saga: "thunder", time: at(2, "Year 2 · the island of the Sun god's cattle"), // TODO(verify) year
odyssey: od(12, "Eurylochus", "kine", "Thrinacia"),
singers: ["Eurylochus", "Odysseus", "Soldiers"], motifs: ["just-a-man", "full-speed"],
facts: ["Eurylochus turns the crew against Odysseus.", "In Homer, the starving crew eat the cattle of Helios on Thrinacia."],
},
{
title: "Thunder Bringer", saga: "thunder", time: at(3, "Year 3 · Zeus's judgement"), // TODO(verify) year
odyssey: od(12, "Zeus", "Helios", "bolt"),
singers: ["Zeus", "Odysseus", "Eurylochus"], motifs: ["thunder-bringer", "monster"],
facts: ["Zeus punishes the crew for the slain cattle.", "Odysseus is left the only survivor, as in Homer."],
},
// ── The Wisdom Saga
{
title: "Legendary", saga: "wisdom", time: at(10, "Year 10 · Ithaca, Telemachus at twenty"), // TODO(verify) year
odyssey: od(1, "Telemachus", "wooers"),
singers: ["Telemachus"], motifs: [],
facts: ["The first song sung by Telemachus, now grown up.", "Ithaca is overrun by suitors (Homer's wooers) courting Penelope."],
},
{
title: "Little Wolf", saga: "wisdom", time: at(10, "Year 10 · Ithaca, Athena trains Telemachus"), // TODO(verify) year
odyssey: od(1, "Athene", "Mentes", "Telemachus"),
singers: ["Athena", "Telemachus"], motifs: ["little-wolf", "warrior"], // TODO(verify) warrior motif
facts: ["Athena comes to Telemachus, as she does in disguise in Homer's Book 1."],
},
{
title: "We'll Be Fine", saga: "wisdom", time: at(10, "Year 10 · Ithaca, Athena and Telemachus"), // TODO(verify) year
odyssey: od(2, "Telemachus", "Mentor", "Athene"), // TODO(verify)
singers: ["Telemachus", "Athena"], motifs: ["little-wolf"], // TODO(verify) motif
facts: ["Telemachus helps Athena find her way back to Odysseus."], // TODO(verify)
},
{
title: "Love in Paradise", saga: "wisdom", time: at(10, "Year 10 · Ogygia, seven years with Calypso"), // TODO(verify) year
odyssey: od(5, "Calypso", "Hermes"),
singers: ["Calypso", "Odysseus"], motifs: ["open-arms", "friends-close", "ruthlessness"], // TODO(verify) motifs
facts: ["Calypso has kept Odysseus on her island for seven years, as in Homer."],
},
{
title: "God Games", saga: "wisdom", time: at(10, "Year 10 · Olympus"), // TODO(verify) year
odyssey: od(5, "Zeus", "Hermes", "Athene"),
singers: ["Athena", "Zeus", "Hermes", "Hera", "Aphrodite", "Ares", "Odysseus"], motifs: ["thunder-bringer", "warrior"], // TODO(verify) singers and motifs
landing: "arena",
facts: ["Athena challenges the Olympians to win Odysseus's release.", "In Homer, the gods in council send Hermes to free Odysseus."],
},
// ── The Vengeance Saga
{
title: "Not Sorry for Loving You", saga: "vengeance", time: at(10, "Year 10 · Ogygia"), // TODO(verify) year
odyssey: od(5, "Calypso", "raft"),
singers: ["Calypso"], motifs: ["fall-in-love"],
facts: ["Calypso's farewell as Odysseus is set free."],
},
{
title: "Dangerous", saga: "vengeance", time: at(10, "Year 10 · leaving Ogygia"), // TODO(verify) year
odyssey: null, // TODO(verify) EPIC's own plan
singers: ["Hermes", "Odysseus"], motifs: ["ruthlessness", "six-hundred"], // TODO(verify) singers
facts: ["Odysseus and Hermes plan how to escape Poseidon."], // TODO(verify)
},
{
title: "Charybdis", saga: "vengeance", time: at(10, "Year 10 · Charybdis"), // TODO(verify) year
odyssey: od(12, "Charybdis", "fig-tree"),
singers: ["Odysseus", "Hermes"], motifs: ["monster"], // TODO(verify) singers
facts: ["The whirlpool Charybdis becomes part of Odysseus's plan.", "In Homer, Odysseus survives Charybdis by clinging to a fig-tree."],
},
{
title: "Get in the Water", saga: "vengeance", time: at(10, "Year 10 · Poseidon's sea"), // TODO(verify) year
odyssey: od(5, "Poseidon", "raft"),
singers: ["Odysseus", "Poseidon", "Aeolus"], motifs: ["in-the-water", "ruthlessness"], // TODO(verify) singers
facts: ["Odysseus lures Poseidon into a trap."], // TODO(verify)
},
{
title: "Six Hundred Strike", saga: "vengeance", time: at(10, "Year 10 · the duel with Poseidon"), // TODO(verify) year
odyssey: null, // EPIC's own climax
singers: ["Odysseus", "Poseidon"], motifs: ["six-hundred", "ruthlessness", "monster"],
facts: ["Odysseus faces Poseidon in a final duel at sea.", "Ends the Vengeance Saga."], // TODO(verify) first fact
},
// ── The Ithaca Saga
{
title: "The Challenge", saga: "ithaca", time: at(10, "Year 10 · Ithaca, the palace hall"),
odyssey: od(21, "bow", "axes", "Penelope"),
singers: ["Antinous", "Penelope", "Odysseus", "Suitors"], motifs: [], // TODO(verify) singers
landing: "hall",
facts: ["Penelope sets the suitors a challenge with Odysseus's bow, as in Homer's Book 21."], // TODO(verify)
},
{
title: "Hold Them Down", saga: "ithaca", time: at(10, "Year 10 · Ithaca, the palace hall"),
odyssey: od(22, "wooers", "Antinous", "Eurymachus"), // TODO(verify)
singers: ["Antinous", "Suitors", "Odysseus"], motifs: [], // TODO(verify) singers
landing: "hall",
facts: ["The suitors close in on Odysseus."], // TODO(verify)
},
{
title: "Odysseus", saga: "ithaca", time: at(10, "Year 10 · Ithaca, the palace hall"),
odyssey: od(22, "wooers", "arrow", "Athene"),
singers: ["Odysseus", "Athena"], motifs: ["monster", "open-arms"], // TODO(verify) singers and motifs
landing: "hall",
facts: ["The battle against the suitors, told in Homer's Book 22.", "A callback song: themes from across the musical return."], // TODO(verify) second fact
},
{
title: "I Can't Help but Wonder", saga: "ithaca", time: at(10, "Year 10 · Ithaca, father and son"),
odyssey: od(16, "Telemachus", "father", "Eumaeus"),
singers: ["Odysseus", "Telemachus"], motifs: ["little-wolf"], // TODO(verify) motif
landing: "hall",
facts: ["Odysseus meets the grown son he left as an infant.", "In Homer, father and son reunite in Eumaeus's hut."],
},
{
title: "Would You Fall in Love with Me Again", saga: "ithaca", time: at(10, "Year 10 · Ithaca, Penelope"),
odyssey: od(23, "Penelope", "bed", "olive"),
singers: ["Penelope", "Odysseus"], motifs: ["fall-in-love"],
parts: [
{ name: "Penelope", singers: ["Penelope"], motifIds: [] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
landing: "hall",
facts: ["The final song of the musical.", "Penelope tests Odysseus, as she tests him with their bed in Homer's Book 23."],
},
];
const kebab = (title: string) =>
title.toLowerCase().replace(/[’']/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const sagaCounts = new Map<PlaceId, number>();
export const songs: AlbumSong[] = drafts.map((d, i) => {
const sagaTrack = (sagaCounts.get(d.saga) ?? 0) + 1;
sagaCounts.set(d.saga, sagaTrack);
const parts = (d.parts ?? []).map((p) => ({
...p,
motifIds: [...new Set([...p.singers.flatMap((s) => characterMotifBySinger[s] ?? []), ...p.motifIds])],
}));
const characters = d.singers.flatMap((s) => characterMotifBySinger[s] ?? []);
return {
id: kebab(d.title), title: d.title, sagaId: d.saga, track: i + 1, sagaTrack,
storyTime: d.time, odyssey: d.odyssey, singers: d.singers,
motifIds: [...new Set([...characters, ...d.motifs, ...parts.flatMap((p) => p.motifIds)])],
parts, facts: d.facts,
videos: [{ title: `${d.title} — search`, url: yt(`EPIC The Musical ${d.title}`) }],
landing: d.landing ?? null,
};
});
+27
View File
@@ -0,0 +1,27 @@
// Album data shapes for the voyage app. Independent of the mockups' src/data/types.ts.
export type LandingId = "cave" | "arena" | "hall";
export type PlaceId =
| "troy" | "cyclops" | "ocean" | "circe" | "underworld" | "thunder" | "wisdom" | "vengeance" | "ithaca";
export interface Motif { id: string; name: string; kind: "motif" | "character"; color: string; description: string }
/** `year` is the saga's release year as in the content folder name. */
export interface AlbumSaga { id: PlaceId; name: string; order: number; year: number; place: string; blurb: string }
/** A book of the Odyssey plus keywords that occur in that book (Butcher & Lang); url: Gutenberg #1728 (Butcher & Lang) book anchor. */
export interface OdysseyRef { book: number; keywords: string[]; url: string }
export interface AlbumPart { name: string; singers: string[]; motifIds: string[] }
export interface AlbumSong {
id: string; title: string; sagaId: PlaceId; track: number /* 1..40 album order */; sagaTrack: number;
storyTime: { yearsAfterTroy: number; label: string }; odyssey: OdysseyRef | null;
singers: string[]; motifIds: string[]; parts: AlbumPart[]; facts: string[];
videos: { title: string; url: string }[]; landing: LandingId | null;
}
export interface FanWork {
songId: string; type: "art" | "animatic" | "cover" | "cosplay" | "other";
title: string; creator: string; url: string; permission: string;
}
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
fanWorks, motifs, prevNextWithMotif, sagaOf, sagas, songById, songs, songsInSaga, songsWithMotif,
} from "../src/data/album";
import { scanLibrary } from "../src/content/library";
import { splitBooks } from "../src/content/homer";
const CONTENT = "/home/user/epic-the-musical-content";
const ODYSSEY = join(CONTENT, "Homers the Odyssey Book", "The Odyssey (Butcher and Lang).txt");
const TITLES = [
"The Horse and the Infant", "Just a Man", "Full Speed Ahead", "Open Arms", "Warrior of the Mind",
"Polyphemus", "Survive", "Remember Them", "My Goodbye",
"Storm", "Luck Runs Out", "Keep Your Friends Close", "Ruthlessness",
"Puppeteer", "Wouldn’t You Like", "Done For", "There Are Other Ways",
"The Underworld", "No Longer You", "Monster",
"Suffering", "Different Beast", "Scylla", "Mutiny", "Thunder Bringer",
"Legendary", "Little Wolf", "We'll Be Fine", "Love in Paradise", "God Games",
"Not Sorry for Loving You", "Dangerous", "Charybdis", "Get in the Water", "Six Hundred Strike",
"The Challenge", "Hold Them Down", "Odysseus", "I Can't Help but Wonder", "Would You Fall in Love with Me Again",
];
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
describe("album sagas", () => {
test("9 sagas ordered troy … ithaca", () => {
expect(sagas.map((s) => s.id)).toEqual([
"troy", "cyclops", "ocean", "circe", "underworld", "thunder", "wisdom", "vengeance", "ithaca",
]);
expect(sagas.map((s) => s.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test("every song is in exactly one known saga, sagaTrack runs 1..n", () => {
const ids = new Set(sagas.map((s) => s.id));
for (const song of songs) expect(ids.has(song.sagaId)).toBe(true);
for (const saga of sagas) {
const inSaga = songsInSaga(saga.id);
expect(inSaga.length).toBeGreaterThan(0);
expect(inSaga.map((s) => s.sagaTrack)).toEqual(inSaga.map((_, i) => i + 1));
for (const s of inSaga) expect(sagaOf(s)).toBe(saga);
}
});
});
describe("album songs", () => {
test("40 songs, tracks 1..40 contiguous", () => {
expect(songs.length).toBe(40);
expect(songs.map((s) => s.track)).toEqual(songs.map((_, i) => i + 1));
});
test("titles match the album in order", () => {
expect(songs.map((s) => s.title)).toEqual(TITLES);
});
test("ids are unique kebab-case titles", () => {
expect(new Set(songs.map((s) => s.id)).size).toBe(40);
expect(songById("wouldnt-you-like")?.title).toBe("Wouldn’t You Like");
expect(songById("ill-be-fine")).toBeUndefined();
for (const s of songs) expect(s.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
});
test("story time never goes backwards in album order", () => {
for (let i = 1; i < songs.length; i++) {
expect(songs[i]!.storyTime.yearsAfterTroy).toBeGreaterThanOrEqual(songs[i - 1]!.storyTime.yearsAfterTroy);
}
for (const s of songs) expect(s.storyTime.label.length).toBeGreaterThan(0);
});
test("landings as pinned", () => {
const landed = Object.fromEntries(songs.filter((s) => s.landing).map((s) => [s.id, s.landing]));
expect(landed).toEqual({
polyphemus: "cave", survive: "cave", "god-games": "arena",
"the-challenge": "hall", "hold-them-down": "hall", odysseus: "hall",
"i-cant-help-but-wonder": "hall", "would-you-fall-in-love-with-me-again": "hall",
});
});
test("every song has singers, 1–3 facts and a video link", () => {
for (const s of songs) {
expect(s.singers.length).toBeGreaterThan(0);
expect(s.facts.length).toBeGreaterThanOrEqual(1);
expect(s.facts.length).toBeLessThanOrEqual(3);
expect(s.videos.length).toBeGreaterThan(0);
for (const v of s.videos) expect(v.url).toStartWith("https://www.youtube.com/");
}
});
test("odyssey refs point at a Gutenberg #1728 (Butcher & Lang) book anchor", () => {
for (const s of songs) {
if (!s.odyssey) continue;
expect(s.odyssey.book).toBeGreaterThanOrEqual(1);
expect(s.odyssey.book).toBeLessThanOrEqual(24);
expect(s.odyssey.keywords.length).toBeGreaterThan(0);
expect(s.odyssey.url).toBe(
`https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap${String(s.odyssey.book).padStart(2, "0")}`,
);
}
});
test("fanWorks is empty in v1", () => {
expect(fanWorks).toEqual([]);
});
});
describe("album motifs", () => {
test("motif ids are unique; every referenced motif exists; every motif is used by ≥2 songs", () => {
const ids = new Set(motifs.map((m) => m.id));
expect(ids.size).toBe(motifs.length);
for (const s of songs) {
for (const id of s.motifIds) expect(ids.has(id)).toBe(true);
for (const p of s.parts) for (const id of p.motifIds) expect(ids.has(id)).toBe(true);
}
for (const m of motifs) expect(songsWithMotif(m.id).length).toBeGreaterThanOrEqual(2);
});
test("motif colours are hex colours", () => {
for (const m of motifs) expect(m.color).toMatch(/^#[0-9a-f]{6}$/i);
});
test("a song's part motifs are among its own motifs", () => {
for (const s of songs) for (const p of s.parts) for (const id of p.motifIds) expect(s.motifIds).toContain(id);
});
test("prevNextWithMotif on a middle occurrence returns both neighbours", () => {
const motif = motifs.find((m) => songsWithMotif(m.id).length >= 3)!;
const withIt = songsWithMotif(motif.id);
const mid = withIt[1]!;
expect(prevNextWithMotif(mid, motif.id)).toEqual({ prev: withIt[0]!, next: withIt[2]! });
expect(prevNextWithMotif(withIt[0]!, motif.id).prev).toBeNull();
expect(prevNextWithMotif(withIt.at(-1)!, motif.id).next).toBeNull();
});
});
describe("album vs the content folder", () => {
test.skipIf(!existsSync(join(CONTENT, "Songs and Lyrics")))("scanLibrary titles match album titles in order", () => {
const lib = scanLibrary(CONTENT).sagas.flatMap((s) => s.songs.map((x) => norm(x.title)));
expect(lib).toEqual(songs.map((s) => norm(s.title)));
});
test.skipIf(!existsSync(ODYSSEY))("odyssey keywords occur in that book of Butcher & Lang", () => {
const books = new Map(splitBooks(readFileSync(ODYSSEY, "utf8")).map((b) => [b.book, [b.summary, ...b.paragraphs].join(" ")]));
const missing: string[] = [];
for (const s of songs) {
if (!s.odyssey) continue;
const text = books.get(s.odyssey.book) ?? "";
for (const k of s.odyssey.keywords) if (!text.includes(k)) missing.push(`${s.id}: book ${s.odyssey.book} "${k}"`);
}
expect(missing).toEqual([]);
});
});