From 61d1871e951c635672d0e637b66b6f3291545d17 Mon Sep 17 00:00:00 2001 From: Justin Lin Date: Wed, 16 Apr 2025 19:06:47 +0800 Subject: [PATCH] feat: parse all quest --- packages/constants/src/quest/condition.ts | 9 ++-- packages/constants/src/quest/response.ts | 3 +- packages/constants/src/quest/stage.ts | 2 +- packages/constants/src/utils.ts | 1 + packages/constants/tests/parseAll.test.ts | 52 +++++++++++++++++++++++ quests/execute/game.yml | 12 +++--- quests/get-started/game.yml | 3 ++ quests/read/game.yml | 7 +-- server/src/models/quest.ts | 6 +-- 9 files changed, 77 insertions(+), 18 deletions(-) create mode 100644 packages/constants/src/utils.ts create mode 100644 packages/constants/tests/parseAll.test.ts diff --git a/packages/constants/src/quest/condition.ts b/packages/constants/src/quest/condition.ts index a3405c0..cbeebf8 100644 --- a/packages/constants/src/quest/condition.ts +++ b/packages/constants/src/quest/condition.ts @@ -1,11 +1,12 @@ import { z } from 'zod' import { fileConditionSchema } from './file-condition.js' +import { toArray } from '../utils.js' export const conditionSchema = z.object({ - command: z.array(z.string()).optional(), - output: z.array(z.string()).optional(), - error: z.array(z.string()).optional(), - pwd: z.array(z.string()).optional(), + command: z.preprocess(toArray, z.array(z.string()).optional()), + output: z.preprocess(toArray, z.array(z.string()).optional()), + error: z.preprocess(toArray, z.array(z.string()).optional()), + pwd: z.preprocess(toArray, z.array(z.string()).optional()), files: z.array(fileConditionSchema).optional(), }) diff --git a/packages/constants/src/quest/response.ts b/packages/constants/src/quest/response.ts index 79f72ce..f02ebeb 100644 --- a/packages/constants/src/quest/response.ts +++ b/packages/constants/src/quest/response.ts @@ -1,8 +1,9 @@ import { z } from 'zod' +import { toArray } from '../utils.js' export const responseSchema = z.object({ type: z.enum(['narrative', 'dialogue']).default('narrative'), - content: z.string(), + content: z.preprocess(toArray, z.array(z.string())), }) export type Response = z.infer diff --git a/packages/constants/src/quest/stage.ts b/packages/constants/src/quest/stage.ts index 841611e..616e37e 100644 --- a/packages/constants/src/quest/stage.ts +++ b/packages/constants/src/quest/stage.ts @@ -8,7 +8,7 @@ import { requirementSchema } from './requirement.js' export const stageSchema = z.object({ id: z.string(), task: z.string(), - exceptions: z.array(stageExceptionSchema).default([]), + exceptions: stageExceptionSchema.array().default([]), requirements: requirementSchema, condition: conditionSchema, responses: z.array(responseSchema), diff --git a/packages/constants/src/utils.ts b/packages/constants/src/utils.ts new file mode 100644 index 0000000..b84c536 --- /dev/null +++ b/packages/constants/src/utils.ts @@ -0,0 +1 @@ +export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val) diff --git a/packages/constants/tests/parseAll.test.ts b/packages/constants/tests/parseAll.test.ts new file mode 100644 index 0000000..14dc818 --- /dev/null +++ b/packages/constants/tests/parseAll.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll } from '@jest/globals' +import fs from 'fs/promises' +import path from 'path' +import yaml from 'yaml' +import { questSchema } from '../src/quest/quest.js' + +describe('Parse all quests', () => { + let quests: any[] = [] + + test('should parse all quests', async () => { + const questDirectory = path.join(process.cwd(), 'quests') + + const loadQuests = async () => { + const questNames = await fs.readdir(questDirectory, { + withFileTypes: true, + }) + + return Promise.all( + questNames + .filter( + (dirent) => dirent.isDirectory() && !dirent.name.startsWith('.') + ) + .map(async (dirent) => { + const id = dirent.name + const questPath = path.join(questDirectory, id) + const files = await fs.readdir(questPath) + if (!files.includes('game.yml')) { + throw new Error(`Missing game.yml in quest ${id}`) + } + + const body = await fs.readFile( + path.join(questPath, 'game.yml'), + 'utf-8' + ) + const questData = yaml.parse(body, { merge: true }) + const image = files.includes('Dockerfile') ? id : 'base' + return { + id, + image, + ...questData, + } + }) + ) + } + + quests = await loadQuests() + quests.forEach((quest) => { + console.log(`Quest ID: ${quest.id}`) + expect(questSchema.safeParse(quest).error).toBeUndefined() + }) + }) +}) diff --git a/quests/execute/game.yml b/quests/execute/game.yml index aeefe33..e898697 100644 --- a/quests/execute/game.yml +++ b/quests/execute/game.yml @@ -179,9 +179,9 @@ stages: 你看到了終端機輸出 `You are lucky!` 這個字串。 恭喜你,你已經完成了這個課程! exceptions: - condition: - command: ^echo help$ - responses: - - content: | - 你需要先移動到 `commands` 資料夾,再使用相對路徑執行 `lucky` 檔案。 - 請先輸入 `cd commands` 來移動到 `commands` 資料夾,再使用相對路徑 `./lucky` 來執行 `lucky` 檔案。 + - condition: + command: ^echo help$ + responses: + - content: | + 你需要先移動到 `commands` 資料夾,再使用相對路徑執行 `lucky` 檔案。 + 請先輸入 `cd commands` 來移動到 `commands` 資料夾,再使用相對路徑 `./lucky` 來執行 `lucky` 檔案。 diff --git a/quests/get-started/game.yml b/quests/get-started/game.yml index 6e489da..def7e14 100644 --- a/quests/get-started/game.yml +++ b/quests/get-started/game.yml @@ -68,3 +68,6 @@ stages: condition: command: - ^echo finish$ + responses: + - content: | + 恭喜你,你已經完成了這個關卡! diff --git a/quests/read/game.yml b/quests/read/game.yml index 525c494..2c329b2 100644 --- a/quests/read/game.yml +++ b/quests/read/game.yml @@ -69,9 +69,10 @@ stages: not: pwd: - ^/home/commander$ - content: | - 請留在 `/home/commander` 資料夾中。 - 請輸入 `cd ~` 回到 `/home/commander` 資料夾。 + responses: + - content: | + 請留在 `/home/commander` 資料夾中。 + 請輸入 `cd ~` 回到 `/home/commander` 資料夾。 responses: - content: | 你現在知道 `folder` 資料夾的內容了。 diff --git a/server/src/models/quest.ts b/server/src/models/quest.ts index 3a80250..80ea69a 100644 --- a/server/src/models/quest.ts +++ b/server/src/models/quest.ts @@ -74,15 +74,15 @@ class QuestManager { const image = files.includes('Dockerfile') ? id : 'base' const quest = questSchema.parse({ - _id: id, + id, image, ...questData, }) quest.exceptions.push(...exceptions) this.quests.set(id, quest) } catch (error) { - logger.error(`Error parsing quest`, { id, error }) - throw new QuestValidationError(`Error parsing quest`, id, error) + logger.error(`Error parsing quest ${id}`, { error }) + throw new QuestValidationError(`Error parsing quest ${id}`, id, error) } }) }