# Project export: Odyssey: The Journey Home

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: An AI-native literary journey where Homer remembers your choices—and returns them to you at the end.
- Devpost: https://devpost.com/software/odyssey-the-journey-home
- GitHub: https://github.com/Crystal32378/odyssey
- Demo: https://odyssey-preview.crystalys-chang.workers.dev/
- Video: https://www.youtube.com/embed/14uw02cCzM0?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Crystal Chang (55 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

# Odyssey — Your AI Journey Home

> **The map never changes. The traveler always does.**

Odyssey is an AI-native interactive journey inspired by Homer’s *Odyssey*. Every traveler moves through the same fourteen mythological stations, but Homer reshapes the narration around the traveler’s stated home goal and the choices recorded along the way.

GPT is not the hero. **GPT is Homer. The player is the hero.**

## The problem

Classical literature too often appears only in university classrooms: read once, analyzed for an assignment, and then left behind. Yet works such as *The Odyssey* contain enduring questions about home, temptation, loyalty, grief, identity, endurance, and return—questions that can reveal different meanings at different stages of life.

The barrier is not only access. The names, places, and episodes are difficult to remember, and most readers do not have a simple way to return to the text, reconnect its fragments, and reflect on what they mean now.

## The response

Odyssey condenses the essential arc of the epic into a simple, replayable fourteen-station journey. Its visual interface helps players remember key characters, places, and dilemmas, while their own choices make each return a new act of interpretation.

AI does not replace the classic, summarize it into trivia, or tell the player what to believe. It helps the text meet the traveler where they are.

> **Odyssey does not turn a classic into trivia. It makes the classic returnable.**

## Product idea

- The map is the interface, not an illustration.
- The fourteen-island sequence remains fixed.
- Narrative changes according to the player’s Journey Memory.
- AI interprets language and narrates; deterministic engine logic owns progress and scoring.
- Homer mirrors evidence from the journey and never diagnoses, coaches, or predicts.
- Optional voice lets the player actively choose to hear Homer recite the current passage.

## Public Preview

The current judging Preview is available at:

https://odyssey-preview.crystalys-chang.workers.dev

