# Project export: Langtour: A game to learn languages in real-life scenarios.

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: UC Berkeley AI Hackathon 2026
- Tagline: Langtour is a story-mode language learning game where players travel across the globe, completing real-life scenarios to master new languages.
- Devpost: https://devpost.com/software/langtour
- GitHub: https://github.com/darthvader58/langtour
- Demo: https://langtour-production.up.railway.app/
- Video: https://www.youtube.com/embed/7dNyAFo0sFo?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Vivek Patel (41 commits), Shashwat Raj (31 commits), michaeltin001 (19 commits)

## Devpost submission (written by the team)

### Inspiration

The inspiration for Langtour was born out of frustration with traditional language apps. Rote memorization and highly scripted lessons often strip away the most crucial part of language: human connection and context. We wanted to build something that bridges the gap between studying vocabulary and actually speaking it. By dynamically generating real life scenarios, we aim to transform language learning into an interactive and practical adventure.

### What it does

Langtour gamifies language learning by immersing players in a global storyline. Players begin on an interactive 3D globe displaying the target language. Starting with an initial balance of LangCoins, players unlock their first country, causing the globe to rotate to that location. Upon entering a new country, a story pop-up assigns the player a unique role, such as an undercover spy, adding narrative stakes to the lessons. As players navigate a country, they face multiple scenarios presented in a grid. Each scenario acts as a distinct level representing a real life situation, like ordering at a restaurant or decoding a local newspaper. Before tackling a scenario, players are equipped with customized lessons tailored to their current vocabulary. Scenarios must be unlocked sequentially, with an adaptive AI dynamically dictating progression based on the player's improvements. Inside a scenario, the core interaction is entirely voice powered using speech-to-text (STT). Players speak directly into the microphone in response to dynamic NPC prompts. An AI agent provides real time, forgiving feedback and pronunciation improvements. User progress is saved as an expanding conceptual word graph. Mastering vocabulary in one scenario automatically unlocks related scenarios with overlapping terminology. The capstone of every country is the "Boss Level," an unscripted, real life conversation created on the fly by the AI. Conquering it finishes the country and awards the LangCoins necessary to unlock the next destination.

### How we built it

Under the hood, Langtour uses a modern stack (React/Vite/Three.js frontend, Node.js/Express/Supabase backend) to drive an adaptive procedural loop: Scenario Initialization & Vocabulary Selection: When a player starts a scenario, the backend dynamically calculates the optimal target vocabulary. It queries the Supabase database via fsrs.js (Free Spaced Repetition Scheduler) to inject words that are due for review. Simultaneously, @ai-sdk/google (gemini-embedding-2) embeds the scenario topic and calculates cosine similarity against unknown vocabulary, selecting new words that are highly relevant to the context and anchored to words the user already knows well. Contextual Dialog Generation: With the target vocabulary selected, the backend uses the Vercel AI SDK to prompt gemini-2.5-flash. The prompt includes the scenario context and the chosen target words, instructing the AI to generate the next NPC dialogue line specifically designed to elicit those words from the user naturally. Speech-to-Text & Real-Time Evaluation: The user speaks their reply into the microphone, which is transcribed in real time via the Deepgram STT API. The transcribed response is sent back to the backend, where a secondary AI prompt acts as an evaluator. It checks the transcription to ensure the user appropriately utilized at least one target word in context, while forgiving minor grammatical mistakes. Memory Tracking & Progression Updates: If the evaluator marks the response as successful, the backend automatically updates the fsrs.js stability and difficulty metrics for the successfully used target word. This transaction is saved to the Supabase database, adjusting the expanding word graph and influencing which scenarios and review words will appear next.

### Challenges we ran into

The most formidable hurdle was minimizing latency within our voice and AI pipelines. In order for the conversation to be immersive, the dialogue must mimic the natural cadence of a real conversation. We leveraged Deepgram to ensure our system could efficiently process audio, transcribe it, analyze grammar, and generate a contextual response. Their ultra-low latency API solved our biggest bottleneck, allowing us to focus our optimization efforts purely on the LLM prompt engineering. Balancing the strictness of the Spaced Repetition System with the leniency needed for flowing conversation also took meticulous tuning.

### Accomplishments we're proud of

We are proud of our app's integration with Deepgram, which serves as the lightning-fast, highly accurate backbone for all of our voice-powered interactions. Building a seamless, real time conversational interface wouldn't have been possible without it. Additionally, constructing the dynamic vocabulary discovery engine from scratch was a major achievement. We learned how to correctly manage our backend and Supabase database to seamlessly bridge a complex Spaced Repetition System with a semantic word graph.

### What we learned

