From 4c976382f3702ffeaf568f248a49c072b36cf318 Mon Sep 17 00:00:00 2001 From: sd0 Date: Wed, 4 Feb 2026 22:18:08 +0800 Subject: [PATCH] feat: Introduces bilingual i18n for Claude Code course - Establishes internationalization (i18n) support for English and Traditional Chinese (zh-TW) - Adds i18n foundation: strings.js with locale detection, translation helper, and template functions - Refactors UI/data flow to consume translated strings and localized content - Extends data models with zh-TW fields and updates core data (rooms/items/enemies/characters) with zh translations - Enables locale selection via URL query (?lang=zh-TW) and persists locale in progress data - Updates server/index flow to surface zh-TW option and logs - Introduces new zh-TW course content and accompanying lessons - Adds tests for i18n (detectLocale, t(), templates) and data integrity; includes server routing tests - Adds necessary dependencies and config (Anthropic SDK, dotenv, Vitest) and associated config/scripts - Improves READMEs and UI styling to support Chinese locale --- .claude/skills/course/SKILL.md | 46 +- .claude/skills/course/lessons.json | 79 +- CLAUDE.md | 46 +- README.md | 15 +- README.zh-TW.md | 144 +++ dungeon/course-progress.json | 3 +- dungeon/server.js | 4 +- learn-claude/zh-TW/00-intro.md | 35 + learn-claude/zh-TW/01-first-session.md | 32 + learn-claude/zh-TW/02-cli-navigation.md | 36 + learn-claude/zh-TW/03-context.md | 49 + learn-claude/zh-TW/04-modes.md | 31 + learn-claude/zh-TW/05-claude-md.md | 41 + learn-claude/zh-TW/06-writing-rules.md | 46 + learn-claude/zh-TW/07-prompting.md | 65 ++ learn-claude/zh-TW/08-creating-skills.md | 68 ++ learn-claude/zh-TW/09-claude-agents.md | 62 ++ learn-claude/zh-TW/10-application-agents.md | 73 ++ package.json | 15 + pnpm-lock.yaml | 1029 +++++++++++++++++++ reference/complete/data/characters.json | 12 +- reference/complete/data/enemies.json | 1 + reference/complete/data/items.json | 18 + reference/complete/data/rooms.json | 12 + reference/complete/game.js | 100 +- reference/complete/index.html | 1 + reference/complete/server.js | 4 +- reference/complete/strings.js | 255 +++++ reference/complete/style.css | 38 + reference/complete/ui/ui.js | 47 +- reference/starter/server.js | 3 +- tests/i18n.test.js | 271 +++++ tests/server.test.js | 88 ++ vitest.config.js | 7 + 34 files changed, 2684 insertions(+), 92 deletions(-) create mode 100644 README.zh-TW.md create mode 100644 learn-claude/zh-TW/00-intro.md create mode 100644 learn-claude/zh-TW/01-first-session.md create mode 100644 learn-claude/zh-TW/02-cli-navigation.md create mode 100644 learn-claude/zh-TW/03-context.md create mode 100644 learn-claude/zh-TW/04-modes.md create mode 100644 learn-claude/zh-TW/05-claude-md.md create mode 100644 learn-claude/zh-TW/06-writing-rules.md create mode 100644 learn-claude/zh-TW/07-prompting.md create mode 100644 learn-claude/zh-TW/08-creating-skills.md create mode 100644 learn-claude/zh-TW/09-claude-agents.md create mode 100644 learn-claude/zh-TW/10-application-agents.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 reference/complete/strings.js create mode 100644 tests/i18n.test.js create mode 100644 tests/server.test.js create mode 100644 vitest.config.js diff --git a/.claude/skills/course/SKILL.md b/.claude/skills/course/SKILL.md index 9914642..df17a2b 100644 --- a/.claude/skills/course/SKILL.md +++ b/.claude/skills/course/SKILL.md @@ -1,7 +1,7 @@ --- name: course description: Interactive Claude Code learning course with progress tracking -argument-hint: "[lesson-number | next | progress | reset | exit | update | complete]" +argument-hint: "[lesson-number | next | progress | reset | exit | update | complete | lang en|zh-TW]" --- # Dungeons & Agents - Interactive Course @@ -95,6 +95,47 @@ Be specific in instructions: "Update the Take Button in the Actions Section" not - `exit` → Save position and exit the course - `update` → Check for and apply updates from GitHub - `complete` → Mark course as complete and show graduation message +- `lang [en|zh-TW]` → Set course language + +--- + +## Language / Locale + +The course supports English (`en`) and Traditional Chinese (`zh-TW`). + +### Detecting locale + +Read the `locale` field from `dungeon/course-progress.json`. If the field is missing, default to `en`. + +### Setting locale + +When $ARGUMENTS starts with `lang`: + +1. Parse the language code (e.g., `lang zh-TW` or `lang en`) +2. Update `dungeon/course-progress.json` to include `"locale": "zh-TW"` (or `"en"`) +3. Display confirmation: + +``` +╭──────────────────────────────────────────────╮ +│ Language set to: 繁體中文 (zh-TW) │ +│ │ +│ Run /course to continue learning │ +╰──────────────────────────────────────────────╯ +``` + +### Using locale + +When locale is `zh-TW`: + +- Read lesson files from `learn-claude/zh-TW/XX-*.md` instead of `learn-claude/XX-*.md` (use the `file_zh` field from lessons.json) +- Use `title_zh` and `name_zh` from lessons.json for dashboard display +- The tutor should converse in Traditional Chinese +- Keep code blocks, CLI commands, file paths, and slash commands in English + +When locale is `en` (default): + +- Use `file` and `title` fields from lessons.json as before +- The tutor converses in English --- @@ -107,7 +148,8 @@ When $ARGUMENTS is empty, show the course dashboard: { "completed": [], "current": null, - "graduated": false + "graduated": false, + "locale": "en" } ``` 2. Read lesson list from `skills/course/lessons.json` diff --git a/.claude/skills/course/lessons.json b/.claude/skills/course/lessons.json index 24dc1cc..0b6fe7b 100644 --- a/.claude/skills/course/lessons.json +++ b/.claude/skills/course/lessons.json @@ -1,48 +1,105 @@ { "title": "Learn Claude Code", + "title_zh": "學習 Claude Code", "parts": [ { "name": "Introduction", - "lessons": [{ "id": "00", "title": "Welcome", "file": "00-intro.md" }] + "name_zh": "簡介", + "lessons": [ + { + "id": "00", + "title": "Welcome", + "title_zh": "歡迎", + "file": "00-intro.md", + "file_zh": "zh-TW/00-intro.md" + } + ] }, { "name": "Getting Started", + "name_zh": "入門", "lessons": [ { "id": "01", "title": "Your First Session", - "file": "01-first-session.md" + "title_zh": "你的第一次 Session", + "file": "01-first-session.md", + "file_zh": "zh-TW/01-first-session.md" }, { "id": "02", "title": "CLI Navigation", - "file": "02-cli-navigation.md" + "title_zh": "CLI 導航", + "file": "02-cli-navigation.md", + "file_zh": "zh-TW/02-cli-navigation.md" }, - { "id": "03", "title": "Managing Context", "file": "03-context.md" }, - { "id": "04", "title": "Modes", "file": "04-modes.md" } + { + "id": "03", + "title": "Managing Context", + "title_zh": "管理上下文", + "file": "03-context.md", + "file_zh": "zh-TW/03-context.md" + }, + { + "id": "04", + "title": "Modes", + "title_zh": "模式", + "file": "04-modes.md", + "file_zh": "zh-TW/04-modes.md" + } ] }, { "name": "Project Context", + "name_zh": "專案上下文", "lessons": [ - { "id": "05", "title": "CLAUDE.md", "file": "05-claude-md.md" }, - { "id": "06", "title": "Writing Rules", "file": "06-writing-rules.md" }, - { "id": "07", "title": "Prompting", "file": "07-prompting.md" }, + { + "id": "05", + "title": "CLAUDE.md", + "title_zh": "CLAUDE.md", + "file": "05-claude-md.md", + "file_zh": "zh-TW/05-claude-md.md" + }, + { + "id": "06", + "title": "Writing Rules", + "title_zh": "撰寫規則", + "file": "06-writing-rules.md", + "file_zh": "zh-TW/06-writing-rules.md" + }, + { + "id": "07", + "title": "Prompting", + "title_zh": "提示技巧", + "file": "07-prompting.md", + "file_zh": "zh-TW/07-prompting.md" + }, { "id": "08", "title": "Creating Skills", - "file": "08-creating-skills.md" + "title_zh": "建立技能", + "file": "08-creating-skills.md", + "file_zh": "zh-TW/08-creating-skills.md" } ] }, { "name": "Agents", + "name_zh": "代理", "lessons": [ - { "id": "09", "title": "Subagents", "file": "09-claude-agents.md" }, + { + "id": "09", + "title": "Subagents", + "title_zh": "子代理", + "file": "09-claude-agents.md", + "file_zh": "zh-TW/09-claude-agents.md" + }, { "id": "10", "title": "Application Agents", - "file": "10-application-agents.md" + "title_zh": "應用程式代理", + "file": "10-application-agents.md", + "file_zh": "zh-TW/10-application-agents.md" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 7837006..57a52d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,12 +54,54 @@ When adding or modifying enemies: - When implementing features, check reference/complete/game.js, reference/complete/ui/ui.js, and reference/complete/data/ for reference - Use these as guardrails to ensure your implementations match the intended patterns and structure +## Internationalization (i18n) + +The game supports English (`en`) and Traditional Chinese (`zh-TW`). Locale is detected via URL query parameter `?lang=zh-TW`. + +### Architecture + +- **`strings.js`**: Core i18n infrastructure — `detectLocale()`, `t()` helper, `S` string map, `T` template functions, `localizeHTML()` +- **Data JSON files**: Use inline `_zh` suffix fields (e.g., `name_zh`, `description_zh`) — not separate overlay files +- **Items**: Use `icon` field for icon lookup instead of English name matching + +### Translation helper `t()` + +Dual-signature function: +- `t(localeMap)` — for system strings: `t(S.welcome)` returns the localized string from `{ en: "...", "zh-TW": "..." }` +- `t(obj, field)` — for data objects: `t(room, "name")` returns `room.name_zh` (zh-TW) or `room.name` (en) + +### Template functions `T.*` + +Locale-aware string formatters for sentences with parameters: +- `T.youGo(dir)`, `T.youPickUp(item)`, `T.youAttackFor(name, dmg)`, `T.enemyDefeated(name)`, etc. + +### When adding translatable content + +1. Add `_zh` fields to data JSON files alongside English fields +2. Add new system strings to the `S` object in `strings.js` with both `en` and `zh-TW` +3. Use `t()` or `T.*` in game.js/ui.js — never hardcode English strings +4. Add items with an `icon` field for icon lookup + +### Course locale + +- Course language is stored in `dungeon/course-progress.json` as `"locale": "en"` or `"locale": "zh-TW"` +- Lesson translations are in `learn-claude/zh-TW/` +- Set via `/course lang zh-TW` + +### Testing + +Run `npm test` to verify i18n infrastructure: +- `t()` function behavior for both locales +- `T.*` template functions output +- Data file integrity (all `_zh` fields present) +- `S` string completeness + ## General Principle When you add any game artifact (character, room, item, enemy), update: -1. The data file (JSON) -2. The UI elements that display/interact with it +1. The data file (JSON) — include `_zh` fields for zh-TW translations +2. The UI elements that display/interact with it — use `t()` for all user-facing strings 3. Any visual assets (pixel art) if applicable --- diff --git a/README.md b/README.md index 108aadc..e5e4154 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +English | [繁體中文](README.zh-TW.md) + # Learn Claude Code An interactive course for learning Claude Code in Claude Code. @@ -49,10 +51,17 @@ Each lesson teaches a Claude Code concept, then has you apply it to the game. By Start the game server: ```bash -node dungeon/server.js +pnpm start ``` -Then open http://localhost:3000 in your browser. +- English: http://localhost:3000 +- Traditional Chinese: http://localhost:3000?lang=zh-TW + +When building step-by-step during the course, use the student workspace: + +```bash +pnpm run dev +``` --- @@ -114,6 +123,8 @@ learn-claude-code/ | `/course exit` | Pause and save position | | `/course reset` | Start over | | `/course update` | Update to latest version | +| `/course lang zh-TW` | Switch to Traditional Chinese | +| `/course lang en` | Switch to English | --- diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000..d827204 --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,144 @@ +[English](README.md) | 繁體中文 + +# 學習 Claude Code + +一門在 Claude Code 中學習 Claude Code 的互動式課程。 + +Claude 以對話方式教學,逐步引導你學習概念並完成實作練習。 + +## 快速開始 + +1. 首先,安裝 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)(如果尚未安裝): + +```bash +npm install -g @anthropic-ai/claude-code +``` + +2. 複製此儲存庫: + +```bash +git clone https://github.com/delbaoliveira/learn-claude-code +cd learn-claude-code +``` + +3. 啟動 Claude 對話: + +```bash +claude +``` + +4. 輸入 `/course lang zh-TW` 設定為繁體中文,然後輸入 `/course` 開始課程。 + +--- + +## 你將打造的專案 + +在整個課程中,你將打造 **Dungeons & Agents** —— 一款在瀏覽器中執行的文字冒險遊戲。 + +``` +╔═══════════════════════════════════════════╗ +║ DUNGEONS & AGENTS ║ +╚═══════════════════════════════════════════╝ + +你站在一個黑暗洞穴的入口。 +一陣冷風從深處吹來。 + +> go north +``` + +每堂課教授一個 Claude Code 概念,然後讓你將其應用到遊戲中。到第 10 課結束時,你將擁有一個完整的遊戲,包含房間、物品、戰鬥系統,以及對 Claude Code 的扎實理解。 + +啟動遊戲伺服器: + +```bash +pnpm start +``` + +- 英文版:http://localhost:3000 +- 繁體中文版:http://localhost:3000?lang=zh-TW + +課程中逐步建構時使用學生工作區: + +```bash +pnpm run dev +``` + +--- + +## 課程結構 + +**簡介** + +0. 歡迎 + +**第一部分:入門** + +1. 你的第一次 Session +2. CLI 導航 +3. 管理上下文 +4. 模式 + +**第二部分:專案上下文** + +5. CLAUDE.md +6. 撰寫規則 +7. 提示技巧 +8. 建立技能 + +**第三部分:代理** + +9. 子代理 +10. 應用程式代理 + +--- + +## 目錄結構 + +``` +learn-claude-code/ +├── learn-claude/ # 11 堂課程 +│ └── zh-TW/ # 繁體中文版課程 +├── dungeon/ # 你的工作區 +│ ├── server.js # 遊戲伺服器 +│ ├── index.html # 終端風格 UI +│ ├── game.js # 遊戲引擎(由你來打造!) +│ ├── data/ # 遊戲資料(房間、物品、敵人) +│ └── course-progress.json # 你的學習進度 +├── reference/ +│ ├── starter/ # 初始狀態(用於 /course reset) +│ └── complete/ # 完整參考實作 +├── .claude/ +│ └── skills/ +│ ├── course/ # 互動式課程執行器 +│ └── dungeon/ # 遊戲建構技能(第 09 課) +└── README.md +``` + +## 課程指令 + +| 指令 | 說明 | +| ------------------ | -------------------------- | +| `/course` | 顯示進度儀表板 | +| `/course next` | 繼續下一堂課 | +| `/course progress` | 查看詳細統計 | +| `/course exit` | 暫停並儲存進度 | +| `/course reset` | 重新開始 | +| `/course update` | 更新至最新版本 | +| `/course lang zh-TW` | 設定為繁體中文 | +| `/course lang en` | 設定為英文 | + +--- + +## 給貢獻者 + +### 根據官方文件審查課程 + +``` +/review-lesson 03 +``` + +### 產生新課程 + +``` +/lesson "topic name" +``` diff --git a/dungeon/course-progress.json b/dungeon/course-progress.json index d65a430..47f4f2c 100644 --- a/dungeon/course-progress.json +++ b/dungeon/course-progress.json @@ -1,5 +1,6 @@ { "completed": ["00", "01"], "current": "02", - "graduated": false + "graduated": false, + "locale": "zh-TW" } diff --git a/dungeon/server.js b/dungeon/server.js index 0485ab0..350999c 100644 --- a/dungeon/server.js +++ b/dungeon/server.js @@ -28,7 +28,8 @@ const server = http.createServer(async (req, res) => { return } - let filePath = req.url === "/" ? "/index.html" : req.url + const urlPath = req.url.split("?")[0] + let filePath = urlPath === "/" ? "/index.html" : urlPath filePath = path.join(__dirname, filePath) const ext = path.extname(filePath) @@ -56,5 +57,6 @@ fs.watch(__dirname, { recursive: true }, (event, filename) => { server.listen(PORT, () => { console.log(`\n Dungeons & Agents running at http://localhost:${PORT}`) + console.log(` 繁體中文版 http://localhost:${PORT}?lang=zh-TW`) console.log(` Live reload enabled\n`) }) diff --git a/learn-claude/zh-TW/00-intro.md b/learn-claude/zh-TW/00-intro.md new file mode 100644 index 0000000..7fe5655 --- /dev/null +++ b/learn-claude/zh-TW/00-intro.md @@ -0,0 +1,35 @@ +═══════════════════════════════════════════════════════════════════ +第 00 課:歡迎 +═══════════════════════════════════════════════════════════════════ + +嗨,我是 Claude,我將會是你在這門課程中的導覽員。 + +我是一個 AI 模型,能夠閱讀你的程式碼、回答問題、撰寫測試、修復錯誤,並幫助你更快地建構專案。這門課程將教你如何充分發揮它的能力。 + +## 課程指令 + +| 指令 | 功能說明 | +| ------------------ | ---------------------------- | +| `/course` | 顯示課程儀表板 | +| `/course next` | 繼續前往下一課 | +| `/course 05` | 跳到指定課程 | +| `/course progress` | 查看詳細進度 | +| `/course exit` | 暫停並儲存目前進度 | +| `/course update` | 從 GitHub 拉取最新版本 | + +## 在我們繼續之前 + +當我對你的程式碼進行修改時,你會看到差異比對: + +```diff +- old code (being removed) ++ new code (being added) +``` + +你可以選擇**接受**或**拒絕**每一項變更。 + +--- + +## 試試看 + +當你準備好了,輸入 "next" 來儲存進度並繼續前往第一課。 diff --git a/learn-claude/zh-TW/01-first-session.md b/learn-claude/zh-TW/01-first-session.md new file mode 100644 index 0000000..35d4897 --- /dev/null +++ b/learn-claude/zh-TW/01-first-session.md @@ -0,0 +1,32 @@ +═══════════════════════════════════════════════════════════════════ +第 01 課:你的第一次 Session +═══════════════════════════════════════════════════════════════════ + +Session(工作階段)是一段互動式對話,在這段對話中 Claude 可以查看你的專案並對程式碼進行修改。 + +Claude 會自動讀取你的專案結構。 + +> **提示**:你可以使用 `/exit` 或 `Ctrl+C` 退出目前的 Session,並使用 `claude --continue` 繼續最近的 Session。 + +## 試試看 + +讓我們對 Dungeons & Agents 進行你的第一次修改。 + +1. 請 Claude 新增第一批指令: + + > Look at the dungeon game in dungeon/. Add a basic command system to game.js: + > + > - 'help' shows available commands + > - 'look' says "You are in a dark cave. Exits: up" + > - Unknown commands show "I don't understand that" + > - Enable the "Look" and "Take" buttons in the UI. + +2. 接受 Claude 的變更。 + +3. 在**另一個終端機**中啟動遊戲伺服器: + + ```bash + node dungeon/server.js + ``` + +4. 開啟 http://localhost:3000 並試試新的 help 指令。 diff --git a/learn-claude/zh-TW/02-cli-navigation.md b/learn-claude/zh-TW/02-cli-navigation.md new file mode 100644 index 0000000..681e635 --- /dev/null +++ b/learn-claude/zh-TW/02-cli-navigation.md @@ -0,0 +1,36 @@ +═══════════════════════════════════════════════════════════════════ +第 02 課:CLI 導覽 +═══════════════════════════════════════════════════════════════════ + +學會幾個 CLI 導覽快捷鍵會讓你的操作速度大幅提升。 + +## 鍵盤快捷鍵 + +| 快捷鍵 | 動作 | +| -------------- | -------------------------------- | +| `Enter` | 送出訊息 | +| `Option+Enter` | 換行(用於多行輸入) | +| `Escape` | 取消目前動作 / 清除輸入 | +| `Ctrl+C` | 中斷 Claude,然後退出 Session | +| `Ctrl+L` | 清除螢幕 | +| `Up/Down` | 瀏覽訊息歷史紀錄 | + +## 斜線指令 + +| 指令 | 功能說明 | +| ---------- | ------------------------------------- | +| `/help` | 顯示所有指令 | +| `/clear` | 清除對話歷史紀錄 | +| `/compact` | 摘要對話內容以節省上下文空間 | +| `/config` | 開啟設定 | +| `/cost` | 顯示 token 使用量與費用 | +| `/context` | 顯示上下文視窗使用狀況 | +| `/model` | 切換模型 | +| `/quit` | 退出 Claude Code | + +## 試試看 + +1. 輸入 `/help` 查看所有可用指令。 +2. 執行 `/model` 並在接下來的課程中切換到 **Haiku**。 + +> **提示**:Haiku 速度更快、費用更低。它非常適合學習練習和簡單直接的任務。當你需要更深度的推理時,請使用 Sonnet 或 Opus。 diff --git a/learn-claude/zh-TW/03-context.md b/learn-claude/zh-TW/03-context.md new file mode 100644 index 0000000..5ce7fc4 --- /dev/null +++ b/learn-claude/zh-TW/03-context.md @@ -0,0 +1,49 @@ +═══════════════════════════════════════════════════════════════════ +第 03 課:管理上下文 +═══════════════════════════════════════════════════════════════════ + +## 什麼是上下文? + +上下文是 Claude 在對話中能「看見」的所有內容——你的訊息、Claude 的回覆、讀取過的檔案、撰寫過的程式碼。這些全都存在於一個**上下文視窗**中,而它的大小是有限制的。 + +你可以把它想像成一塊白板。隨著你的工作,白板會逐漸被填滿。當它滿了,較早的內容會被清除以騰出空間給新內容。如果重要的細節被清除了,Claude 可能會遺忘你們先前討論過的事情。 + +## 為什麼這很重要 + +你正在進行一段深入的除錯工作。你已經分享了堆疊追蹤、解釋了架構、逐一檢視了檔案。然後 Claude 漏掉了你先前問過的某件事。 + +發生了什麼事?對話變得太長,早期的上下文被擠出去了。以下是管理上下文的方法。 + +## 關鍵指令 + +**`/context`** — 顯示你已使用了多少上下文視窗空間。 + +**`/compact`** — 將對話摘要成重點,釋放空間的同時保留重要的上下文。 + +**`/clear`** — 清除對話歷史紀錄並釋放上下文空間。 + +## 試試看 + +是時候為 Dungeons & Agents 新增房間了。 + +1. 執行 `/context` 查看你目前使用了多少上下文視窗空間。 + +2. 請 Claude 擴展房間系統: + + > Expand the dungeon game with more rooms. The cave-entrance already exists in data/rooms.json. Add 4 more connected rooms: + > + > - "narrow-tunnel" (up from entrance) — cramped passage, exits: down, right, up, left + > - "treasure-room" (right of tunnel) — glittering chamber with gold coins, exits: left + > - "underground-lake" (left of tunnel) — vast cavern with dark water, exits: right, left + > - "secret-garden" (up from tunnel) — impossible flowers blooming underground, exits: down + > + > Update cave-entrance to have an exit up to narrow-tunnel. + > Update game.js so 'look' shows the current room description and exits, 'go [direction]' or arrow keys (ArrowUp/Down/Left/Right) to move between rooms. + > Update the mapLayout grid in ui/ui.js to show all rooms in their correct positions. + > Make sure the map and room positions match. + +3. 測試遊戲:用 `go up`、方向鍵、`look` 來探索。 + +4. 再次執行 `/context`——注意使用量增加了。 + +隨著你新增更多功能:對話變長時使用 `/compact`,需要重新開始時使用 `/clear`。 diff --git a/learn-claude/zh-TW/04-modes.md b/learn-claude/zh-TW/04-modes.md new file mode 100644 index 0000000..c5e9e4e --- /dev/null +++ b/learn-claude/zh-TW/04-modes.md @@ -0,0 +1,31 @@ +═══════════════════════════════════════════════════════════════════ +第 04 課:模式 +═══════════════════════════════════════════════════════════════════ + +你請 Claude 為地下城遊戲新增一個戰鬥系統。它立刻開始撰寫程式碼——但等等,戰鬥應該是回合制還是即時制?傷害該如何計算?生命值要存在哪裡? + +對於複雜的任務,你希望 Claude 先_思考再寫程式碼_。這就是規劃模式的用途。 + +Claude Code 有三種模式,適用於不同的情境: + +- **一般模式(預設)** — Claude 提出變更建議,你逐一核准。最適合學習、檢視不熟悉的程式碼,或進行敏感的修改。 +- **自動接受模式** — Claude 不需詢問即可進行檔案編輯。Shell 指令仍需你核准。最適合值得信賴的重構和批次操作。 +- **規劃模式** — Claude 在撰寫程式碼前先進行研究和規劃。它會探索程式碼庫、提出方案,待你核准後再開始實作。最適合複雜功能和架構層級的變更。 + +## 試試看 + +1. 按 `Shift+Tab` 直到你在輸入欄位下方看到 "plan" 模式指示器。 + +2. 請 Claude 規劃物品欄系統: + + > Plan an inventory system for the dungeon game. Players can pick up items with 'take [item]', view inventory with 'inventory'. Items are stored in data/items.json. + > + > - Create pixel art for the items in the inventory. + > - Make sure the Inventory List (in Inventory Section) and Take Button (in Actions Section) are working correctly. The Take Button should be enabled when items are present in the current room. + > - Add a box at the top of the dialogue to display the items available in the current room. + +3. 檢閱 Claude 的規劃並核准。 + +> 提示:選擇選項 2 以保留課程的上下文。 + +4. 測試:`look` 應該會顯示物品,`take sword` 可以撿起物品,`inventory` 會列出你攜帶的物品。 diff --git a/learn-claude/zh-TW/05-claude-md.md b/learn-claude/zh-TW/05-claude-md.md new file mode 100644 index 0000000..f81e5c1 --- /dev/null +++ b/learn-claude/zh-TW/05-claude-md.md @@ -0,0 +1,41 @@ +═══════════════════════════════════════════════════════════════════ +第 05 課:CLAUDE.md +═══════════════════════════════════════════════════════════════════ + +每次你請 Claude 新增一個房間,你都要解釋 JSON 格式。每次你想要一個物品,你都要描述資料存放的位置。如果 Claude 能自己記住呢? + +**CLAUDE.md** 是一個 Claude 在 Session 開始時會自動讀取的檔案。將它加到你的專案根目錄,就能為 Claude 提供關於你專案的持久上下文。 + +## 範例: + +```markdown +# My Project + +## Overview + +React app with TypeScript and Tailwind. + +## Conventions + +- Functional components with hooks +- Use named exports, not default exports +- Don't modify files in /legacy +``` + +## 為什麼這很重要 + +沒有 CLAUDE.md,你會不斷重複自己。有了它: + +- Claude 了解你的專案結構 +- Claude 自動遵循你的慣例 +- 新團隊成員可以立即讓 Claude 上手 + +## 試試看 + +1. 為地下城遊戲建立一個 CLAUDE.md: + + > Create a CLAUDE.md in dungeon/ documenting the game structure, how rooms.json works, and that room descriptions should be 2-3 atmospheric sentences. Also document that whenever rooms are added or modified, the mapLayout grid in ui/ui.js must be updated to keep the mini map synchronized with actual room connections. + +2. 測試看看:問 "What's the format for adding a new room?"——Claude 應該不需要你解釋就能回答。 + +現在你已經有了 CLAUDE.md,下一課將教你如何撰寫 Claude 確實會遵循的規則。 diff --git a/learn-claude/zh-TW/06-writing-rules.md b/learn-claude/zh-TW/06-writing-rules.md new file mode 100644 index 0000000..9962003 --- /dev/null +++ b/learn-claude/zh-TW/06-writing-rules.md @@ -0,0 +1,46 @@ +═══════════════════════════════════════════════════════════════════ +課程 06:撰寫規則 +═══════════════════════════════════════════════════════════════════ + +**規則**是 Claude 在處理你的專案時所遵循的約束條件。以遊戲為例,這代表你可以定義遊戲規則——武器傷害範圍、敵人數值、房間需求——而 Claude 每次新增內容時都會遵守這些規則。 + +## 規則的存放位置 + +在你的 `CLAUDE.md` 檔案中: + +```markdown +## Rules + +- Always use pnpm, never npm +- Don't modify files in /legacy +- Run tests before committing +``` + +## 規則類型 + +**風格:**"Use named exports, not default exports" + +**邊界:**"Don't modify anything in /packages/core" + +**流程:**"Run `pnpm test` after modifying test files" + +**安全:**"Never commit API keys or secrets" + +## 撰寫好的規則 + +| 模糊 | 具體 | +| ----------------- | ------------------------------------------ | +| "Write good code" | "Add JSDoc comments to exported functions" | +| "Be careful" | "Ask before deleting files" | + +## 動手試試 + +1. 請 Claude 新增規則: + + > Add a Rules section to dungeon/CLAUDE.md with these game rules: weapon damage 1-10, enemy HP 5-30, room descriptions 2-3 atmospheric sentences, every room must have at least one exit, no one-way doors. + +2. 試著打破規則: + + > Add a legendary sword that does 50 damage + +3. Claude 應該會拒絕或調整以遵守你的規則。 diff --git a/learn-claude/zh-TW/07-prompting.md b/learn-claude/zh-TW/07-prompting.md new file mode 100644 index 0000000..40ebe70 --- /dev/null +++ b/learn-claude/zh-TW/07-prompting.md @@ -0,0 +1,65 @@ +═══════════════════════════════════════════════════════════════════ +課程 07:提示技巧 +═══════════════════════════════════════════════════════════════════ + +你請 Claude「加入戰鬥系統」,結果它建了一套精密的系統,包含類別、事件、骰子判定和狀態效果——但你只是想讓玩家打一隻哥布林而已。 + +令人沮喪的 Session 和高效率的 Session 之間的差別,就在於你如何下提示。 + +## 好提示的結構 + +**範圍** — 要修改哪些檔案或區域 + +> "In game.js, add an attack command..." +> "Store enemies in data/enemies.json" + +**背景** — 目前已經有什麼? + +> "The game already has rooms, items, and a take command..." + +**約束** — 遵循 CLAUDE.md 中的規則 + +> "Enemy HP should be 5-30 per the game rules" +> "Keep it simple — no status effects or special abilities" + +**驗收標準** — 「完成」是什麼樣子? + +> "Player types 'attack', sees damage dealt, goblin dies or player dies" + +## 提示模式 + +**新增內容:** + +> Add a [room/item/enemy] called [name] in [location]. It should [description]. + +**新增功能:** + +> Add [command] to game.js. It should [behavior]. Store data in [file]. + +**修正錯誤:** + +> When I [action], expected [X] but got [Y]. The problem is in [file]. + +**說明:** + +> How does the [system] work? Walk me through [specific flow]. + +## 動手試試 + +**模糊的提示:** + +> Add combat to the game + +**在計畫模式中使用詳細的提示:** + +> Add combat to the dungeon game in game.js: +> +> - Enemies stored in data/enemies.json (id, name, hp, damage, room) +> - Player has hp (starts at 100) +> - 'attack' command starts turn-based combat with enemy in current room +> - Each turn: player deals 5 base damage plus weapon damage, enemy deals its damage +> - At 0 hp: enemy defeated (remove from room) or player dies (game over) +> - Add a goblin (30 hp, 3 damage) in narrow-tunnel +> - Create a Portrait Display box on top of the items box that displays the NPCs (enemies and characters) name, portrait, and description. The box should display on room entry if the NPC is present. + +測試:前往隧道,輸入 `attack`,和哥布林戰鬥。 diff --git a/learn-claude/zh-TW/08-creating-skills.md b/learn-claude/zh-TW/08-creating-skills.md new file mode 100644 index 0000000..9aadb75 --- /dev/null +++ b/learn-claude/zh-TW/08-creating-skills.md @@ -0,0 +1,68 @@ +═══════════════════════════════════════════════════════════════════ +課程 08:建立技能 +═══════════════════════════════════════════════════════════════════ + +每次你在地牢遊戲中新增角色時,都要輸入一樣的指令:詢問個性、知識、位置、招呼語,存到正確的檔案。技能讓你只需要寫一次這些指令。Claude 會在相關時自動套用——或者你也可以用 `/skill-name` 手動呼叫。 + +## 為什麼要使用技能 + +沒有技能的話,你得重複自己: + +> "Add a wizard. Ask me for personality, knowledge, location, greeting. Save to data/characters.json." + +有了技能,你只需要說: + +> "Add a wizard to the treasure room." + +Claude 會辨識出這個任務符合你的 `add-character` 技能,載入它,然後照著你的指令執行。 + +## 技能的結構 + +**前置資料** — 在 `---` 行之間的中繼資料: + +- `name` — 技能名稱(例如 `add-character`) +- `description` — 告訴 Claude 何時自動使用這個技能 +- `argument-hint` — 手動呼叫時的佔位文字 + +**主體** — Claude 遵循的提示。使用 `$ARGUMENTS` 代入使用者的輸入。 + +```markdown +--- +name: add-character +description: Add an NPC to the dungeon game +argument-hint: "" +--- + +Create a new NPC named $ARGUMENTS for the dungeon game. + +Ask the user for: + +- Personality (2-3 traits) +- Knowledge (what do they know about the dungeon?) +- Location (which room are they in?) +- Greeting (what they say when you first talk to them) + +Save to dungeon/data/characters.json. +``` + +## 技能的存放位置 + +- `.claude/skills/` — 專案層級 +- `~/.claude/skills/` — 全域(所有專案) + +## 動手試試 + +1. 請 Claude 建立技能: + + > Create a Claude Code skill at .claude/skills/add-character/SKILL.md that adds NPCs to the dungeon game. It should ask for personality, knowledge, location, and greeting, then save to data/characters.json. + > + > - It should also create a pixel art for the character in ui/portraits.js. + > - The character's name, portrait, and description should be displayed in the Portrait Display box on room entry if the character is present. + +2. 測試自動呼叫——只需描述你想要的: + + > Add a wizard character to the secret garden. They should be wise and guide the player to the treasure room. + +3. 在繼續之前,讓我們啟用對話按鈕,這樣就能和角色互動: + + > Enable the Talk Button in the Actions Section when a character is in the room. diff --git a/learn-claude/zh-TW/09-claude-agents.md b/learn-claude/zh-TW/09-claude-agents.md new file mode 100644 index 0000000..52cd5b0 --- /dev/null +++ b/learn-claude/zh-TW/09-claude-agents.md @@ -0,0 +1,62 @@ +═══════════════════════════════════════════════════════════════════ +課程 09:子代理 +═══════════════════════════════════════════════════════════════════ + +**子代理**是在自己的上下文視窗中運行的專門助手。Claude 會為複雜任務產生子代理——它們獨立工作,然後回報結果。 + +例如,你可以建立用於除錯、設計、實作功能等的代理。 + +為什麼要使用子代理? + +- **保留上下文** — 將探索工作隔離在主要對話之外 +- **強制約束** — 限制子代理可以使用的工具 +- **控制成本** — 將任務導向更快、更便宜的模型,如 Haiku + +## 內建子代理 + +| Agent | 用途 | 模型 | +| ------------------- | -------------------------- | ------------ | +| **Explore** | 搜尋和理解程式碼 | Haiku(快速)| +| **Plan** | 規劃前的研究 | 繼承 | +| **General-purpose** | 複雜的多步驟任務 | 繼承 | + +## 建立自訂子代理 + +執行 `/agents` 來開啟互動式子代理管理器。 + +**子代理欄位:** + +- `name` — 識別名稱(小寫,連字號) +- `description` — 告訴 Claude 何時使用這個子代理 +- `tools` — 子代理可以使用的工具(選填) +- `model` — `sonnet`、`opus`、`haiku` 或 `inherit`(選填) + +## 背景代理 + +在你繼續工作的同時,讓子代理在背景執行: + +- **`Ctrl+B`** — 將正在執行的任務移到背景 +- **"run this in the background"** — 直接請 Claude 執行 +- **`/tasks`** — 檢查背景工作進度 + +## 動手試試 + +1. 執行 `/agents` + +2. 選擇 **Create new agent** → **Project-level** + +3. 選擇 **Generate with Claude** 並描述代理: + + ``` + A character creator that makes NPCs for a dungeon game. It should read /skills/add-character/skill.md to understand the character creation process, read the rooms to understand the world, design characters with personality traits, and save them to data/characters.json. + ``` + +4. 保留預設的工具和模型,選擇一個顏色,然後儲存。 + +5. 在背景中測試: + + > Create two new characters: a Dwarf NPC for the treasure-room and a Elf for the underground-lake. Run this in the background. + +6. 在它執行時,檢查 `/tasks` 來查看背景工作。 + +7. 測試:`talk dwarf` 應該會顯示矮人的招呼語。 diff --git a/learn-claude/zh-TW/10-application-agents.md b/learn-claude/zh-TW/10-application-agents.md new file mode 100644 index 0000000..a761988 --- /dev/null +++ b/learn-claude/zh-TW/10-application-agents.md @@ -0,0 +1,73 @@ +═══════════════════════════════════════════════════════════════════ +課程 10:應用程式代理 +═══════════════════════════════════════════════════════════════════ + +你使用了 Claude 子代理來建立新的遊戲角色。角色已經存在了,但它們的回應是靜態的。 + +現在讓我們用 AI 讓它們能動態回應使用者。 + +這堂課你需要有一個 Anthropic API 金鑰,需要至少 5 美元的額度。這堂課的提示也會安裝 Anthropic SDK。如果你想跳過,只需輸入 `/course complete`。 + +## 差異 + +- **Claude 子代理** — 幫助你撰寫程式碼 +- **應用程式代理** — 在應用程式中執行,與玩家互動。 + +## 安全提醒 + +請妥善保管你的 API 金鑰。不應該提交到版本控制、暴露給瀏覽器,或分享給任何人。 + +## 動手試試 + +1. 請 Claude 建立 `.env` 檔案: + + > Create a `.env` file in the dungeon directory with my API key. The file should contain: + > + > ``` + > ANTHROPIC_API_KEY=sk-ant-xxxxx + > ``` + > + > Add the .env file to .gitignore. + +2. 從 https://console.anthropic.com/settings/keys 取得你的 API 金鑰,然後將 `sk-ant-xxxxx` 替換為你的實際 API 金鑰。 + +3. 在計畫模式中,請 Claude 加入 API 整合: + + > Add Claude API integration so NPCs respond dynamically: + > + > - Install the Anthropic SDK and dotenv + > - Create a POST /api/talk endpoint in server.js + > - Use ANTHROPIC_API_KEY from environment + > - It should take { character, message } and return an AI response + > - The character's personality and knowledge should shape the response + > - When in dialogue mode, the player types messages directly (no prefix needed) + > - Typing "leave" or moving to another room exits dialogue mode + +4. 重新啟動伺服器: + + ```bash + node dungeon/server.js + ``` + +5. 在遊戲中測試(使用方向鍵或輸入指令): + + ``` + ↑ (or go up) + ↑ (or go up) + talk + what do you know about the goblin? + tell me about yourself + leave + ``` + +NPC 現在應該會動態回應,使用它們的個性和知識。它們不再只是資料——它們是代理。 + +## 你做到了! + +你已經完成了整個課程。 + +當你準備好時,執行: + +``` +/course complete +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..259c7dc --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "scripts": { + "start": "node reference/complete/server.js", + "dev": "node dungeon/server.js", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.72.1", + "dotenv": "^17.2.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e612d49 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1029 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.72.1 + version: 0.72.1 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + devDependencies: + vitest: + specifier: ^3.0.0 + version: 3.2.4 + +packages: + + '@anthropic-ai/sdk@0.72.1': + resolution: {integrity: sha512-MiUnue7qN7DvLIoYHgkedN2z05mRf2CutBzjXXY2krzOhG2r/rIfISS2uVkNLikgToB5hYIzw+xp2jdOtRkqYQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@anthropic-ai/sdk@0.72.1': + dependencies: + json-schema-to-ts: 3.1.1 + + '@babel/runtime@7.28.6': {} + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + dotenv@17.2.3: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + ts-algebra@2.0.0: {} + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1: + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.4: + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/reference/complete/data/characters.json b/reference/complete/data/characters.json index bcbd45e..23902d4 100644 --- a/reference/complete/data/characters.json +++ b/reference/complete/data/characters.json @@ -1,16 +1,24 @@ { "wizard": { "name": "Elara the Wise", + "name_zh": "智者艾拉拉", "personality": "Ancient, mysterious, and deeply knowledgeable about the dungeon's secrets", + "personality_zh": "古老、神秘,對地牢的秘密有著深厚的認識", "knowledge": "Knows the dungeon is far older than most realize. The goblin in the tunnel is a guardian that must be defeated. The legendary sword aids greatly in combat. The throne room holds secrets for the truly brave.", + "knowledge_zh": "知道這座地牢比大多數人所認知的還要古老。通道中的哥布林是必須擊敗的守衛。傳奇之劍在戰鬥中大有助益。王座室為真正勇敢的人隱藏著秘密。", "location": "secret-garden", - "greeting": "Ah, a traveler has found their way to my garden. I've been expecting someone. The dungeon reveals itself only to those who seek wisdom." + "greeting": "Ah, a traveler has found their way to my garden. I've been expecting someone. The dungeon reveals itself only to those who seek wisdom.", + "greeting_zh": "啊,一位旅人找到了通往我花園的路。我一直在等待著某個人。地牢只會向尋求智慧的人展現自己。" }, "treasure-guardian": { "name": "Thorne Ironheart", + "name_zh": "鐵心索恩", "personality": "Fearless, honorable, and fiercely protective of the dungeon's riches", + "personality_zh": "無畏、正直,對地牢的財富有著強烈的守護意志", "knowledge": "Has faced the throne room's ancient evil. Brute strength alone won't defeat it - cunning and swiftness are key. True treasure is measured in character, not gold. Was once a seeker like the player, now guards the vault.", + "knowledge_zh": "曾面對王座室的遠古邪惡。光靠蠻力無法擊敗它——機智和敏捷才是關鍵。真正的寶藏是以品格來衡量,而非黃金。曾經和玩家一樣是個探索者,現在守護著寶庫。", "location": "treasure-room", - "greeting": "Stand tall, adventurer. I am Thorne Ironheart, guardian of this hoard. Only those with courage and honor may touch what lies here." + "greeting": "Stand tall, adventurer. I am Thorne Ironheart, guardian of this hoard. Only those with courage and honor may touch what lies here.", + "greeting_zh": "挺起胸膛,冒險者。我是鐵心索恩,這座寶庫的守護者。唯有擁有勇氣和榮譽的人,才能觸碰這裡的一切。" } } diff --git a/reference/complete/data/enemies.json b/reference/complete/data/enemies.json index 72ea005..c048f51 100644 --- a/reference/complete/data/enemies.json +++ b/reference/complete/data/enemies.json @@ -1,6 +1,7 @@ { "goblin": { "name": "Goblin", + "name_zh": "哥布林", "hp": 10, "damage": 3, "room": "narrow-tunnel" diff --git a/reference/complete/data/items.json b/reference/complete/data/items.json index 59523dc..62a9cd3 100644 --- a/reference/complete/data/items.json +++ b/reference/complete/data/items.json @@ -1,34 +1,52 @@ { "rusty-sword": { "name": "Rusty Sword", + "name_zh": "生鏽的劍", + "icon": "sword", "description": "An old sword, covered in rust but still sharp enough to be useful.", + "description_zh": "一把老舊的劍,佈滿鏽跡但仍然鋒利到足以使用。", "room": "cave-entrance", "damage": 10 }, "ancient-torch": { "name": "Ancient Torch", + "name_zh": "古老的火炬", + "icon": "torch", "description": "A torch that flickers with an eternal flame.", + "description_zh": "一支閃爍著永恆火焰的火炬。", "room": "narrow-tunnel" }, "health-potion": { "name": "Health Potion", + "name_zh": "治療藥水", + "icon": "potion", "description": "A red potion that smells of herbs. It looks like it could restore health.", + "description_zh": "一瓶散發草藥香味的紅色藥水。看起來可以恢復生命值。", "room": "treasure-room", "heals": 30 }, "golden-key": { "name": "Golden Key", + "name_zh": "黃金鑰匙", + "icon": "key", "description": "A ornate golden key. It must unlock something important.", + "description_zh": "一把精美的黃金鑰匙。它一定能打開某個重要的東西。", "room": "underground-lake" }, "magic-gem": { "name": "Magic Gem", + "name_zh": "魔法寶石", + "icon": "gem", "description": "A glowing gemstone that pulses with magical energy.", + "description_zh": "一顆散發著魔法能量脈動光芒的寶石。", "room": "secret-garden" }, "legendary-sword": { "name": "Legendary Sword", + "name_zh": "傳奇的劍", + "icon": "sword", "description": "A magnificent blade forged by ancient masters. Its edge gleams with otherworldly power.", + "description_zh": "一把由遠古大師鍛造的華麗之劍。劍鋒閃耀著超凡的力量。", "room": "secret-garden", "damage": 10 } diff --git a/reference/complete/data/rooms.json b/reference/complete/data/rooms.json index 007a8f9..ffb5a93 100644 --- a/reference/complete/data/rooms.json +++ b/reference/complete/data/rooms.json @@ -1,14 +1,18 @@ { "cave-entrance": { "name": "Cave Entrance", + "name_zh": "洞穴入口", "description": "You stand at the entrance of a dark cave. Faint light filters in from outside.", + "description_zh": "你站在一個黑暗洞穴的入口。微弱的光線從外面透進來。", "exits": { "up": "narrow-tunnel" } }, "narrow-tunnel": { "name": "Narrow Tunnel", + "name_zh": "狹窄通道", "description": "A cramped passage with rough stone walls. The air grows cooler as you venture deeper.", + "description_zh": "一條狹窄的通道,牆壁由粗糙的石頭構成。隨著你深入探險,空氣變得更加涼爽。", "exits": { "down": "cave-entrance", "up": "secret-garden", @@ -18,14 +22,18 @@ }, "treasure-room": { "name": "Treasure Room", + "name_zh": "寶藏室", "description": "A glittering chamber filled with gold coins and precious artifacts. Wealth beyond measure surrounds you.", + "description_zh": "一間閃閃發光的房間,充滿了金幣和珍貴的文物。難以計量的財富環繞著你。", "exits": { "left": "narrow-tunnel" } }, "underground-lake": { "name": "Underground Lake", + "name_zh": "地下湖", "description": "A vast cavern opening before you, with a dark underground lake stretching into the shadows. Strange echoes bounce off the water.", + "description_zh": "一個巨大的洞窟在你面前展開,一片黑暗的地下湖延伸到陰影之中。奇異的回音從水面上彈跳。", "exits": { "right": "narrow-tunnel", "left": "throne-room" @@ -33,14 +41,18 @@ }, "secret-garden": { "name": "Secret Garden", + "name_zh": "秘密花園", "description": "An impossible garden blooming underground, with flowers that glow softly in the darkness. You've found something magical.", + "description_zh": "一座不可思議的花園在地下綻放,花朵在黑暗中散發柔和的光芒。你發現了一些神奇的事物。", "exits": { "down": "narrow-tunnel" } }, "throne-room": { "name": "Throne Room", + "name_zh": "王座室", "description": "A vast chamber with a throne of obsidian and gold. The room feels heavy with ancient power. Your only choice is to retreat.", + "description_zh": "一個巨大的房間,擺放著黑曜石與黃金打造的王座。房間瀰漫著古老力量的沉重感。你唯一的選擇是撤退。", "exits": { "right": "underground-lake" } diff --git a/reference/complete/game.js b/reference/complete/game.js index 7046ba1..ae9970d 100644 --- a/reference/complete/game.js +++ b/reference/complete/game.js @@ -74,33 +74,31 @@ function processCommand(input) { // Basic commands if (command === "help") { - print( - "Available commands: help, look, go, take, inventory, attack, talk, leave", - ) + print(t(S.availableCommands)) } else if (command === "look") { const room = rooms[currentRoom] if (room) { - print(room.description) + print(t(room, "description")) // Show items in room const roomItems = Object.entries(items) .filter(([id, item]) => item.room === currentRoom) - .map(([id, item]) => item.name) + .map(([id, item]) => t(item, "name")) if (roomItems.length > 0) { - print(`Items: ${roomItems.join(", ")}`) + print(T.itemList(roomItems.join(", "))) } // Show enemies in room const roomEnemies = Object.entries(enemies) .filter(([id, enemy]) => enemy.room === currentRoom && enemy.hp > 0) - .map(([id, enemy]) => `${enemy.name} (${enemy.hp} hp)`) + .map(([id, enemy]) => T.enemyStatus(t(enemy, "name"), enemy.hp)) if (roomEnemies.length > 0) { - print(`Enemies: ${roomEnemies.join(", ")}`) + print(T.enemyList(roomEnemies.join(", "))) } const exitList = Object.keys(room.exits).join(", ") if (exitList) { - print(`Exits: ${exitList}`) + print(T.exitList(T.localizeExits(exitList))) } } } else if (command.startsWith("go ")) { @@ -122,7 +120,7 @@ function processCommand(input) { const message = input.slice(4) // Use 'input' to preserve case sayTo(message) } else { - print("I don't understand that.", "error") + print(t(S.dontUnderstand), "error") } } @@ -136,40 +134,40 @@ function goDirection(direction) { updateUI() const newRoom = rooms[currentRoom] if (newRoom) { - print(`You go ${direction}.`) - print(newRoom.description) + print(T.youGo(direction)) + print(t(newRoom, "description")) // Show characters in room const roomCharacters = Object.entries(characters) .filter(([id, char]) => char.location === currentRoom) - .map(([id, char]) => char.name) + .map(([id, char]) => t(char, "name")) if (roomCharacters.length > 0) { - print(`Talk to: ${roomCharacters.join(", ")}`) + print(T.talkToList(roomCharacters.join(", "))) } // Show items in room const roomItems = Object.entries(items) .filter(([id, item]) => item.room === currentRoom) - .map(([id, item]) => item.name) + .map(([id, item]) => t(item, "name")) if (roomItems.length > 0) { - print(`Items: ${roomItems.join(", ")}`) + print(T.itemList(roomItems.join(", "))) } // Show enemies in room const roomEnemies = Object.entries(enemies) .filter(([id, enemy]) => enemy.room === currentRoom && enemy.hp > 0) - .map(([id, enemy]) => `${enemy.name} (${enemy.hp} hp)`) + .map(([id, enemy]) => T.enemyStatus(t(enemy, "name"), enemy.hp)) if (roomEnemies.length > 0) { - print(`Enemies: ${roomEnemies.join(", ")}`) + print(T.enemyList(roomEnemies.join(", "))) } const exitList = Object.keys(newRoom.exits).join(", ") if (exitList) { - print(`Exits: ${exitList}`) + print(T.exitList(T.localizeExits(exitList))) } } } else { - print("You can't go that way.", "error") + print(t(S.cantGoThatWay), "error") } } @@ -184,11 +182,12 @@ function take(itemName) { ([id, item]) => item.room === currentRoom, ) } else { - // Target specified - match by name (case-insensitive, partial match) + // Target specified - match by name or zh name (case-insensitive, partial match) entry = Object.entries(items).find( ([id, item]) => item.room === currentRoom && - item.name.toLowerCase().includes(itemName.toLowerCase()), + (item.name.toLowerCase().includes(itemName.toLowerCase()) || + (item.name_zh && item.name_zh.includes(itemName))), ) } @@ -196,21 +195,21 @@ function take(itemName) { const [id, item] = entry item.room = null // Remove from room inventory.push(id) // Add to inventory - print(`You pick up the ${item.name}.`, "success") + print(T.youPickUp(t(item, "name")), "success") updateInventory() updateTakeButton() showItemsBar() } else { - print("You don't see that here.", "error") + print(t(S.dontSeeHere), "error") } } function showInventory() { if (inventory.length === 0) { - print("You are carrying nothing.") + print(t(S.carryingNothing)) } else { - const carried = inventory.map((id) => items[id].name).join(", ") - print(`You are carrying: ${carried}`) + const carried = inventory.map((id) => t(items[id], "name")).join(", ") + print(T.carrying(carried)) } } @@ -223,7 +222,7 @@ function attack() { ) if (!entry) { - print("There's nothing to attack here.", "error") + print(t(S.nothingToAttack), "error") return } @@ -235,32 +234,32 @@ function attack() { // Player attacks enemy.hp -= playerDamage - print(`You attack the ${enemy.name} for ${playerDamage} damage!`, "combat") + print(T.youAttackFor(t(enemy, "name"), playerDamage), "combat") if (enemy.hp <= 0) { - print(`The ${enemy.name} is defeated!`, "success") + print(T.enemyDefeated(t(enemy, "name")), "success") enemy.room = null // Remove enemy from room updateAttackButton() showEncounterBox() return } - print(`The ${enemy.name} has ${enemy.hp} hp left.`, "combat") + print(T.enemyHpLeft(t(enemy, "name"), enemy.hp), "combat") // Enemy attacks back playerHp -= enemy.damage - print(`The ${enemy.name} attacks you for ${enemy.damage} damage!`, "combat") + print(T.enemyAttacksYou(t(enemy, "name"), enemy.damage), "combat") updateHpBar() if (playerHp <= 0) { - print("You have been slain. Game over.", "error") - print("Refresh to restart.") + print(t(S.youHaveBeenSlain), "error") + print(t(S.refreshToRestart)) commandInput.disabled = true return } - print(`You have ${playerHp} hp left.`, "combat") + print(T.playerHpLeft(playerHp), "combat") } // ============ NPC CONVERSATION SYSTEM ============ @@ -277,11 +276,11 @@ function talk(name) { ([id, e]) => id === "goblin" && e.room === currentRoom && e.hp > 0 ) if (goblin) { - print(`Goblin: "GRAK SNORK BLURGLE!! MEEP GRONK SKREEEE!!"`, "npc") + print(T.npcSays(t(goblin[1], "name"), t(S.goblinSpeak)), "npc") return } - print("There's no one here to talk to.", "error") + print(t(S.noOneHere), "error") return } @@ -290,16 +289,17 @@ function talk(name) { // No target specified - talk to first NPC in room entry = npcsInRoom[0] } else { - // Match by name or ID (case-insensitive, partial match) + // Match by name, zh name, or ID (case-insensitive, partial match) entry = npcsInRoom.find( ([id, c]) => c.name.toLowerCase().includes(name.toLowerCase()) || + (c.name_zh && c.name_zh.includes(name)) || id.toLowerCase().includes(name.toLowerCase()), ) } if (!entry) { - print("I don't see anyone by that name here.", "error") + print(t(S.dontSeeAnyone), "error") return } @@ -311,15 +311,15 @@ function talk(name) { if (encounterBox.hidden) { showPortrait(character) } - print(`${character.name}: "${character.greeting}"`) + print(T.npcGreeting(t(character, "name"), t(character, "greeting"))) print("") - print("Type your message, or 'leave' to end conversation.") + print(t(S.typeMessageOrLeave)) } function leaveConversation() { if (!talkingTo) return - print(`You stop talking to ${talkingTo.name}.`) + print(T.youStopTalkingTo(t(talkingTo, "name"))) talkingTo = null talkingToId = null hidePortrait() @@ -327,11 +327,11 @@ function leaveConversation() { async function sayTo(message) { if (!talkingTo) { - print("You're not talking to anyone.", "error") + print(t(S.notTalkingToAnyone), "error") return } - print(`You: "${message}"`) + print(T.youSay(message)) try { const res = await fetch("/api/talk", { @@ -344,11 +344,11 @@ async function sayTo(message) { }) const data = await res.json() - print(`${talkingTo.name}: "${data.response}"`, "npc") + print(T.npcSays(t(talkingTo, "name"), data.response), "npc") } catch (err) { console.error("Say error:", err) // Fallback to greeting if API fails - print(`${talkingTo.name}: "${talkingTo.greeting}"`, "npc") + print(T.npcSays(t(talkingTo, "name"), t(talkingTo, "greeting")), "npc") } } @@ -446,18 +446,18 @@ async function init() { updateUI() enableCommandButtons() - print("Welcome to Dungeons & Agents!") + print(t(S.welcome)) print("") const room = rooms[currentRoom] if (room) { - print(room.description) + print(t(room, "description")) // Show items in the starting room const roomItems = Object.entries(items) .filter(([id, item]) => item.room === currentRoom) - .map(([id, item]) => item.name) + .map(([id, item]) => t(item, "name")) if (roomItems.length > 0) { - print(`Items: ${roomItems.join(", ")}`) + print(T.itemList(roomItems.join(", "))) } } } diff --git a/reference/complete/index.html b/reference/complete/index.html index c57d285..267abb0 100644 --- a/reference/complete/index.html +++ b/reference/complete/index.html @@ -164,6 +164,7 @@