The judging Preview is deployed from the reviewed Build Week checkpoint identified below. PR [#12](https://github.com/Crystal32378/odyssey/pull/12), immutable tags, and the evidence record preserve the exact implementation and merge history.

The public Build Week demo is available on [YouTube](https://youtu.be/14uw02cCzM0). It runs for 2 minutes 41 seconds and includes English narration explaining the product, Codex, and GPT-5.6.

## Run locally

### Prerequisites

- Node.js 22.13 or newer
- npm
- an OpenAI API key with access to the configured text and speech models

### Setup

```bash
git clone https://github.com/Crystal32378/odyssey.git
cd odyssey
npm install
cp .env.local.example .env.local
```

Add the local API key to `.env.local`. Never commit that file.

```dotenv
OPENAI_API_KEY=your_api_key
HOMER_MODEL=gpt-5.6-sol
HOMER_AUDIO_MODEL=gpt-4o-mini-tts
DIVINE_MODEL=gpt-5.6-terra
LUNA_MODEL=gpt-5.6-luna
```

Start the development server:

```bash
npm run dev
```

The terminal prints the local URL when the server is ready.

### Verification commands

| Command | Purpose |
|---|---|
| `npm test` | Run the deterministic Engine, Homer, Divine Presence, Luna, D1 ledger, Soundscape, recovery, accessibility, and asset tests |
| `npm run lint` | Run ESLint across the application |
| `npm run build` | Run the configured model-set preflight, including Sol/Terra/Luna, and create the production Cloudflare build |
| `npm run deploy:preview` | Build and deploy the Preview-only Worker with authenticated Wrangler credentials |

The checked-in `wrangler.jsonc` names only the Preview Worker. Production and any formal domain require a separate review.

## Environment variables

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `OPENAI_API_KEY` | Yes | none | Server-side credential for Homer text and voice requests |
| `HOMER_MODEL` | No | `gpt-5.6-sol` | Responses API model used for Homer scenes, transitions, summaries, and Journey Cards |
| `HOMER_AUDIO_MODEL` | No | `gpt-4o-mini-tts` | Speech model used with the locked Onyx voice direction |
| `DIVINE_MODEL` | No | `gpt-5.6-terra` | Responses API model used for bounded Divine Presence text; it never replaces Homer or the Engine |
| `LUNA_MODEL` | No | `gpt-5.6-luna` | Fixed Responses API model for Circe, Sirens, and Calypso threshold encounters; browser input cannot select or override it |

Secrets stay server-side. Browser code never receives the OpenAI API key.

## Architecture

Odyssey keeps narrative interpretation separate from deterministic journey authority:

| Area | Responsibility |
|---|---|
| `app/page.tsx` | Map, fourteen-shore interaction, ending ritual, Journey Card presentation, and session recovery |
| `app/divine-presence-stage.tsx` | Dismissible Divine Presence presentation between a resolved voyage and the destination arrival |
| `lib/journey.ts` | Fixed island order, allowed action tags, stat changes, progression, and endings |
| `lib/homer-client.ts` | Browser-to-server Homer requests, timeouts, and safe retry behavior |
| `app/api/homer/route.ts` | GPT-5.6 Responses API calls, strict JSON Schemas, server-side output validation, and safe errors |
| `app/api/homer/audio/route.ts` | Optional Homer speech using Onyx and the locked recitation prompt |
| `app/api/divine/route.ts` | Shared encounter endpoint that dispatches only the server-owned `divine` and `luna` layers to their dedicated handlers |
| `lib/server/divine-handler.ts` | Terra requests, canonical trigger validation, strict Structured Outputs, and authored fallback behavior |
| `lib/divine.ts` and `lib/divine-session.ts` | Server-owned deity registry plus tab-local presentation, recovery, and dismissal state |
| `lib/server/luna-handler.ts` | Luna requests, server-owned character prompts, strict Structured Outputs, bounded retries, and authored fallback behavior |
| `lib/luna.ts` and `lib/luna-session.ts` | Three canonical threshold entries plus tab-local presentation, recovery, deduplication, and late-response protection |
| `lib/server/encounters/*` and `db/schema.ts` | Atomic, expiring D1 encounter receipts that prevent duplicate paid generation |
| `lib/soundscape.ts` and `app/soundscape-control.tsx` | Optional fail-open sea, sailing, Divine bird, and Ithaca loom presentation with persistent Mute / Unmute |
| `lib/api-boundary.ts` and `lib/homer-payload.ts` | Request size limits, rate limits, and sanitized Journey Memory payloads |
| `worker/index.ts` | Cloudflare Worker entry point and image delivery |

Journey Memory remains in browser `sessionStorage`; Odyssey does not create a remote player profile. The server receives only the bounded journey fields needed for the current Homer, Divine Presence, or Luna request. GPT interprets and narrates; `lib/journey.ts` alone decides progress, stats, action tags, and endings.

Divine Presence and Luna reuse one minimal Cloudflare D1 encounter-receipt ledger. Separate `layer` values make each `journeyId + layer + triggerId` idempotent without adding a second migration or player-profile system. A receipt stores an opaque journey UUID, layer, trigger, payload hash, status, final oracle/source, timestamps, and expiry. It does **not** store the full prompt, full player timeline, or a remote player profile. Receipts have a 24-hour logical expiry and an hourly best-effort physical cleanup job; the project does not claim immediate or strict physical deletion at the expiry instant.

## OpenAI integration

- Homer text uses the Responses API with the explicit default model `gpt-5.6-sol`.
- Six canonical Divine Presence encounters use the separate `gpt-5.6-terra` model; the server fixes the deity, lore, persona, trigger, image, voice family, and fallback.
- Circe, the Sirens, and Calypso each have one bounded threshold encounter using the fixed `gpt-5.6-luna` model; the server fixes the actor, trigger, persona, lore, request contract, output schema, and fallback.
- Every phase

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 70 recognized source files, 665 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (95 of 95)

```
.env.local.example
.gitignore
.openai/hosting.json
app/api/divine/route.ts
app/api/homer/audio/route.ts
app/api/homer/route.ts
app/chatgpt-auth.ts
app/divine-presence-stage.tsx
app/globals.css
app/layout.tsx
app/luna-threshold.tsx
app/page.tsx
app/penelope-recognition.tsx
app/soundscape-control.tsx
db/index.ts
db/schema.ts
design-qa.md
docs/audio-assets.md
docs/build-week-evidence.md
docs/codex-cli-gate5-handoff.md
docs/qa/clarity-local/desktop.json
docs/qa/clarity-local/mobile.json
docs/qa/soundscape-local/chrome-audit.json
docs/qa/soundscape-local/chrome-mobile-audit.json
docs/qa/soundscape-local/chrome-reduced-motion-mobile-audit.json
docs/qa/soundscape-local/cross-browser-smoke.json
docs/qa/soundscape-local/optional-audio-desktop.json
docs/qa/soundscape-local/optional-audio-mobile.json
docs/visual-assets.md
drizzle.config.ts
drizzle/0000_mixed_starbolt.sql
drizzle/meta/_journal.json
drizzle/meta/0000_snapshot.json
eslint.config.mjs
examples/d1/app/api/notes/route.ts
examples/d1/db/schema.ts
lib/api-boundary.ts
lib/divine-client.ts
lib/divine-session.ts
lib/divine.ts
lib/homer-client.ts
lib/homer-payload.ts
lib/island-art.ts
lib/journey.ts
lib/luna-client.ts
lib/luna-session.ts
lib/luna.ts
lib/server/divine-handler.ts
lib/server/encounters/d1-receipt-ledger.ts
lib/server/encounters/receipt-ledger.ts
lib/server/luna-handler.ts
lib/soundscape.ts
lib/voyage.ts
LICENSE
next.config.ts
package.json
pnpm-workspace.yaml
postcss.config.mjs
prompts/HOMER_SYSTEM_PROMPT.ts
README.md
scripts/capture-audio-rights.mjs
scripts/check-homer-model.mjs
scripts/clarity-browser-audit.mjs
scripts/cross-browser-smoke.mjs
scripts/local-browser-audit.mjs
scripts/local-optional-audio-audit.mjs
scripts/prepare-soundscape.mjs
tests/api-boundary.test.ts
tests/api-routes.test.ts
tests/clarity-pass.test.ts
tests/divine-api.test.ts
tests/divine-client.test.ts
tests/divine-presence-stage.test.ts
tests/divine-registry.test.ts
tests/divine-session.test.ts
tests/divine-wiring.test.ts
tests/encounter-ledger.test.ts
tests/homer-client.test.ts
tests/journey.test.ts
tests/luna-api.test.ts
tests/luna-client.test.ts
tests/luna-registry.test.ts
tests/luna-session.test.ts
tests/luna-threshold.test.ts
tests/luna-wiring.test.ts
tests/model-preflight.test.ts
tests/opening-title.test.ts
tests/penelope-recognition.test.ts
tests/soundscape.test.ts
tests/voyage.test.ts
tsconfig.json
vite.config.ts
worker/index.ts
WORKLOG.md
wrangler.jsonc
```

### Dependencies

- package.json: @cloudflare/vite-plugin@1.37.1, @tailwindcss/postcss@4.2.1, @types/node@22.19.19, @types/react@19.2.14, @types/react-dom@19.2.3, @vitejs/plugin-react@6.0.2, @vitejs/plugin-rsc@0.5.26, drizzle-kit@0.31.10, drizzle-orm@0.45.2, eslint@9.39.4, eslint-config-next@16.2.6, next@16.2.6, react@19.2.6, react-dom@19.2.6, react-server-dom-webpack@19.2.6, tailwindcss@4.2.1, typescript@5.9.3, vinext@0.0.50, vite@8.0.13, wrangler@4.92.0

### Recent commits (newest first)

- Merge pull request #15 from Crystal32378/codex/finalize-submission-evidence
- docs: finalize submission evidence
- Merge pull request #12 from Crystal32378/codex/build-week-core
- docs: align submission evidence with final build
- docs: add future directions for relational gods and live voice
- clarify journey entry and shore actions
- fade the sea on Ithaca arrival
- verify bird accent across six divine encounters
- refine soundscape narrative timing
- fix optional soundscape runtime playback
- add Penelope loom texture
- add restrained Divine bird accent
- refine soundscape mix and Homer ducking
- add mobile and reduced-motion soundscape evidence
- record local soundscape browser evidence
- add fail-open journey soundscape
- add submission-safe soundscape assets
- polish opening title line break
- fix Luna natural focus order
- refine Luna invitation hierarchy

## Key source files (fetched from GitHub, selected and truncated for size)

### WORKLOG.md

```markdown
# Odyssey Worklog

## Public launch blockers

- `PUBLIC_DEPLOY_BLOCKER: rate limiting required before public launch`
- `PUBLIC_DEPLOY_BLOCKER: Server must rebuild a strict allowlisted payload before sending requests to OpenAI. Do not forward the raw client body.`
- Add a request body size limit before public launch.

Before public deployment, the API boundary therefore requires all three controls: server-side allowlist payload reconstruction, request body size limiting, and rate limiting. These are deployment hardening tasks and do not block the fourteen-island MVP review.

## Scoring follow-up

- Journey stats have different theoretical maxima. Normalize them before any future comparative visualization.
- Raw scores are journey evidence, not objective personality measurements. Summary and Journey Card must remain a mirror grounded in quotes and named islands, never psychoanalysis.

```

### design-qa.md

```markdown
# Odyssey visual-polish design QA

- Source visual truth: supplied `Troy-2png.png` reference plus the locked homepage and visual constitution in the project handoff.
- Rendered implementation: `http://localhost:3000/` on `feature/visual-polish`.
- Browser evidence: `docs/qa/`.
- Primary desktop viewport: 1440 × 1000.
- Primary mobile viewport: 390 × 844.
- States exercised: homepage, Troy, Underworld, Calypso, Calypso ending, Ithaca ending, Summary, Journey Card.
- Primary interactions exercised: start journey, answer shores, advance deterministic sequence, choose Calypso ending, continue from Calypso, complete Ithaca, reveal Summary and Journey Card, restart journey.
- Console result: no warnings or errors reported by the browser.

## Full-view comparison evidence

- `docs/qa/troy-source-vs-implementation.png` places the source Troy painting and the 1440 × 1000 rendered Troy screen in one comparison image.
- `docs/qa/home-desktop.png` confirms that the locked map-first homepage composition is unchanged.
- `docs/qa/troy-mobile.png` confirms that the shore painting remains visible above the narrative at 390 × 844.
- `docs/qa/underworld-desktop.png` confirms that a later island is visually distinct while retaining the common editorial layout.
- `docs/qa/calypso-mobile.png` confirms mobile focal-point behavior on a lighter island.
- `docs/qa/ithaca-card-mobile.png` confirms the compact single-column Journey Card treatment.

## Focused comparison evidence

Focused checks were performed on:

- Island painting crops and overlays at desktop and mobile sizes.
- Homer medallion and audio control at mobile size.
- Journey Card crest, title, field hierarchy, quote, and shore count at mobile size.
- Transparent asset edges on dark preview backgrounds.

## Required fidelity surfaces

### Fonts and typography

- The established Georgia / Times editorial system is preserved.
- Island names retain strong classical display scale without clipping.
- Narrative, question, field labels, and controls preserve the existing hierarchy.
- Mobile Summary and Card use readable single-column line lengths and wrapping.

### Spacing and layout rhythm

- Desktop retains the established painting / narrative split.
- Mobile now presents a compact shore painting before narrative content.
- Primary and restart actions have separate visual roles and sufficient spacing.
- No horizontal overflow was measured at 390 px.

### Colors and visual tokens

- Existing deep navy, muted ivory, bronze, and amber tokens are preserved.
- Island paintings remain the dark mortal system; the locked pale marble map remains the threshold.
- Overlays are restrained and preserve image detail while supporting labels.

### Image quality and asset fidelity

- Fourteen supplied 4:3 paintings are mapped to fourteen islands.
- Optimized WebP derivatives retain sufficient detail at 1440 px source width.
- Island-specific focal positions prevent key subjects from being lost on mobile.
- Ship, crest, and Homer as
[truncated — 2276 more characters]
```

### package.json

```
{
  "name": "site-creator-vinext-starter",
  "version": "0.1.0",
  "private": true,
  "license": "MIT",
  "engines": {
    "node": ">=22.13.0"
  },
  "scripts": {
    "predev": "node scripts/check-homer-model.mjs",
    "dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
    "prebuild": "node scripts/check-homer-model.mjs",
    "build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
    "start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
    "deploy:preview": "npm run build && npm exec wrangler deploy",
    "test": "node --test tests/*.test.ts",
    "lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern .wrangler --ignore-pattern .vinext",
    "db:generate": "drizzle-kit generate"
  },
  "dependencies": {
    "drizzle-orm": "0.45.2",
    "next": "16.2.6",
    "react": "19.2.6",
    "react-dom": "19.2.6"
  },
  "devDependencies": {
    "@cloudflare/vite-plugin": "1.37.1",
    "@tailwindcss/postcss": "4.2.1",
    "@types/node": "22.19.19",
    "@types/react": "19.2.14",
    "@types/react-dom": "19.2.3",
    "@vitejs/plugin-react": "6.0.2",
    "@vitejs/plugin-rsc": "0.5.26",
    "drizzle-kit": "0.31.10",
    "eslint": "9.39.4",
    "eslint-config-next": "16.2.6",
    "react-server-dom-webpack": "19.2.6",
    "tailwindcss": "4.2.1",
    "typescript": "5.9.3",
    "vinext": "0.0.50",
    "vite": "8.0.13",
    "wrangler": "4.92.0"
  },
  "type": "module"
}

```

### db/index.ts

```typescript
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

export function getDb() {
  if (!env.DB) {
    throw new Error(
      "Cloudflare D1 binding `DB` is unavailable. Bind it through Wrangler or the hosting control plane before using the database."
    );
  }

  return drizzle(env.DB, { schema });
}

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";
import { SoundscapeControl } from "./soundscape-control";

export const metadata: Metadata = {
  title: "Odyssey · 返鄉之旅",
  description: "An AI-native mythic journey across fourteen shores.",
  icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="zh-Hant"><body>{children}<SoundscapeControl/></body></html>;
}

```

### worker/index.ts

```typescript
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
import { cleanupExpiredEncounterReceipts } from "../lib/server/encounters/d1-receipt-ledger";

interface Env {
  ASSETS: Fetcher;
  DB?: D1Database;
  IMAGES: {
    input(stream: ReadableStream): {
      transform(options: Record<string, unknown>): {
        output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
      };
    };
  };
}

interface ExecutionContext {
  waitUntil(promise: Promise<unknown>): void;
  passThroughOnException(): void;
}

// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };

const worker = {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/_vinext/image") {
      const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
      return handleImageOptimization(request, {
        fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
        transformImage: async (body, { width, format, quality }) => {
          const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
          return result.response();
        },
      }, allowedWidths);
    }

    return handler.fetch(request, env, ctx);
  },
  scheduled(controller: { scheduledTime: number }, env: Env, ctx: ExecutionContext) {
    if (!env.DB) {
      console.warn("Encounter receipt cleanup skipped: DB binding unavailable.");
      return;
    }
    ctx.waitUntil(
      cleanupExpiredEncounterReceipts(env.DB, controller.scheduledTime)
        .then((deleted) => console.info(`Encounter receipt cleanup removed ${deleted} expired row(s).`))
        .catch((error) => console.error("Encounter receipt cleanup failed.", error)),
    );
  },
};