Building Langtour taught us how to orchestrate multi-agent AI workflows. We learned how to coordinate specialized agents, assigning one to handle narrative dialogue and another to act as a linguistic evaluator. We also gained a deep appreciation for the complexities of memory tracking algorithms like FSRS, and learned how to apply semantic vector embeddings to create interconnected learning paths.

### What's next

for Langtour Moving forward, we plan to expand the interactive globe, unlocking dozens of new countries and introducing a wider array of languages. We aim to deepen the multi-agent scenarios, introducing branching narratives and more complex situations. Finally, we want to bring this experience to mobile devices, allowing users to practice their language skills seamlessly on the go. Setup & Installation You must have Node.js installed and provide your own Deepgram API key and Google Gemini API key. If you are cloning this project from GitHub on a completely fresh machine, follow these steps to get everything working: 1. Install Dependencies Because this project is structured with a separate frontend (client/) and backend (node/), you must install the dependencies in three different folders: 3. Configure Environment Variables You need to manually create two environment files (.env and .env.local) in the root directory to store your API and database keys securely: .env file: .env.local file: (Make sure to replace the placeholder values with your actual API keys). 4. Start the Application Once the dependencies are installed and the keys are provided, you can spin up both the Vite frontend and Express backend simultaneously from the root folder using concurrently: The app will now be running locally.

## README (from the GitHub repository)

# Langtour: Speak the World

Langtour is a speech-driven, story-mode language game. You don't drill flashcards — you *travel*. You spin a 3D globe, spend LangCoins to unlock a country, slip into a disguise, and clear the real-life situations a tourist actually hits by **speaking** your way through them. A voice in your ear coaches you, an in-scene character talks back, and the only thing that advances the story is a sentence you actually said out loud — and said well.

---

## The story of the Langtourist

> *You're a langtourist — a traveler who never visits anywhere as just a tourist.*
>
> *Every country you unlock hands you a new cover story and a new voice in your ear, walking you through it.*
>
> *The costume changes. The rule doesn't: you only pass as a local once you can actually talk like one.*
>
> *Pack light. Speak up. Let's go.*

That framing pops up once, before you've picked anywhere to go. The tone is deliberately Sackboy / LittleBigPlanet — a toybox world of costumes, not a spy thriller. Then every country you land in reframes you as a different character with a different reason to blend in, and gives you a **sidekick** — a companion in your ear whose whole job is getting you through your cover convincingly.

The disguise is never decoration. It's the *reason* the vocabulary matters: you're not "learning to order food," you're a spy who'll get made if you ask a market vendor the price like a tourist. Each of the six launch countries has its own cover and its own sidekick, authored once as canon in `shared/personaCanon.js` and read by both the UI (arrival story, lore codex) and the server (the sidekick's live voice), so the character you meet in the popup is the same one who corrects your grammar mid-scene:

| Country | Your cover | Sidekick | Their voice |
|---------|-----------|----------|-------------|
| 🇨🇳 China | **Spy** infiltrating a Shanghai black market | **Wren**, your handler on the radio | Dry, clipped, Watson-to-your-Sherlock. *"You're not blending into the crowd correctly."* |
| 🇮🇳 India | **Bollywood actor** chasing a first callback in Mumbai | **Rhea**, a fast-talking talent agent | Hype and hustle, but she'll cut a flat line reading |
| 🇫🇷 France | **Art thief** casing the Louvre | a fence who moves stolen canvases | Cool, exacting, Marcel-register French |
| 🇲🇽 Mexico | **Treasure hunter** on the trail of a relic | a guide who's done this before | Adventurous, warns you off the obvious traps |
| 🇪🇬 Egypt | **Archaeologist** reading a tomb | a scholar-companion | Patient, teacherly, respectful of the place |
| 🇧🇷 Brazil | **Undercover journalist** chasing a story in Rio | a local reporter | Warm, quick, keeps you on the story |

The sidekick praises you in character, corrects you in character, and — crucially — **can't do the one thing you have to do yourself: talk.** That's the whole game.

---

## The full user flow

A run through Langtour, from the globe to unlocking the next country:

1. **Land on the globe.** A 3D Earth (Three.js) shows the world with land geometry drawn from GeoJSON polygons. New players start with **100 LangCoins and zero countries unlocked** — the first thing you do is *choose* where to go.

2. **Unlock a country.** Spending 100 LangCoins unlocks a country and rotates the globe to it. The cost, the balance check, and the unlock are all decided **server-side** by a Postgres RPC — the client can ask to unlock, but never computes or asserts a balance. Unlocking the *next* country is gated on having actually finished (and claimed the reward for) the one before it.

3. **Meet your cover.** An arrival story pops up: a few tap-to-continue pages introducing your disguise and your sidekick, plus a lore codex you can revisit. The UI re-skins itself to that character — palette, accent, motifs, sidekick portrait — all driven by theme tokens keyed to the character, never hardcoded per screen.

4. **Pick a scenario.** Each country is a chain of real-life situations (market, restaurant, directions, reading a sign…). They're not a fixed list authored by hand — the chain is **generated for you**, each new situation seeded from the vocabulary you just mastered in the previous one (see *Forward-chaining* below). You clear them in order.

5. **Play a turn, out loud.** Inside a scenario, an in-scene character (the "NPC") says a line — shown with the native script, a phonetic reading, and a translation. You **speak your reply into the mic.** Deepgram transcribes it live. Your transcript goes to the server's evaluator, which decides pass or fail against a strict three-part rubric (below). On a pass, the sidekick praises you and the words you used correctly get logged into your memory. On a fail, the sidekick *teaches* — it names what went wrong (off-topic, too vague, bare word, grammar, wrong word) and nudges you toward a fix **without handing you the correct sentence.**

6. **Watch the scenario grow.** A situation doesn't have a fixed word count. It starts with 3–4 target words and **grows** its target set as you actually use words, up to an adaptive cap sized to what a tourist genuinely needs there and how fast you're moving. The scenario is only *complete* when the set has grown to its cap and you've used every word in it correctly.

7. **Finish the country and get paid.** When your whole generated chain for a country is covered and every scenario in it is complete, you can claim the country's LangCoin reward — again, a server RPC verifies completeness and pays the server-side reward value. That's the fuel to unlock your next destination, and the loop repeats with a new cover and a new sidekick.

8. **See your forest.** Everything you've learned renders as a living **word graph** — a 3D constellation growing from a root, out to a tree per situation-type, out to the full language. Words are colored by how well you know them. You don't just have a score; you can *see* the language filling in.

> **On fairness / anti-cheat:** completion is *earned by speaking*, full stop. The server's evaluator is the only thing that can mark a turn passed, and only a passed turn (with the scenario's goal met) records a completion. There's a single admin-only skip button for the owner's own testing, gated on a server-verified email — no player can reach it.