ACTIONS

+ diff --git a/reference/complete/server.js b/reference/complete/server.js index a632d89..e5325e8 100644 --- a/reference/complete/server.js +++ b/reference/complete/server.js @@ -92,7 +92,8 @@ Respond in character with dialogue only. Keep responses concise (1-2 sentences). return } - let filePath = req.url === "/" ? "/index.html" : req.url + const urlPath = req.url.split("?")[0] + let filePath = urlPath === "/" ? "/index.html" : urlPath filePath = path.join(__dirname, filePath) const ext = path.extname(filePath) @@ -120,5 +121,6 @@ fs.watch(__dirname, { recursive: true }, (event, filename) => { server.listen(PORT, () => { console.log(`\n Dungeons & Agents running at http://localhost:${PORT}`) + console.log(` 繁體中文版 http://localhost:${PORT}?lang=zh-TW`) console.log(` Live reload enabled\n`) }) diff --git a/reference/complete/strings.js b/reference/complete/strings.js new file mode 100644 index 0000000..c432539 --- /dev/null +++ b/reference/complete/strings.js @@ -0,0 +1,255 @@ +// Dungeons & Agents - i18n / Localization +// Supports English (en) and Traditional Chinese (zh-TW) + +// ============ LOCALE DETECTION ============ + +function detectLocale(search) { + if (search === undefined) { + search = typeof window !== "undefined" ? window.location.search : "" + } + return new URLSearchParams(search).get("lang") === "zh-TW" ? "zh-TW" : "en" +} + +const locale = detectLocale() + +if (typeof document !== "undefined") { + document.documentElement.lang = locale === "zh-TW" ? "zh-TW" : "en" +} + +// ============ TRANSLATION HELPER ============ + +// Factory for testability +function createT(loc) { + return function t(objOrMap, field) { + if (field !== undefined) { + // Signature 1: t(obj, field) — data objects with _zh suffixed fields + if (loc === "zh-TW" && objOrMap[field + "_zh"]) { + return objOrMap[field + "_zh"] + } + return objOrMap[field] + } + // Signature 2: t({ en: "...", "zh-TW": "..." }) — locale maps + if (loc === "zh-TW" && objOrMap["zh-TW"]) { + return objOrMap["zh-TW"] + } + return objOrMap.en + } +} + +const t = createT(locale) + +// ============ SYSTEM STRINGS ============ + +const S = { + // Welcome / init + welcome: { en: "Welcome to Dungeons & Agents!", "zh-TW": "歡迎來到 Dungeons & Agents!" }, + typeHelp: { en: "Type 'help' for available commands.", "zh-TW": "輸入 'help' 查看可用指令。" }, + + // Help + availableCommands: { + en: "Available commands: help, look, go, take, inventory, attack, talk, leave", + "zh-TW": "可用指令:help、look、go、take、inventory、attack、talk、leave", + }, + + // Look labels + items: { en: "Items", "zh-TW": "物品" }, + enemies: { en: "Enemies", "zh-TW": "敵人" }, + exits: { en: "Exits", "zh-TW": "出口" }, + talkTo: { en: "Talk to", "zh-TW": "可對話" }, + + // Movement + cantGoThatWay: { en: "You can't go that way.", "zh-TW": "你不能往那個方向走。" }, + + // Inventory + dontSeeHere: { en: "You don't see that here.", "zh-TW": "這裡沒有那個東西。" }, + carryingNothing: { en: "You are carrying nothing.", "zh-TW": "你身上什麼都沒有。" }, + + // Combat + nothingToAttack: { en: "There's nothing to attack here.", "zh-TW": "這裡沒有可以攻擊的目標。" }, + youHaveBeenSlain: { en: "You have been slain. Game over.", "zh-TW": "你已陣亡。遊戲結束。" }, + refreshToRestart: { en: "Refresh to restart.", "zh-TW": "重新整理頁面以重新開始。" }, + + // NPC conversation + noOneHere: { en: "There's no one here to talk to.", "zh-TW": "這裡沒有人可以交談。" }, + dontSeeAnyone: { en: "I don't see anyone by that name here.", "zh-TW": "這裡沒有叫那個名字的人。" }, + typeMessageOrLeave: { + en: "Type your message, or 'leave' to end conversation.", + "zh-TW": "輸入你的訊息,或輸入 'leave' 結束對話。", + }, + notTalkingToAnyone: { en: "You're not talking to anyone.", "zh-TW": "你目前沒有在與任何人對話。" }, + + // Error + dontUnderstand: { en: "I don't understand that.", "zh-TW": "我不明白那個指令。" }, + + // Goblin easter egg + goblinSpeak: { + en: "GRAK SNORK BLURGLE!! MEEP GRONK SKREEEE!!", + "zh-TW": "GRAK SNORK BLURGLE!! MEEP GRONK SKREEEE!!", + }, + + // UI labels + statusLabel: { en: "STATUS", "zh-TW": "狀態" }, + locationLabel: { en: "LOCATION", "zh-TW": "位置" }, + inventoryLabel: { en: "INVENTORY", "zh-TW": "物品欄" }, + actionsLabel: { en: "ACTIONS", "zh-TW": "行動" }, + emptyInventory: { en: "Empty", "zh-TW": "空的" }, + unknownLocation: { en: "Unknown", "zh-TW": "未知" }, + enterCommand: { en: "Enter command...", "zh-TW": "輸入指令..." }, + itemsBarTitle: { en: "ITEMS:", "zh-TW": "物品:" }, + + // Map legend + legendYou: { en: "You", "zh-TW": "你" }, + legendVisited: { en: "Visited", "zh-TW": "已探索" }, + legendUnknown: { en: "Unknown", "zh-TW": "未知" }, + + // Button labels + btnLook: { en: "Look", "zh-TW": "查看" }, + btnInv: { en: "Inv", "zh-TW": "背包" }, + btnHelp: { en: "Help", "zh-TW": "說明" }, + btnTake: { en: "Take", "zh-TW": "拿取" }, + btnTalk: { en: "Talk", "zh-TW": "交談" }, + btnAtk: { en: "Atk", "zh-TW": "攻擊" }, +} + +// ============ TEMPLATE FUNCTIONS ============ + +// Direction display names +const dirNames = { + up: { en: "up", "zh-TW": "上" }, + down: { en: "down", "zh-TW": "下" }, + left: { en: "left", "zh-TW": "左" }, + right: { en: "right", "zh-TW": "右" }, +} + +function createTemplates(loc, tFn) { + const isZh = loc === "zh-TW" + return { + youGo: (dir) => + isZh + ? `你往${dirNames[dir]?.["zh-TW"] || dir}走。` + : `You go ${dir}.`, + youPickUp: (name) => + isZh ? `你撿起了${name}。` : `You pick up the ${name}.`, + carrying: (list) => + isZh ? `你帶著:${list}` : `You are carrying: ${list}`, + youAttackFor: (name, dmg) => + isZh + ? `你攻擊了${name},造成 ${dmg} 點傷害!` + : `You attack the ${name} for ${dmg} damage!`, + enemyDefeated: (name) => + isZh ? `${name}被擊敗了!` : `The ${name} is defeated!`, + enemyHpLeft: (name, hp) => + isZh ? `${name}還剩 ${hp} HP。` : `The ${name} has ${hp} hp left.`, + enemyAttacksYou: (name, dmg) => + isZh + ? `${name}攻擊你造成了 ${dmg} 點傷害!` + : `The ${name} attacks you for ${dmg} damage!`, + playerHpLeft: (hp) => + isZh ? `你還剩 ${hp} HP。` : `You have ${hp} hp left.`, + youStopTalkingTo: (name) => + isZh ? `你結束了與${name}的對話。` : `You stop talking to ${name}.`, + npcGreeting: (name, greeting) => + isZh ? `${name}:「${greeting}」` : `${name}: "${greeting}"`, + youSay: (msg) => (isZh ? `你:「${msg}」` : `You: "${msg}"`), + npcSays: (name, msg) => + isZh ? `${name}:「${msg}」` : `${name}: "${msg}"`, + localizeExits: (exitsStr) => + isZh + ? exitsStr.split(", ").map(d => dirNames[d]?.["zh-TW"] || d).join("、") + : exitsStr, + exitList: (exits) => `${tFn(S.exits)}: ${exits}`, + itemList: (items) => `${tFn(S.items)}: ${items}`, + enemyList: (enemies) => `${tFn(S.enemies)}: ${enemies}`, + talkToList: (chars) => `${tFn(S.talkTo)}: ${chars}`, + enemyStatus: (name, hp) => + isZh ? `${name} (${hp} HP)` : `${name} (${hp} hp)`, + enemyEncounterStats: (hp, dmg) => + isZh ? `${hp} HP • ${dmg} 傷害` : `${hp} HP • ${dmg} damage`, + } +} + +const T = createTemplates(locale, t) + +// ============ HTML LOCALIZATION ============ + +function localizeHTML() { + if (typeof document === "undefined" || locale === "en") return + + // Panel titles + const panelTitle = document.querySelector(".panel-title") + if (panelTitle) panelTitle.textContent = t(S.statusLabel) + + const subtitles = document.querySelectorAll(".panel-subtitle") + const subtitleKeys = [S.locationLabel, S.inventoryLabel, S.actionsLabel] + subtitles.forEach((el, i) => { + if (subtitleKeys[i]) el.textContent = t(subtitleKeys[i]) + }) + + // Map legend + const legendItems = document.querySelectorAll(".legend-item") + const legendKeys = [S.legendYou, S.legendVisited, S.legendUnknown] + legendItems.forEach((el, i) => { + if (!legendKeys[i]) return + const swatch = el.querySelector(".legend-swatch") + el.textContent = "" + if (swatch) el.appendChild(swatch) + el.appendChild(document.createTextNode(" " + t(legendKeys[i]))) + }) + + // Buttons with data-cmd + const btnMap = { look: S.btnLook, inventory: S.btnInv, help: S.btnHelp, attack: S.btnAtk } + document.querySelectorAll(".pixel-btn[data-cmd]").forEach((btn) => { + const cmd = btn.dataset.cmd + if (!btnMap[cmd]) return + const icon = btn.querySelector(".btn-icon") + btn.textContent = "" + if (icon) btn.appendChild(icon) + btn.appendChild(document.createTextNode(" " + t(btnMap[cmd]))) + }) + + // Talk / Take buttons (no data-cmd) + const talkBtn = document.getElementById("talk-btn") + if (talkBtn) { + const icon = talkBtn.querySelector(".btn-icon") + talkBtn.textContent = "" + if (icon) talkBtn.appendChild(icon) + talkBtn.appendChild(document.createTextNode(" " + t(S.btnTalk))) + } + + const takeBtn = document.getElementById("take-btn") + if (takeBtn) { + const icon = takeBtn.querySelector(".btn-icon") + takeBtn.textContent = "" + if (icon) takeBtn.appendChild(icon) + takeBtn.appendChild(document.createTextNode(" " + t(S.btnTake))) + } + + // Items bar title + const itemsTitle = document.querySelector(".items-title") + if (itemsTitle) itemsTitle.textContent = t(S.itemsBarTitle) + + // Input placeholder + const cmdInput = document.getElementById("command") + if (cmdInput) cmdInput.placeholder = t(S.enterCommand) + + // Initial output text + const outputDiv = document.getElementById("output") + if (outputDiv) { + const systemPs = outputDiv.querySelectorAll("p.system") + if (systemPs.length > 0) systemPs[0].textContent = t(S.typeHelp) + } + + // Inventory empty text + const emptyItem = document.querySelector(".inventory-empty") + if (emptyItem) emptyItem.textContent = t(S.emptyInventory) +} + +if (typeof document !== "undefined") { + document.addEventListener("DOMContentLoaded", localizeHTML) +} + +// ============ EXPORTS (for testing) ============ + +if (typeof module !== "undefined" && module.exports) { + module.exports = { detectLocale, createT, createTemplates, S, dirNames } +} diff --git a/reference/complete/style.css b/reference/complete/style.css index 28f6d63..3f4a116 100644 --- a/reference/complete/style.css +++ b/reference/complete/style.css @@ -715,3 +715,41 @@ body { border-right: none; } } + +/* CJK font support for Traditional Chinese */ +:lang(zh-TW) { + --font-cjk: 'Noto Sans TC', 'PingFang TC', 'Microsoft JhengHei', 'Noto Sans CJK TC', sans-serif; + --text-lg: 18px; + --text-md: 14px; + --text-sm: 12px; + --text-icon: 16px; +} + +:lang(zh-TW) body { + font-family: var(--font-cjk); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.8; +} + +:lang(zh-TW) .pixel-btn, +:lang(zh-TW) #command { + font-family: var(--font-cjk); +} + +:lang(zh-TW) .pixel-btn { + font-size: var(--text-sm); + padding: 8px 6px; +} + +:lang(zh-TW) .output p { + line-height: 2; +} + +:lang(zh-TW) .entity-label { + font-size: 10px; +} + +:lang(zh-TW) .game-title { + font-family: var(--font-main); +} diff --git a/reference/complete/ui/ui.js b/reference/complete/ui/ui.js index c46110d..3e7b3f2 100644 --- a/reference/complete/ui/ui.js +++ b/reference/complete/ui/ui.js @@ -73,16 +73,20 @@ function updateHpBar() { function updateLocation() { const room = rooms[currentRoom] if (room) { - locationName.textContent = room.name + locationName.textContent = t(room, "name") visitedRooms.add(currentRoom) } else { - locationName.textContent = "Unknown" + locationName.textContent = t(S.unknownLocation) } } -// Get icon for item -function getItemIcon(itemName) { - const name = itemName.toLowerCase() +// Get icon for item (uses icon field if available, falls back to name matching) +function getItemIcon(item) { + if (item.icon) { + const iconMap = { sword: "⚔", key: "🗝", torch: "🔥", potion: "🧪", shield: "🛡", gem: "💎" } + return iconMap[item.icon] || "◆" + } + const name = (item.name || "").toLowerCase() if (name.includes("sword")) return "⚔" if (name.includes("key")) return "🗝" if (name.includes("torch")) return "🔥" @@ -99,7 +103,7 @@ function updateInventory() { if (inventory.length === 0) { const li = document.createElement("li") li.className = "inventory-empty" - li.textContent = "Empty" + li.textContent = t(S.emptyInventory) inventoryList.appendChild(li) } else { inventory.forEach((id) => { @@ -107,9 +111,9 @@ function updateInventory() { const li = document.createElement("li") const icon = document.createElement("span") icon.className = "item-icon" - icon.textContent = getItemIcon(item.name) + icon.textContent = getItemIcon(item) li.appendChild(icon) - li.appendChild(document.createTextNode(item.name)) + li.appendChild(document.createTextNode(t(item, "name"))) inventoryList.appendChild(li) }) } @@ -197,8 +201,8 @@ function showPortrait(character) { portraitArt.style.boxShadow = 'none' portraitArt.textContent = '?' } - portraitName.textContent = character.name - portraitTrait.textContent = character.personality + portraitName.textContent = t(character, "name") + portraitTrait.textContent = t(character, "personality") portraitContainer.hidden = false } @@ -208,12 +212,17 @@ function hidePortrait() { } // Map item IDs to portrait IDs -function getItemPortraitId(itemId) { - if (itemId.includes("sword")) return "sword" - if (itemId.includes("key")) return "key" - if (itemId.includes("potion")) return "potion" - if (itemId.includes("torch")) return "torch" - if (itemId.includes("gem")) return "gem" +function getItemPortraitId(itemIdOrObj) { + // Use icon field if an item object is passed + if (typeof itemIdOrObj === "object" && itemIdOrObj.icon) { + return itemIdOrObj.icon + } + const id = typeof itemIdOrObj === "string" ? itemIdOrObj : "" + if (id.includes("sword")) return "sword" + if (id.includes("key")) return "key" + if (id.includes("potion")) return "potion" + if (id.includes("torch")) return "torch" + if (id.includes("gem")) return "gem" return "gem" // fallback } @@ -266,10 +275,10 @@ function showEncounterBox() { // Prioritize character over enemy if (charEntry) { const [id, char] = charEntry - showEncounter(getCharacterPortraitId(id), char.name, char.personality, "character") + showEncounter(getCharacterPortraitId(id), t(char, "name"), t(char, "personality"), "character") } else if (enemyEntry) { const [id, enemy] = enemyEntry - showEncounter(id, enemy.name, `${enemy.hp} HP • ${enemy.damage} damage`, "enemy") + showEncounter(id, t(enemy, "name"), T.enemyEncounterStats(enemy.hp, enemy.damage), "enemy") } else { encounterBox.hidden = true } @@ -302,7 +311,7 @@ function showItemsBar() { roomItems.forEach(([id, item]) => { itemsList.appendChild( - createEntityIcon(getItemPortraitId(id), item.name, "item") + createEntityIcon(getItemPortraitId(item), t(item, "name"), "item") ) }) diff --git a/reference/starter/server.js b/reference/starter/server.js index 0485ab0..f22a8d7 100644 --- a/reference/starter/server.js +++ b/reference/starter/server.js @@ -28,7 +28,8 @@ const server = http.createServer(async (req, res) => { return } - let filePath = req.url === "/" ? "/index.html" : req.url + const urlPath = req.url.split("?")[0] + let filePath = urlPath === "/" ? "/index.html" : urlPath filePath = path.join(__dirname, filePath) const ext = path.extname(filePath) diff --git a/tests/i18n.test.js b/tests/i18n.test.js new file mode 100644 index 0000000..29b878e --- /dev/null +++ b/tests/i18n.test.js @@ -0,0 +1,271 @@ +import { describe, it, expect } from "vitest" +import { readFileSync } from "fs" +import { resolve } from "path" +import { detectLocale, createT, createTemplates, S, dirNames } from "../reference/complete/strings.js" + +// ============ detectLocale ============ + +describe("detectLocale", () => { + it("defaults to 'en' when no query param", () => { + expect(detectLocale("")).toBe("en") + }) + + it("defaults to 'en' when search is undefined-like", () => { + expect(detectLocale("?foo=bar")).toBe("en") + }) + + it("returns 'zh-TW' when ?lang=zh-TW", () => { + expect(detectLocale("?lang=zh-TW")).toBe("zh-TW") + }) + + it("returns 'en' for invalid locale values", () => { + expect(detectLocale("?lang=fr")).toBe("en") + expect(detectLocale("?lang=zh-CN")).toBe("en") + }) +}) + +// ============ t() helper ============ + +describe("t() with English locale", () => { + const t = createT("en") + + it("returns English from locale map", () => { + expect(t(S.welcome)).toBe("Welcome to Dungeons & Agents!") + }) + + it("returns base field from data object", () => { + const room = { name: "Cave Entrance", name_zh: "洞穴入口" } + expect(t(room, "name")).toBe("Cave Entrance") + }) + + it("returns field even when _zh is missing", () => { + const obj = { name: "Test" } + expect(t(obj, "name")).toBe("Test") + }) +}) + +describe("t() with zh-TW locale", () => { + const t = createT("zh-TW") + + it("returns zh-TW from locale map", () => { + expect(t(S.welcome)).toBe("歡迎來到 Dungeons & Agents!") + }) + + it("returns _zh field from data object", () => { + const room = { name: "Cave Entrance", name_zh: "洞穴入口" } + expect(t(room, "name")).toBe("洞穴入口") + }) + + it("falls back to English when _zh field is missing", () => { + const obj = { name: "Test" } + expect(t(obj, "name")).toBe("Test") + }) + + it("falls back to English when locale map has no zh-TW", () => { + const map = { en: "English only" } + expect(t(map)).toBe("English only") + }) +}) + +// ============ T template functions ============ + +describe("Template functions (English)", () => { + const tEn = createT("en") + const T = createTemplates("en", tEn) + + it("T.youGo formats correctly", () => { + expect(T.youGo("up")).toBe("You go up.") + }) + + it("T.youPickUp formats correctly", () => { + expect(T.youPickUp("Rusty Sword")).toBe("You pick up the Rusty Sword.") + }) + + it("T.youAttackFor formats correctly", () => { + expect(T.youAttackFor("Goblin", 10)).toBe("You attack the Goblin for 10 damage!") + }) + + it("T.enemyDefeated formats correctly", () => { + expect(T.enemyDefeated("Goblin")).toBe("The Goblin is defeated!") + }) + + it("T.playerHpLeft formats correctly", () => { + expect(T.playerHpLeft(75)).toBe("You have 75 hp left.") + }) + + it("T.carrying formats correctly", () => { + expect(T.carrying("Sword, Torch")).toBe("You are carrying: Sword, Torch") + }) + + it("T.youSay formats correctly", () => { + expect(T.youSay("Hello")).toBe('You: "Hello"') + }) + + it("T.npcSays formats correctly", () => { + expect(T.npcSays("Elara", "Welcome")).toBe('Elara: "Welcome"') + }) + + it("T.npcGreeting formats correctly", () => { + expect(T.npcGreeting("Elara", "Welcome")).toBe('Elara: "Welcome"') + }) + + it("T.exitList formats correctly", () => { + expect(T.exitList("up, down")).toBe("Exits: up, down") + }) + + it("T.localizeExits keeps English directions", () => { + expect(T.localizeExits("up, down")).toBe("up, down") + }) +}) + +describe("Template functions (zh-TW)", () => { + const tZh = createT("zh-TW") + const T = createTemplates("zh-TW", tZh) + + it("T.youGo formats correctly in Chinese", () => { + expect(T.youGo("up")).toBe("你往上走。") + }) + + it("T.youPickUp formats correctly in Chinese", () => { + expect(T.youPickUp("生鏽的劍")).toBe("你撿起了生鏽的劍。") + }) + + it("T.youAttackFor formats correctly in Chinese", () => { + expect(T.youAttackFor("哥布林", 10)).toBe("你攻擊了哥布林,造成 10 點傷害!") + }) + + it("T.enemyDefeated formats correctly in Chinese", () => { + expect(T.enemyDefeated("哥布林")).toBe("哥布林被擊敗了!") + }) + + it("T.playerHpLeft formats correctly in Chinese", () => { + expect(T.playerHpLeft(75)).toBe("你還剩 75 HP。") + }) + + it("T.youSay uses Chinese quotation marks", () => { + expect(T.youSay("你好")).toBe("你:「你好」") + }) + + it("T.npcSays uses Chinese quotation marks", () => { + expect(T.npcSays("艾拉拉", "歡迎")).toBe("艾拉拉:「歡迎」") + }) + + it("T.npcGreeting uses Chinese quotation marks", () => { + expect(T.npcGreeting("艾拉拉", "歡迎")).toBe("艾拉拉:「歡迎」") + }) + + it("T.exitList uses Chinese label", () => { + expect(T.exitList("上、下")).toBe("出口: 上、下") + }) + + it("T.localizeExits translates directions to Chinese", () => { + expect(T.localizeExits("up, down")).toBe("上、下") + }) + + it("T.localizeExits handles unknown directions gracefully", () => { + expect(T.localizeExits("up, northwest")).toBe("上、northwest") + }) +}) + +// ============ Data file integrity ============ + +describe("Data file integrity — rooms.json", () => { + const rooms = JSON.parse( + readFileSync(resolve("reference/complete/data/rooms.json"), "utf-8") + ) + + it("all rooms have name_zh", () => { + for (const [id, room] of Object.entries(rooms)) { + expect(room.name_zh, `room ${id} missing name_zh`).toBeTruthy() + } + }) + + it("all rooms have description_zh", () => { + for (const [id, room] of Object.entries(rooms)) { + expect(room.description_zh, `room ${id} missing description_zh`).toBeTruthy() + } + }) +}) + +describe("Data file integrity — items.json", () => { + const items = JSON.parse( + readFileSync(resolve("reference/complete/data/items.json"), "utf-8") + ) + + it("all items have name_zh", () => { + for (const [id, item] of Object.entries(items)) { + expect(item.name_zh, `item ${id} missing name_zh`).toBeTruthy() + } + }) + + it("all items have description_zh", () => { + for (const [id, item] of Object.entries(items)) { + expect(item.description_zh, `item ${id} missing description_zh`).toBeTruthy() + } + }) + + it("all items have icon field", () => { + for (const [id, item] of Object.entries(items)) { + expect(item.icon, `item ${id} missing icon`).toBeTruthy() + } + }) +}) + +describe("Data file integrity — characters.json", () => { + const characters = JSON.parse( + readFileSync(resolve("reference/complete/data/characters.json"), "utf-8") + ) + + it("all characters have name_zh", () => { + for (const [id, char] of Object.entries(characters)) { + expect(char.name_zh, `character ${id} missing name_zh`).toBeTruthy() + } + }) + + it("all characters have personality_zh", () => { + for (const [id, char] of Object.entries(characters)) { + expect(char.personality_zh, `character ${id} missing personality_zh`).toBeTruthy() + } + }) + + it("all characters have greeting_zh", () => { + for (const [id, char] of Object.entries(characters)) { + expect(char.greeting_zh, `character ${id} missing greeting_zh`).toBeTruthy() + } + }) +}) + +describe("Data file integrity — enemies.json", () => { + const enemies = JSON.parse( + readFileSync(resolve("reference/complete/data/enemies.json"), "utf-8") + ) + + it("all enemies have name_zh", () => { + for (const [id, enemy] of Object.entries(enemies)) { + expect(enemy.name_zh, `enemy ${id} missing name_zh`).toBeTruthy() + } + }) +}) + +// ============ S strings completeness ============ + +describe("S strings have both en and zh-TW", () => { + for (const [key, map] of Object.entries(S)) { + if (typeof map === "object" && map.en) { + it(`S.${key} has zh-TW translation`, () => { + expect(map["zh-TW"], `S.${key} missing zh-TW`).toBeTruthy() + }) + } + } +}) + +// ============ dirNames completeness ============ + +describe("dirNames have both en and zh-TW", () => { + for (const [dir, map] of Object.entries(dirNames)) { + it(`dirNames.${dir} has en and zh-TW`, () => { + expect(map.en).toBeTruthy() + expect(map["zh-TW"]).toBeTruthy() + }) + } +}) diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 0000000..d3c5606 --- /dev/null +++ b/tests/server.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest" +import http from "http" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const dungeonDir = path.resolve(__dirname, "../dungeon") + +const mimeTypes = { + ".html": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".json": "application/json", +} + +// Replicate the server routing logic for testing +function createTestServer() { + return http.createServer((req, res) => { + const urlPath = req.url.split("?")[0] + let filePath = urlPath === "/" ? "/index.html" : urlPath + filePath = path.join(dungeonDir, filePath) + + const ext = path.extname(filePath) + const contentType = mimeTypes[ext] || "text/plain" + + fs.readFile(filePath, (err, content) => { + if (err) { + res.writeHead(404) + res.end("Not found") + return + } + res.writeHead(200, { "Content-Type": contentType }) + res.end(content) + }) + }) +} + +function fetch(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + let data = "" + res.on("data", (chunk) => (data += chunk)) + res.on("end", () => resolve({ status: res.statusCode, body: data })) + }).on("error", reject) + }) +} + +describe("Server routing", () => { + let server + let baseUrl + + beforeAll(async () => { + server = createTestServer() + await new Promise((resolve) => { + server.listen(0, () => { + const port = server.address().port + baseUrl = `http://localhost:${port}` + resolve() + }) + }) + }) + + afterAll(async () => { + await new Promise((resolve) => server.close(resolve)) + }) + + it("serves index.html at /", async () => { + const res = await fetch(`${baseUrl}/`) + expect(res.status).toBe(200) + }) + + it("serves index.html at /?lang=zh-TW (query string stripped)", async () => { + const res = await fetch(`${baseUrl}/?lang=zh-TW`) + expect(res.status).toBe(200) + expect(res.body).toContain("html") + }) + + it("serves index.html at /?lang=en", async () => { + const res = await fetch(`${baseUrl}/?lang=en`) + expect(res.status).toBe(200) + }) + + it("returns 404 for nonexistent paths", async () => { + const res = await fetch(`${baseUrl}/nonexistent.html`) + expect(res.status).toBe(404) + }) +}) diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..34b1c9e --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + include: ["tests/**/*.test.js"], + }, +})