export default worker;

```

### app/page.tsx

```typescript
"use client";

import { useEffect, useRef, useState, type CSSProperties } from "react";
import { DivinePresenceStage } from "./divine-presence-stage";
import { LunaThreshold } from "./luna-threshold";
import { PenelopeRecognition } from "./penelope-recognition";
import { requestDivineEncounter } from "../lib/divine-client";
import { getDivineTriggerForResolvedDeparture, type DivineTriggerId } from "../lib/divine";
import {
  activateDivineEncounter,
  clearDivineSession,
  createDivineSession,
  dismissDivineEncounter,
  readDivineSession,
  recoverDivineEncounter,
  resetDivineSession,
  writeDivineSession,
  type DivineSession,
} from "../lib/divine-session";
import { ENDING_REQUEST_TIMEOUT_MS, requestHomer } from "../lib/homer-client";
import { ISLAND_ART, ISLAND_PRESENTATION } from "../lib/island-art";
import { createJourneyMemory, getIsland, HomerScene, HomerTransition, ISLANDS, JourneyCard, JourneyMemory, JourneySummary, resolveIsland } from "../lib/journey";
import { requestLunaEncounter } from "../lib/luna-client";
import { getLunaTriggerForIslandIndex } from "../lib/luna";
import {
  activateLunaEncounter,
  clearLunaSession,
  createLunaSession,
  queueLunaEncounter,
  readLunaSession,
  reconcileLunaSession,
  resetLunaSession,
  writeLunaSession,
  type LunaSession,
} from "../lib/luna-session";
import { advanceCrossingGate, ARRIVAL_REVEAL_DURATION_MS, ARRIVAL_STAGE_DELAYS_MS, canBeginCrossing, createCrossingGate, crossingCanSettle, getVoyageLeg, JourneyPhase, recoverJourneyPhase, type CrossingGate, VOYAGE_CAMERA_MOBILE_START_SCALE, VOYAGE_CAMERA_START_SCALE, VOYAGE_DURATION_MS } from "../lib/voyage";
import { PENELOPE_LOOM_SESSION_KEY, soundscape } from "../lib/soundscape";