---

## How the pieces work

### Speech-to-text (Deepgram)

Voice is the core interaction, not a feature. The pipeline uses **Deepgram** on two paths, each tuned for a different job (`node/lib/voice/langParams.js`, `node/routes/voice.js`):

- **Live streaming** (`wss://…/listen`) gives you low-latency interim captions as you speak, so the conversation feels like a conversation. It runs `nova-3` for all six languages, with `interim_results=true` and `utterance_end_ms=1000` (Deepgram rejects the stream if that's below 1000 — a real bug we hit and fixed). This path feeds the on-screen text only.
- **Batch transcription** (`https://…/listen`) runs over the *complete* recorded utterance with accuracy prioritized over latency: `punctuate=true`, `smart_format=true`, and diarization. This is the **authoritative** transcript that feeds the evaluator.

Model choice is per-language and deliberate: batch uses `nova-2` for Chinese (verified stronger Chinese coverage and diarization on complete audio) but `nova-3` for Hindi, French, Spanish, Arabic, and Portuguese — `nova-3` is more robust to the **non-native accents this whole game is built around**, and critically, `nova-2` doesn't support Arabic at all (an early hardcoded `nova-2` batch URL silently broke Egypt entirely until it was routed to `nova-3`).

The transcriber's only job is to capture *what you said*. It does **not** decide whether you passed — that's the evaluator's call. This split matters: it means a mumbled-but-correct answer and a crystal-clear-but-off-topic answer are judged on *meaning*, not audio quality.

*(An optional pronunciation-scoring layer sits behind a swappable `scorePronunciation(audio, lang, target

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 143 recognized source files, 794 KB.
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- Google Gemini (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- Vercel AI SDK (technology) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (120 of 152)

```
.env.example
.gitignore
.nvmrc
client/.gitignore
client/eslint.config.js
client/index.html
client/package.json
client/postcss.config.js
client/README.md
client/src/api.js
client/src/App.css
client/src/App.jsx
client/src/assets/landPolygons.js
client/src/auth/passwordPolicy.js
client/src/auth/passwordPolicy.test.js
client/src/CharacterStoryPopup.jsx
client/src/CompletionScreen.jsx
client/src/components/AuthModal.jsx
client/src/components/CognitiveLoadMeter.jsx
client/src/components/GameplayPhase.jsx
client/src/components/growthModel.js
client/src/components/growthModel.test.js
client/src/components/InputPhase.jsx
client/src/components/LevelUpAnimation.jsx
client/src/components/LoreCodex.jsx
client/src/components/MicrophoneRecorder.jsx
client/src/components/PassportStamp.jsx
client/src/components/profile/constellationModel.js
client/src/components/profile/constellationModel.test.js
client/src/components/profile/profileModel.js
client/src/components/profile/profileModel.test.js
client/src/components/profile/ProgressNavigator.jsx
client/src/components/profile/userProfile.css
client/src/components/profile/UserProfileOverlay.jsx
client/src/components/profile/WordConstellation3D.jsx
client/src/components/ScenarioRunner.jsx
client/src/components/SegmentedText.jsx
client/src/components/supersetIcons.js
client/src/components/supersetIcons.test.js
client/src/components/ToyIcon.jsx
client/src/components/ui/Icon.jsx
client/src/components/ui/primitives.jsx
client/src/components/VisualCluster.jsx
client/src/components/VocabularyWeb.jsx
client/src/components/voice/AudioTranscriptPlayer.jsx
client/src/components/voice/LiveWaveform.jsx
client/src/components/voice/Waveform.jsx
client/src/components/voice/waveformPeaks.js
client/src/components/VoiceWaveform.jsx
client/src/components/wordDisplay.js
client/src/components/wordDisplay.test.js
client/src/countryTheme.js
client/src/countryTheme.test.js
client/src/gameData.js
client/src/hooks/useProfile.js
client/src/index.css
client/src/LandingPage.jsx
client/src/lib/supabase.js
client/src/loreCodex.js
client/src/loreCodex.test.js
client/src/main.jsx
client/src/missionListApi.js
client/src/missionListApi.test.js
client/src/missionListModel.js
client/src/missionListModel.test.js
client/src/pages/VoiceTestPage.jsx
client/src/profile/profileApi.js
client/src/profile/profileApi.test.js
client/src/profile/useUserProfileData.js
client/src/ScenariosPage.jsx
client/src/storyData.js
client/src/storyData.test.js
client/src/storyGate.js
client/src/storyGate.test.js
client/src/theme.js
client/vite.config.js
docs/contracts/ai-module.md
docs/contracts/auth-hardening.md
docs/contracts/economy-rpcs.md
docs/contracts/scenario-list.md
docs/contracts/sidekick-personas.md
docs/contracts/speech-pipeline.md
docs/contracts/story-narration.md
docs/contracts/supermemory-forest.md
docs/contracts/theme-tokens.md
docs/contracts/word-graph-payload.md
docs/security/adversarial-pass-2026-07.md
docs/security/auth-hardening-checklist.md
node/lib/ai/errors.js
node/lib/ai/index.js
node/lib/ai/languages.js
node/lib/ai/model.js
node/lib/ai/personas.js
node/lib/ai/prompts/evaluate_response.js
node/lib/ai/prompts/generate_turn.js
node/lib/auth.js
node/lib/config.js
node/lib/db/db.js
node/lib/graph/chain.js
node/lib/graph/graph.js
node/lib/graph/growth.js
node/lib/memory/forest.js
node/lib/profile/history.js
node/lib/profile/metrics.js
node/lib/profile/wordGraph.js
node/lib/srs/fsrs_metrics.js
node/lib/srs/fsrs_update.js
node/lib/srs/onboarding_vocab.js
node/lib/sse.js
node/lib/voice/langParams.js
node/lib/voice/projectStore.js
node/lib/voice/pronunciation.js
node/package.json
node/routes/profile.js
node/routes/scenario.js
node/routes/voice.js
node/scripts/migrateSqliteToSupabase.js
node/scripts/pronunciation-proveout.js
node/server.js
node/test/ai_model_chain.test.js
[32 more files omitted for size]
```

### Dependencies

- client/package.json: @eslint/js@^10.0.1, @supabase/supabase-js@^2.108.2, @tailwindcss/postcss@^4.3.1, @types/react@^19.2.14, @types/react-dom@^19.2.3, @vitejs/plugin-react@^6.0.1, autoprefixer@^10.5.0, d3@^7.9.0, eslint@^10.3.0, eslint-plugin-react-hooks@^7.1.1, eslint-plugin-react-refresh@^0.5.2, globals@^17.6.0, jsdom@^29.1.1, postcss@^8.5.15, react@^19.2.6, react-dom@^19.2.6, tailwindcss@^4.3.1, three@^0.184.0, vite@^8.0.12, vitest@^4.1.9
- node/package.json: @ai-sdk/cerebras@^2.0.65, @ai-sdk/google@^3.0.83, @ai-sdk/groq@^3.0.50, @supabase/supabase-js@^2.108.2, @supermemory/tools@2.0.0, ai@^6.0.208, cors@^2.8.6, dotenv@^17.4.2, express@^5.2.1, fsrs.js@^1.2.2, supermemory@4.24.12, ws@^8.21.0
- package.json: @supabase/ssr@^0.12.0, @supabase/supabase-js@^2.108.2, concurrently@^10.0.3

### Recent commits (newest first)

- docs: rewrite README around current engine, story, and flow
- feat: harden email signup with password policy and reset flow
- feat: add Arabic and Portuguese starter vocabulary sets
- feat: render generated mission chain and server-driven completion
- add scenario list endpoint mirroring the claim-reward gate
- fix: pin ai-sdk provider majors to spec v3 for ai@6 compatibility
- feat: add model provider fallback chain with quota-aware 503 handling
- feat: tap-through arrival stories and server-gated lore codex from persona canon
- feat: shared persona canon with elaborated lore wired into sidekick AI
- fix: normalize country codes to ISO-2 across seeder, client, and db
- fix: revoke Supabase default function grants reopening completion RPC
- docs: add build contracts and phase-5 adversarial security note
- fix: make scenario completion server-authoritative with admin-on
- chore: migrate to @supermemory/tools for ai@6 compatibility
- feat: story mode, character theme tokens, and forest constellation
- feat: adapt scenario ui to growing word sets and sidekick feedback
- feat: expose word forest structure in profile wor
- feat: forward-chain scenarios with growing word targets and server-side completion
- feat: harden deepgram pipeline, fix arabic stt, add optional pronunciation scorer
- feat: supermemory-backed word forest with postgres mirror

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

### docs/contracts/word-graph-payload.md

```markdown
# Contract: /api/profile/word-graph forest extension

Status: FROZEN (Phase 4). backend-graph implements; frontend-story consumes.

Existing response fields (nodes with PCA coords, edges, missing count) stay byte-compatible. Additions, sourced from `learning_user_word_forest` (never from Supermemory — the endpoint must not call it):

- Each word node gains: `superset: string|null`, `masteryTier: number` (0–3, default 0), `lastUsedAt: ISO string|null`. Words absent from the mirror get `superset: null, masteryTier: 0, lastUsedAt: null`.
- Top-level addition: `trees: [{ superset: string, wordIds: [...] }]` — one entry per superset present for the user, for root → tree → word rendering.

Mastery tiers (display semantics): 0 = encountered, 1 = used once, 2 = recurring, 3 = mastered. The mirror's `mastery_tier` is authoritative; the endpoint does not recompute it.

```

### docs/contracts/theme-tokens.md

```markdown
# Contract: character theme tokens

Status: FROZEN (Phase 2). Agreed between frontend-story (owner of countryTheme.js) and game-ai (sidekick identity).

## Shape
`countryTheme.js` grows from `{accent, soft, ink, glow}` to a full token set keyed by **country name** (existing key, kept), each entry carrying its character:

```js
THEMES[country] = {
  characterId,           // e.g. 'shanghai-spy' — must match personaId in sidekick-personas.md
  palette: { accent, soft, ink, glow },   // existing four, unchanged semantics
  surface: { bg, card, border },          // page/card/border tints derived from the archetype
  motif:   { texture, icon },             // CSS background token + emoji/icon slug for headers
  sidekick:{ id, name, portrait },        // portrait = asset path; id/name from sidekick-personas.md
}
```

## CSS variables (the only way components consume theme)
Existing vars stay byte-identical so nothing breaks: `--accent`, `--accent-soft`, `--accent-ink`, `--accent-glow`, `--accent-10/15/20/25/30/40/55`.
Added by `getCountryThemeStyle`: `--surface-bg`, `--surface-card`, `--surface-border`, `--motif-texture`, `--sidekick-portrait` (url()).

Rules: no per-component hardcoded colors; components read vars only. Unknown country falls back to China theme (existing behavior, kept). New countries are data-only additions to `THEMES` + catalog.

## Seam with game-ai
`characterId`/`sidekick.id` is the shared key: game-ai selects persona voice by it (`personaId` in ai-module.md ctx), frontend selects portrait/palette by it. Neither side invents ids — the list lives in sidekick-personas.md.

```

### package.json

```
{
  "name": "langtour",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "concurrently \"npm run dev --prefix node\" \"npm run dev --prefix client\"",
    "build": "npm ci --prefix client && npm ci --prefix node --omit=dev && npm run build --prefix client",
    "start": "npm start --prefix node"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/darthvader58/langtour.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "engines": {
    "node": ">=22.12"
  },
  "bugs": {
    "url": "https://github.com/darthvader58/langtour/issues"
  },
  "homepage": "https://github.com/darthvader58/langtour#readme",
  "devDependencies": {
    "concurrently": "^10.0.3"
  },
  "dependencies": {
    "@supabase/ssr": "^0.12.0",
    "@supabase/supabase-js": "^2.108.2"
  }
}

```

### shared/package.json

```
{
  "name": "@langtour/shared",
  "private": true,
  "type": "module",
  "description": "Dependency-free ESM shared between client/ and node/ — e.g. shared/personaCanon.js. See docs/contracts/story-narration.md."
}

```

### node/package.json

```
{
  "name": "node",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "node --test 'test/**/*.test.js'",
    "dev": "node server.js",
    "start": "node server.js",
    "migrate:sqlite": "node scripts/migrateSqliteToSupabase.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "engines": {
    "node": ">=22.12"
  },
  "dependencies": {
    "@ai-sdk/cerebras": "^2.0.65",
    "@ai-sdk/google": "^3.0.83",
    "@ai-sdk/groq": "^3.0.50",
    "@supabase/supabase-js": "^2.108.2",
    "@supermemory/tools": "2.0.0",
    "ai": "^6.0.208",
    "cors": "^2.8.6",
    "dotenv": "^17.4.2",
    "express": "^5.2.1",
    "fsrs.js": "^1.2.2",
    "supermemory": "4.24.12",
    "ws": "^8.21.0"
  }
}

```

### client/package.json

```
{
  "name": "client",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "engines": {
    "node": ">=22.12"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "test": "vitest",
    "test:watch": "vitest watch"
  },
  "dependencies": {
    "@supabase/supabase-js": "^2.108.2",
    "d3": "^7.9.0",
    "react": "^19.2.6",
    "react-dom": "^19.2.6",
    "three": "^0.184.0"
  },
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@tailwindcss/postcss": "^4.3.1",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^6.0.1",
    "autoprefixer": "^10.5.0",
    "eslint": "^10.3.0",
    "eslint-plugin-react-hooks": "^7.1.1",
    "eslint-plugin-react-refresh": "^0.5.2",
    "globals": "^17.6.0",
    "jsdom": "^29.1.1",
    "postcss": "^8.5.15",
    "tailwindcss": "^4.3.1",
    "vite": "^8.0.12",
    "vitest": "^4.1.9"
  }
}

```

### node/server.js

```javascript
import express from 'express';
import cors from 'cors';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PORT, VOICE_AUDIO_DIR } from './lib/config.js';
import { mountVoiceRoutes } from './routes/voice.js';
import { mountScenarioRoutes } from './routes/scenario.js';
import { mountProfileRoutes } from './routes/profile.js';
import { getCatalog, initializeDatabase } from './lib/db/db.js';
import { setBaseDir } from './lib/voice/projectStore.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const clientDist = path.resolve(__dirname, '..', 'client', 'dist');

const app = express();
app.use(cors());
app.use(express.json({ limit: '200mb' }));

async function startServer() {
  process.on('unhandledRejection', (e) => console.error('UNHANDLED:', e));
  if (VOICE_AUDIO_DIR) setBaseDir(VOICE_AUDIO_DIR);

  await initializeDatabase();

  // Use a real HTTP server so the voice route can handle WebSocket upgrades.
  const httpServer = http.createServer(app);

  mountVoiceRoutes(app, httpServer);
  mountScenarioRoutes(app);
  mountProfileRoutes(app);

  app.get('/api/health', (_req, res) => res.json({ ok: true }));

  app.get('/api/catalog', async (req, res) => {
    try {
      res.json(await getCatalog());
    } catch (e) {
      res.status(500).json({ error: e.message });
    }
  });

  // In production the same service hosts the Vite build. Keeping the browser,
  // API, and WebSocket on one origin avoids proxy and CORS configuration.
  if (fs.existsSync(path.join(clientDist, 'index.html'))) {
    app.use(express.static(clientDist));
    app.get('/{*path}', (_req, res) => res.sendFile(path.join(clientDist, 'index.html')));
  }

  httpServer.listen(PORT, '0.0.0.0', () => {
    console.log(`Langtour running at http://0.0.0.0:${PORT}`);
  });
}