type Phase = JourneyPhase;
type AudioStatus = "idle" | "loading" | "ready" | "playing" | "error";
type EndingStage = "idle" | "summarizing" | "sealing";
interface SavedJourney { goal: string; phase: Phase; memory: JourneyMemory | null; scene: HomerScene | null; answer: string; resolution: string; summary: JourneySummary | null; card: JourneyCard | null; }
interface VoyageState {
  id: number;
  fromIndex: number;
  toIndex: number;
  playerInput: string;
  gate: CrossingGate;
  ending?: JourneyMemory["ending"];
  errorMessage: string;
  requestId: string;
  divineTrigger?: DivineTriggerId;
  resolvedMemory?: JourneyMemory;
}
const SESSION_KEY = "odyssey.fourteen-islands.v1";
const VOYAGE_ASSETS = ["/odyssey-map.png", "/assets/ship-token.webp"] as const;

async function decodeVoyageAsset(source: string) {
  const image = new Image();
  image.decoding = "async";
  image.src = source;
  try {
    await image.decode();
  } catch {
    if (image.complete) return;
    await new Promise<void>((resolve) => { image.onload = () => resolve(); image.onerror = () => resolve(); });
  }
}

export default function Home() {
  const [goal, setGoal] = useState(""); const [phase, setPhase] = useState<Phase>("map"); const [memory, setMemory] = useState<JourneyMemory | null>(null);
  const [scene, setScene] = useState<HomerScene | null>(null); const [answer, setAnswer] = useState(""); const [resolution, setResolution] = useState("");
  const [summary, setSummary] = useState<JourneySummary | null>(null); const [card, setCard] = useState<JourneyCard | null>(null);
  const [errorMessage, setErrorMessage] = useState(""); const [requestId, setRequestId] = useState(""); const [hydrated, setHydrated] = useState(false);
  const [audioStatus, setAudioStatus] = useState<AudioStatus>("idle"); const audioRef = useRef<HTMLAudioElement | null>(null); const audioUrlRef = useRef<string | null>(null);
  const [endingStage, setEndingStage] = useState<EndingStage>("idle");
  const [voyage, setVoyage] = useState<VoyageState | null>(null); const reducedMotion = usePrefersReducedMotion();
  const voyageRef = useRef<VoyageState | null>(null); const crossingSequenceRef = useRef(0); const crossingBusyRef = useRef(false);
  const [divineSession, setDivineSession] = useState<DivineSession | null>(null); const divineSessionRef = useRef<DivineSession | null>(null); const divineRequestRef = useRef<string | null>(null);
  const [lunaSession, setLunaSession] = useState<LunaSession | null>(null); const lunaSessionRef = useRef<LunaSession | null>(null); const lunaRequestRef = useRef<string | null>(null);
  const [completedLunaPresentation, setCompletedLunaPresentation] = useState<string | null>(null);

  // Session storage is an external source; this one-time hydration intentionally restores its snapshot.
  /* eslint-disable react-hooks/set-state-in-effect */
  useEffect(() => {
    let restoredMemory: JourneyMemory | null = null;
    try {
      const raw = sessionStorage.getItem(SESSION_KEY);
      if (raw) {
        const s = JSON.parse(raw) as SavedJourney;
        const interruptedEntry = s.phase === "loading" && !s.scene;
        const interruptedCrossing = (s.phase === "resolving" || s.phase === "voyaging") && Boolean(s.answer?.trim());
        restoredMemory = interruptedEntry ? null : s.memory;
        setGoal(s.goal || ""); setPhase(recoverJourneyPhase(s.phase, Boolean(s.scene))); setMemory(restoredMemory); setScene(s.scene); setAnswer(s.answer || ""); setResolution(s.resolution || ""); setSummary(s.summary); setCard(s.card);
        if (interruptedEntry) setErrorMessage("The first crossing was interrupted. Your Ithaca is preserved; begin again when ready.");
        else if (interruptedCrossing) setErrorMessage("The crossing was interrupted. Your answer is preserved; try the passage again.");
      }
    } catch {
      sessionStorage.removeItem(SESSION_KEY);
    }
    let nextDivine: DivineSession;
    try {
      nextDivine = restoredMemory
        ? recoverDivineEncounter(readDivineSession(sessionStorage), restoredMemory)
        : resetDivineSession(sessionStorage);
    } catch {
      nextDivine = createDivineSession();
    }
    divineSessionRef.current = nextDivine;
    setDivineSession(nextDivine);
    try {
      const restoredLuna = resto
[truncated — 32893 more characters]
```

### app/api/divine/route.ts

```typescript
import { env } from "cloudflare:workers";
import { handleDivineRequest } from "../../../lib/server/divine-handler.ts";
import { handleLunaRequest } from "../../../lib/server/luna-handler.ts";
import {
  createD1EncounterReceiptLedger,
  type EncounterD1Database,
} from "../../../lib/server/encounters/d1-receipt-ledger.ts";

export async function POST(request: Request) {
  const database = (env as { DB?: EncounterD1Database }).DB;
  const dependencies = {
    ledger: createD1EncounterReceiptLedger(database),
    apiKey: process.env.OPENAI_API_KEY,
    logger: console,
  };
  return request.headers.get("x-odyssey-encounter-layer") === "luna"
    ? handleLunaRequest(request, dependencies)
    : handleDivineRequest(request, dependencies);
}

```

### app/api/homer/route.ts

```typescript
import { NextResponse } from "next/server.js";
import { isRateLimited, readJsonWithLimit } from "../../../lib/api-boundary.ts";
import { cleanPayload } from "../../../lib/homer-payload.ts";
import { getIsland, ISLANDS } from "../../../lib/journey.ts";
import type { HomerScene, HomerTransition, JourneyCard, JourneySummary } from "../../../lib/journey.ts";

const HOMER_MAX_BODY_BYTES = 64 * 1024;

const sceneSchema = objectSchema({ narrative: { type: "string", maxLength: 150 }, question: { type: "string", maxLength: 120 } });
const transitionSchema = (tags: readonly string[]) => objectSchema({
  resolution: { type: "string", maxLength: 120 }, action_tag: { type: "string", enum: tags }, next_narrative: { type: "string", maxLength: 150 }, next_question: { type: "string", maxLength: 120 },
});
const summarySchema = objectSchema({ summary: { type: "string", maxLength: 1400 } });
const cardSchema = objectSchema({ title: bounded(90), strength: bounded(220), temptation: bounded(220), turningPoint: bounded(280), ithaca: bounded(280), quote: bounded(220) });

function bounded(maxLength: number) { return { type: "string", maxLength }; }
function objectSchema(properties: Record<string, unknown>) { return { type: "object", additionalProperties: false, properties, required: Object.keys(properties) }; }
function outputText(data: { output?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }> }) { return data.output?.find((item) => item.type === "message")?.content?.find((item) => item.type === "output_text")?.text; }
function text(value: unknown, max: number): value is string { return typeof value === "string" && value.length > 0 && value.length <= max; }
function validScene(value: unknown): value is HomerScene { const v = value as Record<string, unknown>; return !!v && Object.keys(v).length === 2 && text(v.narrative, 150) && text(v.question, 120); }
function validTransition(value: unknown, tags: readonly string[]): value is HomerTransition { const v = value as Record<string, unknown>; return !!v && Object.keys(v).length === 4 && text(v.resolution, 120) && typeof v.action_tag === "string" && tags.includes(v.action_tag) && text(v.next_narrative, 150) && text(v.next_question, 120); }
function validSummary(value: unknown): value is JourneySummary { const v = value as Record<string, unknown>; return !!v && Object.keys(v).length === 1 && text(v.summary, 1400); }
function validCard(value: unknown): value is JourneyCard { const v = value as Record<string, unknown>; return !!v && Object.keys(v).length === 6 && text(v.title, 90) && text(v.strength, 220) && text(v.temptation, 220) && text(v.turningPoint, 280) && text(v.ithaca, 280) && text(v.quote, 220); }

const constitution = `You are Homer, the oral poet of Odyssey—not a coach, therapist, personality analyst, or chatbot.
Preserve the classical world. Never replace myth with modern objects. The player is the hero; you are the witness.
Use only the Home Goal, exact player words, and timeline as evidence. Never claim hidden motives.
Write restrained, resonant English suitable for oral recitation. No praise, advice, fortune telling, or motivational language.
Narratives are at most 150 characters and questions at most 120 characters. Return only strict structured output.`;

export async function POST(request: Request) {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) return NextResponse.json({ error: "HOMER_KEY_MISSING", message: "The sea is silent." }, { status: 503 });
  if (isRateLimited(request, "homer")) return NextResponse.json({ error: "HOMER_RATE_LIMITED", message: "The sea asks for a brief silence." }, { status: 429 });
  const parsedBody = await readJsonWithLimit(request, HOMER_MAX_BODY_BYTES);
  if (!parsedBody.ok) return invalidInput(parsedBody.field, parsedBody.status);
  const body = parsedBody.value as Record<string, unknown>;
  const phases = ["enter", "resolve", "summary", "card"] as const;
  if (!body || !phases.includes(body.phase)) return invalidInput("phase");
  const phase = body.phase as (typeof phases)[number];
  const islandIndex = body.islandIndex;
  if ((phase === "enter" || phase === "resolve") && (!Number.isInteger(islandIndex) || islandIndex < 0 || islandIndex >= ISLANDS.length)) return invalidInput("islandIndex");
  const payload = cleanPayload(body, phase);
  if (!payload) return invalidInput("journey payload");
  const current = getIsland(payload.phase === "enter" || payload.phase === "resolve" ? payload.islandIndex : 0) || ISLANDS[0];
  const next = payload.phase === "enter" || payload.phase === "resolve" ? getIsland(payload.islandIndex + 1) : undefined;
  let schema: ReturnType<typeof objectSchema>;
  let name: string;
  let instruction: string;
  let validate: (value: unknown) => boolean;

  if (phase === "summary") {
    schema = summarySchema; name = "journey_summary"; validate = validSummary;
    instruction = `Write a conclusive journey summary in 140–220 words. Cite at least three different named islands and concrete recorded choices. If ending is Calypso, honor it as a valid ending without pretending Ithaca was reached.`;
  } else if (phase === "card") {
    schema = cardSchema; name = "journey_card"; validate = validCard;
    instruction = `Create a concise Journey Card grounded in the supplied stats, summary, and timeline. Mention at least three islands across the fields. "ithaca" means the traveler's true object of return; for a Calypso ending, clearly preserve that unresolved distance. Return quote as plain text without surrounding quotation marks.`;
  } else if (phase === "resolve") {
    schema = transitionSchema(current.allowedActionTags); name = "island_transition"; validate = (v) => validTransition(v, current.allowedActionTags);
    const nextContext = next ? `Next shore: ${next.name}\nNext epithet: ${next.epithet}\nNext mythic ground: ${next.myth}` : "This is Ithaca. Write a brief closing threshold in the next fields.";
    instruction = `Resolve ${current.name} from the player's
[truncated — 2187 more characters]
```

### app/api/homer/audio/route.ts

```typescript
import { NextResponse } from "next/server.js";
import { Buffer } from "node:buffer";
import { request as httpsRequest } from "node:https";
import { isRateLimited, readJsonWithLimit } from "../../../../lib/api-boundary.ts";

interface SpeechResponse {
  status: number;
  requestId: string | null;
  contentType: string | null;
  body: Buffer;
}

function createSpeech(apiKey: string, payload: object): Promise<SpeechResponse> {
  const body = JSON.stringify(payload);

  return new Promise((resolve, reject) => {
    const request = httpsRequest(
      "https://api.openai.com/v1/audio/speech",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(body),
          Authorization: `Bearer ${apiKey}`,
        },
      },
      (response) => {
        const chunks: Buffer[] = [];
        response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
        response.on("end", () => {
          const requestId = response.headers["x-request-id"];
          const contentType = response.headers["content-type"];
          resolve({
            status: response.statusCode ?? 500,
            requestId: Array.isArray(requestId) ? requestId[0] : requestId ?? null,
            contentType: Array.isArray(contentType) ? contentType[0] : contentType ?? null,
            body: Buffer.concat(chunks),
          });
        });
      },
    );

    request.on("error", reject);
    request.end(body);
  });
}

export async function POST(request: Request) {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: "AUDIO_KEY_MISSING" }, { status: 503 });
  }

  if (isRateLimited(request, "audio")) {
    return NextResponse.json({ error: "AUDIO_RATE_LIMITED" }, { status: 429 });
  }

  const parsedBody = await readJsonWithLimit(request, 2_000);
  if (!parsedBody.ok) {
    return NextResponse.json({ error: "AUDIO_INPUT_INVALID" }, { status: parsedBody.status });
  }

  const body = parsedBody.value as Record<string, unknown>;
  const text = typeof body?.text === "string" ? body.text.trim() : "";
  if (!text || text.length > 600) {
    return NextResponse.json({ error: "AUDIO_INPUT_INVALID" }, { status: 400 });
  }

  const response = await createSpeech(apiKey, {
    model: process.env.HOMER_AUDIO_MODEL || "gpt-4o-mini-tts",
    voice: "onyx",
    input: text,
    instructions:
      "Homer does not perform. He remembers. Recite as an ancient witness who has carried this story through countless generations. Use a deep, weathered, restrained tone. Speak very slowly, with long deliberate pauses between thoughts. Never rush, never sound eager, theatrical, conversational, motivational, or like a movie trailer. Let silence carry as much weight as the words. Add no words and preserve the text exactly.",
    response_format: "mp3",
  });

  if (response.status < 200 || response.status >= 300) {
    let safeError: { error?: { code?: string; type?: string; message?: string } } = {};
    try {
      safeError = JSON.parse(response.body.toString("utf8") || "{}");
    } catch {
      // Keep upstream error bodies out of user-facing output.
    }
    console.error("Homer audio failure", {
      status: response.status,
      requestId: response.requestId,
      code: safeError?.error?.code,
      type: safeError?.error?.type,
    });
    return NextResponse.json(
      { error: "AUDIO_UNAVAILABLE", requestId: response.requestId },
      { status: 502 },
    );
  }

  return new Response(response.body, {
    headers: {
      "Content-Type": response.contentType || "audio/mpeg",
      "Cache-Control": "private, max-age=3600",
    },
  });
}

```

[59 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]