startServer();

```

### client/src/main.jsx

```javascript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

```

### client/src/App.jsx

```javascript
import { useState, useEffect } from 'react'
import LandingPage from './LandingPage'
import ScenariosPage from './ScenariosPage'
import VoiceTestPage from './pages/VoiceTestPage'
import ScenarioRunner from './components/ScenarioRunner'
import CharacterStoryPopup from './CharacterStoryPopup'
import CompletionScreen from './CompletionScreen'
import AuthModal from './components/AuthModal'
import { API } from './api'
import { useProfile } from './hooks/useProfile'
import { COUNTRIES as LOCAL_COUNTRIES } from './gameData'
import UserProfileOverlay from './components/profile/UserProfileOverlay'
import LoreCodex from './components/LoreCodex'
import { isFreshLangtourist, shouldShowArrivalStory } from './storyGate'
import { getMissionList } from './missionListApi'
import { shouldShowCompletion } from './missionListModel'

function App() {
  const profile = useProfile()
  const [selectedCountry, setSelectedCountry] = useState(null)
  const [activeScenario, setActiveScenario] = useState(null)
  const [hash, setHash] = useState(window.location.hash)
  const [storySeen, setStorySeen] = useState([])
  // In-memory only (never localStorage): the server has no "seen intro" slot,
  // so this session flag is the one UI-only exception the story-mode ticket
  // allows. First-launch detection itself still comes from server profile
  // data (zero unlocks/completions), not from this flag.
  const [introDismissed, setIntroDismissed] = useState(false)
  const [completionCountry, setCompletionCountry] = useState(null)
  const [glowCountry, setGlowCountry] = useState(null)
  const [catalog, setCatalog] = useState(null)
  const [catalogError, setCatalogError] = useState('')
  const [profileOpen, setProfileOpen] = useState(false)
  const [loreCodexOpen, setLoreCodexOpen] = useState(false)

  useEffect(() => {
    fetch(`${API}/api/catalog`)
      .then((res) => {
        if (!res.ok) throw new Error('Unable to load game catalog')
        return res.json()
      })
      .then(setCatalog)
      .catch((error) => setCatalogError(error.message))
  }, [])

  useEffect(() => {
    const onHashChange = () => setHash(window.location.hash);
    window.addEventListener('hashchange', onHashChange);
    return () => window.removeEventListener('hashchange', onHashChange);
  }, []);

  if (hash === '#test') {
    return <VoiceTestPage />
  }

  if (catalogError) {
    return <div className="flex h-screen items-center justify-center bg-[#0F1418] text-red-200">{catalogError}</div>
  }

  if (!catalog || profile.authLoading) {
    return <div className="flex h-screen items-center justify-center bg-[#0F1418] text-white">Loading Langtour…</div>
  }

  // A PASSWORD_RECOVERY session carries a signed-in `user`, but the person
  // is mid-reset, not ready for gameplay — keep them on AuthModal (in its
  // set-new-password view) until they've chosen a new password.
  if (!profile.user || profile.isPasswordRecovery) {
    return (
      <div className="flex h-screen items-center justify-center bg-[#0F1418] text-white">
        <AuthModal
          loading={profile.authLoading}
          error={profile.authError}
          message={profile.authMessage}
          onGoogle={profile.signInWithGoogle}
          onEmailSignIn={profile.signInWithEmail}
          onEmailSignUp={profile.signUpWithEmail}
          onForgotPassword={profile.resetPasswordForEmail}
          isPasswordRecovery={profile.isPasswordRecovery}
          onUpdatePassword={profile.updateUserPassword}
        />
      </div>
    )
  }

  const {
    characters,
    countries,
    rewardTokens,
    unlockCost,
  } = catalog

  const handleUnlockCountry = async (countryName, cost) => {
    const code = countries.find((c) => c.name === countryName)?.code
    if (!code) return false
    const result = await profile.unlockCountry(code, cost)
    if (!result) return false
    setGlowCountry(countryName)
    return true
  }

  const showIntro = isFreshLangtourist(profile) && !introDismissed && !selectedCountry && !activeScenario && !completionCountry

  if (showIntro) {
    return (
      <CharacterStoryPopup
        mode="intro"
        onBeginMission={() => setIntroDismissed(true)}
      />
    )
  }

  if (completionCountry) {
    const code = countries.find((c) => c.name === completionCountry)?.code ?? 'us'
    return (
      <CompletionScreen
        country={completionCountry}
        code={code}
        character={characters[completionCountry]}
        rewardTokens={rewardTokens}
        onReturn={async () => {
          setCompletionCountry(null);
          setSelectedCountry(null);
          await profile.claimCountryReward(code)
        }}
      />
    )
  }

  if (activeScenario) {
    const langCode = LOCAL_COUNTRIES.find((c) => c.name === selectedCountry)?.langCode || 'zh'
    return (
      <ScenarioRunner
        scenario={activeScenario}
        langCode={langCode}
        country={selectedCountry}
        isAdmin={profile.isAdmin}
        onEndScenario={async (result) => {
          // Completion is already recorded server-side (inside /api/scenario/evaluate
          // after an evaluator pass, or the admin skip); just refresh our view.
          if (result?.completed && result?.id && !profile.completedScenarios.includes(result.id) && selectedCountry) {
            await profile.reloadGameState()
          }
          setActiveScenario(null);

          // countryComplete is the server's own claim-reward gate mirrored into
          // the list response (docs/contracts/scenario-list.md) — the only
          // signal allowed to show CompletionScreen / trigger the claim call.
          if (selectedCountry) {
            const code = countries.find((c) => c.name === selectedCountry)?.code
            if (code) {
              try {
                const list = await getMissionList({ countryCode: code })
                if (shouldShowCompletion(list)) {
                  setCompletionCountry(selectedCountry);
                }
              } catch (err) {
         
[truncated — 2370 more characters]
```

### node/lib/ai/index.js

```javascript
// Public seam for the AI dialog/evaluation module — see docs/contracts/ai-module.md.
// The route layer (node/routes/scenario.js, owned by backend-graph) calls
// generateTurn/evaluateResponse and nothing else in here. This module never
// touches the DB or the economy; a pass verdict is the route's cue, not ours.
import { generateStructured as defaultGenerateStructured } from './model.js';
import { getPersona, PERSONAS } from './personas.js';
import { LANGUAGE_NAMES, languageName } from './languages.js';
import { buildTurnPrompt, turnSchema } from './prompts/generate_turn.js';
import {
  buildEvaluationPrompt,
  evaluationSchema,
  ERROR_KINDS,
} from './prompts/evaluate_response.js';
import { ModelQuotaError } from './errors.js';

// Strip punctuation/whitespace so "水!" still counts as the bare word 水.
function normalize(text) {
  return (text ?? '')
    .toLowerCase()
    .replace(/[\p{P}\p{S}]/gu, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

// Deterministic pre-check: a transcript that is exactly one target word (or a
// single token) can never be a meaningful reply. Failing it here keeps the
// rubric's floor unfakeable and saves a model call.
function detectBareWord(transcript, targetWords) {
  const norm = normalize(transcript);
  if (!norm) return true;
  if (targetWords.some((w) => normalize(w.expression) === norm)) return true;
  // For space-delimited scripts a single token is a bare word. Chinese writes
  // sentences without spaces, so the token heuristic must not apply there —
  // zh bare words are caught by the exact-target match above or the model.
  if (/\p{Script=Han}/u.test(norm)) return false;
  return !norm.includes(' ');
}

function mapUsedWords(usedExpressions, targetWords) {
  const byExpression = new Map(targetWords.map((w) => [normalize(w.expression), w.id]));
  const ids = [];
  for (const expr of usedExpressions ?? []) {
    const id = byExpression.get(normalize(expr));
    if (id != null && !ids.includes(id)) ids.push(id);
  }
  return ids;
}

function normalizeErrorKind(raw, pass) {
  if (pass) return null;
  return ERROR_KINDS.includes(raw) ? raw : 'too-vague';
}

// Factory so tests inject a stub model caller; production uses the default
// generateObject-backed one in model.js.
export function createAi({ generateStructured = defaultGenerateStructured } = {}) {
  async function generateTurn(ctx) {
    const persona = getPersona(ctx.personaId);
    const object = await generateStructured({
      schema: turnSchema,
      prompt: buildTurnPrompt(ctx, persona),
    });

    // Model-introduced words have no DB id yet (id: null); the route layer
    // resolves/creates ids before persisting. Never duplicate an existing word.
    const targetWords = [...ctx.targetWords];
    const grown = object.newWord;
    if (
      grown?.expression &&
      !targetWords.some((w) => normalize(w.expression) === normalize(grown.expression))
    ) {
      targetWords.push({ id: null, ...grown });
    }

    return {
      npcLine: {
        text: object.npcText,
        reading: object.npcReading,
        translation: object.npcTranslation,
      },
      sidekickLine: object.sidekickText?.trim() ? { text: object.sidekickText.trim() } : null,
      expectedIntent: object.expectedIntent,
      targetWords,
    };
  }

  async function evaluateResponse(ctx, transcript, pronScore = null) {
    const persona = getPersona(ctx.personaId);

    if (detectBareWord(transcript, ctx.targetWords)) {
      return {
        pass: false,
        errorKind: 'bare-word',
        teachingNote:
          'A single word on its own is not an answer — build a full sentence around it that responds to what was asked.',
        sidekickLine: { text: `${persona.voice.catchphrase} One word won't pass here — give them a whole sentence.` },
        usedWords: [],
      };
    }

    const object = await generateStructured({
      schema: evaluationSchema,
      prompt: buildEvaluationPrompt(ctx, transcript, pronScore, persona),
    });

    const pass = object.pass === true;
    return {
      pass,
      errorKind: normalizeErrorKind(object.errorKind, pass),
      teachingNote: object.teachingNote ?? '',
      sidekickLine: { text: object.sidekickText ?? '' },
      usedWords: pass ? mapUsedWords(object.usedExpressions, ctx.targetWords) : [],
    };
  }

  return { generateTurn, evaluateResponse };
}

const defaultAi = createAi();
export const generateTurn = defaultAi.generateTurn;
export const evaluateResponse = defaultAi.evaluateResponse;

export { PERSONAS, getPersona, LANGUAGE_NAMES, languageName, ERROR_KINDS, ModelQuotaError };

```

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