diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 2380b82..8d1f50e 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -export * from './quest.js' +export * from './quest/index.js' export * from './session.js' export const passwordPolicy = { diff --git a/packages/constants/src/quest.ts b/packages/constants/src/quest.ts deleted file mode 100644 index 69adbe0..0000000 --- a/packages/constants/src/quest.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface QuestDetailResponse { - _id: string - title: string - instruction: string - requirements: string[] -} diff --git a/packages/constants/src/quest/command-responder.ts b/packages/constants/src/quest/command-responder.ts new file mode 100644 index 0000000..bbba51a --- /dev/null +++ b/packages/constants/src/quest/command-responder.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { conditionSchema } from './condition.js' +import { responseSchema } from './response.js' + +export const commandResponderSchema = z.object({ + condition: conditionSchema.optional(), + responses: z.array(responseSchema), + hints: z.array(z.string()), +}) + +export type CommandResponder = z.infer diff --git a/packages/constants/src/quest/condition.ts b/packages/constants/src/quest/condition.ts new file mode 100644 index 0000000..a3405c0 --- /dev/null +++ b/packages/constants/src/quest/condition.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { fileConditionSchema } from './file-condition.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(), + files: z.array(fileConditionSchema).optional(), +}) + +export type Condition = z.infer diff --git a/packages/constants/src/quest/file-condition.ts b/packages/constants/src/quest/file-condition.ts new file mode 100644 index 0000000..e86fe24 --- /dev/null +++ b/packages/constants/src/quest/file-condition.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const fileConditionSchema = z.object({ + path: z.string(), + type: z.string().optional(), + exists: z.boolean().default(true), +}) + +export type FileCondition = z.infer diff --git a/packages/constants/src/quest/global-exception.ts b/packages/constants/src/quest/global-exception.ts new file mode 100644 index 0000000..92d1038 --- /dev/null +++ b/packages/constants/src/quest/global-exception.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { conditionSchema } from './condition.js' +import { responseSchema } from './response.js' + +export const globalExceptionSchema = z.object({ + condition: conditionSchema, + responses: z.array(responseSchema), + requirements: z.array(z.string()), +}) + +export type GlobalException = z.infer diff --git a/packages/constants/src/quest/hint.ts b/packages/constants/src/quest/hint.ts new file mode 100644 index 0000000..49f3852 --- /dev/null +++ b/packages/constants/src/quest/hint.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const hintSchema = z.array(z.string()) + +export type Hint = z.infer diff --git a/packages/constants/src/quest/index.ts b/packages/constants/src/quest/index.ts new file mode 100644 index 0000000..0dc6cf7 --- /dev/null +++ b/packages/constants/src/quest/index.ts @@ -0,0 +1,11 @@ +export * from './requirement.js' +export * from './hint.js' +export * from './file-condition.js' +export * from './condition.js' +export * from './response.js' +export * from './command-responder.js' +export * from './stage-exception.js' +export * from './stage.js' +export * from './global-exception.js' +export * from './quest.js' +export * from './quest-detail-response.js' diff --git a/packages/constants/src/quest/quest-detail-response.ts b/packages/constants/src/quest/quest-detail-response.ts new file mode 100644 index 0000000..ddc5e1c --- /dev/null +++ b/packages/constants/src/quest/quest-detail-response.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const questDetailResponseSchema = z.object({ + _id: z.string(), + title: z.string(), + instruction: z.string(), + requirements: z.array(z.string()), +}) + +export type QuestDetailResponse = z.infer diff --git a/packages/constants/src/quest/quest.ts b/packages/constants/src/quest/quest.ts new file mode 100644 index 0000000..e8d0440 --- /dev/null +++ b/packages/constants/src/quest/quest.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { stageSchema } from './stage.js' +import { globalExceptionSchema } from './global-exception.js' +import { requirementSchema } from './requirement.js' + +export const questSchema = z.object({ + id: z.string(), + title: z.string(), + image: z.string(), + instruction: z.string(), + requirements: requirementSchema, + stages: z.array(stageSchema), + exceptions: z.array(globalExceptionSchema).default([]), +}) + +export type Quest = z.infer diff --git a/packages/constants/src/quest/requirement.ts b/packages/constants/src/quest/requirement.ts new file mode 100644 index 0000000..f702582 --- /dev/null +++ b/packages/constants/src/quest/requirement.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const requirementSchema = z.array(z.string()).default([]) + +export type Requirement = z.infer diff --git a/packages/constants/src/quest/response.ts b/packages/constants/src/quest/response.ts new file mode 100644 index 0000000..79f72ce --- /dev/null +++ b/packages/constants/src/quest/response.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const responseSchema = z.object({ + type: z.enum(['narrative', 'dialogue']).default('narrative'), + content: z.string(), +}) + +export type Response = z.infer diff --git a/packages/constants/src/quest/stage-exception.ts b/packages/constants/src/quest/stage-exception.ts new file mode 100644 index 0000000..fd0415d --- /dev/null +++ b/packages/constants/src/quest/stage-exception.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' +import { conditionSchema } from './condition.js' +import { responseSchema } from './response.js' + +export const stageExceptionSchema = z.object({ + condition: conditionSchema, + responses: z.array(responseSchema), +}) + +export type StageException = z.infer diff --git a/packages/constants/src/quest/stage.ts b/packages/constants/src/quest/stage.ts new file mode 100644 index 0000000..841611e --- /dev/null +++ b/packages/constants/src/quest/stage.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { stageExceptionSchema } from './stage-exception.js' +import { conditionSchema } from './condition.js' +import { responseSchema } from './response.js' +import { hintSchema } from './hint.js' +import { requirementSchema } from './requirement.js' + +export const stageSchema = z.object({ + id: z.string(), + task: z.string(), + exceptions: z.array(stageExceptionSchema).default([]), + requirements: requirementSchema, + condition: conditionSchema, + responses: z.array(responseSchema), + hints: hintSchema.default([]), +}) + +export type Stage = z.infer diff --git a/packages/constants/tests/condition.test.ts b/packages/constants/tests/condition.test.ts new file mode 100644 index 0000000..60b388a --- /dev/null +++ b/packages/constants/tests/condition.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from '@jest/globals' +import { conditionSchema } from '../src/index.js' + +describe('parsing condition', () => { + it('should parse condition', () => { + const condition = { + command: ['^echo start$'], + output: ['^start$'], + } + const result = conditionSchema.safeParse(condition) + expect(result.error).toBeUndefined() + }) +}) diff --git a/packages/constants/tests/quest.test.ts b/packages/constants/tests/quest.test.ts new file mode 100644 index 0000000..bccc3b5 --- /dev/null +++ b/packages/constants/tests/quest.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from '@jest/globals' +import { questSchema } from '../src/index.js' + +describe('Test quest schema', () => { + it('should parse quest schema', () => { + const sampleQuest = { + id: 'get-started', + image: 'base', + title: '終端機的基本知識', + instruction: `歡迎來到 Linux Odyssey! + 在這個關卡中,你將會學到: + 1. 如何使用這個網站 + 2. 如何使用終端機`, + stages: [ + { + id: 'start', + task: '按下 `開始` ����開始', + condition: { + command: ['^echo start$'], + }, + responses: [ + { + content: `在畫面的中下方的區��是終端機,你可以在終端機��輸入指令來操作電��。 + 在終端機中,你所看到的 \`commander:~ $\` 叫做命令提示字串。 + ��令提示字串最後的��字號 \`$\` 叫命令提示符,你必須將指令輸入在它的後方。 + ��你輸入完指令並按下 Enter 之後,終端機會��行你輸入的指令,並且會從終端機的下一行開始輸出指令的��行結果。 + 直到你看到新的 \`commander:~ $\` 出現時,代表你可以輸入下一個指令了。 + ��在請你輸入 \`echo hello\` 來看看會發生什麼事。`, + }, + ], + hints: ['�� 在 `commander:~ $` ��面輸入 `echo hello` ��按下 Enter'], + }, + { + id: 'echo-hello', + task: '在終端機輸入 `echo hello`', + requirements: ['start'], + condition: { + command: ['^echo hello$'], + }, + responses: [ + { + content: `你應��可以看到終端機上��示了以下內容: + \`\`\` + commander:~ $ echo hello + hello + \`\`\` + 第一行的 \`commander:~ $\` 是命令提示字串,接在後方的 \`echo hello\` 是你輸入的指令。 + 第二行的 \`hello\` ��是終端機的輸出結果。 + 你現在知道了如何在終端機上輸入指令,以及查看終端機輸出的位置。 + ��在請輸入 \`echo next\` 來��續下一個步��。`, + }, + ], + hints: ['�� ���入 `echo next` ���續下一個步��'], + }, + { + id: 'echo-next', + task: '在終端機輸入 `echo next`', + requirements: ['echo-hello'], + condition: { + command: ['^echo next$'], + }, + responses: [ + { + content: `\`Ctrl + C\` 在終端機中是一個很重要的快捷鍵,它可以用來中��正在��行的指令,並產生一個新的命令提示字串,讓你可以��續輸入指令。 + 如果指令��行很久沒有反應,或你因為其他原因想要中��指令,你可以按下 \`Ctrl + C\` 來中��指令。 + 又或者你輸入了錯��的指令,����得慢慢��除,你也可以按下 \`Ctrl + C\` 來產生新的命令提示字串重新輸入指令。 + 如果你已經理解了��才的說明,請輸入 \`echo finish\` 來結束這一關。`, + }, + ], + hints: [ + '�� ��下 `Ctrl + C` 能��中��正在��行的指令', + '�� ��下 `Ctrl + C` 能��產生新的命令提示字串', + '�� ���入 `echo finish` 來結束這一關', + ], + }, + { + id: 'END', + task: '結束關卡', + requirements: ['echo-next'], + condition: { + command: ['^echo finish$'], + }, + responses: [ + { + content: '恭喜你完成了這個關卡!', + }, + ], + }, + ], + } + expect(questSchema.safeParse(sampleQuest).error).toBeUndefined() + }) +}) diff --git a/packages/constants/tests/response.test.ts b/packages/constants/tests/response.test.ts new file mode 100644 index 0000000..eb3eb15 --- /dev/null +++ b/packages/constants/tests/response.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from '@jest/globals' +import { responseSchema } from '../src/quest/response.js' + +describe('parsing response', () => { + it('should parse valid response', () => { + const response = { + type: 'narrative', + content: 'This is a response content', + } + const result = responseSchema.safeParse(response) + expect(result.error).toBeUndefined() + }) + + it('should fail to parse invalid response', () => { + const response = { + type: 'dialogue', + content: 'This is a response content', + } + const result = responseSchema.safeParse(response) + expect(result.error).toBeUndefined() + }) +}) diff --git a/packages/constants/tests/stage.test.ts b/packages/constants/tests/stage.test.ts new file mode 100644 index 0000000..60b8297 --- /dev/null +++ b/packages/constants/tests/stage.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from '@jest/globals' +import { stageSchema } from '../src/quest/stage.js' + +describe('parsing stage', () => { + it('should parse valid stage', () => { + const stage = { + id: 'start', + task: '按下 `開始` ����開始', + requirements: ['start'], + condition: { + command: ['^echo start$'], + }, + responses: [ + { + type: 'text', + content: `在畫面的中下方的區��是終端機,你可以在終端機��輸入指令來操作電��。 + 在終端機中,你所看到的 \`commander:~ $\` 叫做命令提示字串。 + ��令提示字串最後的��字號 \`$\` 叫命令提示符,你必須將指令輸入在它的後方。 + ��你輸入完指令並按下 Enter 之後,終端機會��行你輸入的指令,並且會從終端機的下一行開始輸出指令的��行結果。 + 直到你看到新的 \`commander:~ $\` 出現時,代表你可以輸入下一個指令了。 + ��在請你輸入 \`echo hello\` 來看看會發生什麼事。`, + }, + ], + hints: ['�� 在 `commander:~ $` ��面輸入 `echo hello` ��按下 Enter'], + } + const result = stageSchema.safeParse(stage) + expect(result.error).toBeUndefined() + }) +}) diff --git a/server/src/cli/build.ts b/server/src/cli/build.ts index 00e1906..86509e1 100644 --- a/server/src/cli/build.ts +++ b/server/src/cli/build.ts @@ -1,9 +1,9 @@ -import { getQuestDockerfiles } from '../utils/quest.js' +import { questManager } from '../models/quest.js' import { buildQuestImage } from '../containers/docker.js' import logger from '../utils/logger.js' async function buildImages() { - const quests = await getQuestDockerfiles() + const quests = await questManager.getQuestDockerfiles() logger.info(`Building ${quests.length} images`) return Promise.all( quests.map(({ id, questPath }) => { diff --git a/server/src/index.ts b/server/src/index.ts index d287884..3ac09d0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,7 +10,7 @@ import connectDB from '@linux-odyssey/models' import './auth/passport.js' import socketServer from './api/socket.js' import apiRouter from './api/routes/index.js' -import { loadAndUpdateQuests } from './utils/quest.js' +import { questManager } from './models/quest.js' import config from './config.js' import errorHandler from './middleware/error.js' import { globalRateLimit } from './middleware/rateLimiter.js' @@ -42,7 +42,7 @@ async function main() { } try { - await loadAndUpdateQuests() + await questManager.loadAndUpdateQuests() } catch (err) { logger.error('Failed to load quests', err) return diff --git a/server/src/models/quest.ts b/server/src/models/quest.ts new file mode 100644 index 0000000..3a80250 --- /dev/null +++ b/server/src/models/quest.ts @@ -0,0 +1,102 @@ +/* eslint-disable max-classes-per-file */ +import fs from 'fs/promises' +import path from 'path' +import yaml from 'yaml' + +import { Quest, questSchema } from '@linux-odyssey/constants' +import logger from '../utils/logger.js' + +class QuestValidationError extends Error { + questId: string + error: any + + constructor(message: string, questId: string, error: any) { + super(message) + this.name = 'QuestValidationError' + this.questId = questId + this.error = error + } +} + +class QuestManager { + private quests = new Map() + private questDirectory = path.join(process.cwd(), '..', 'quests') + + get(id: string) { + return this.quests.get(id) + } + + getAll() { + return Array.from(this.quests.values()) + } + + private async mapQuests(callback: QuestCallback) { + const questNames = await fs.readdir(this.questDirectory, { + withFileTypes: true, + }) + + return Promise.all( + questNames + .filter( + (dirent) => dirent.isDirectory() && !dirent.name.startsWith('.') + ) + .map((dirent) => { + const id = dirent.name + const questPath = path.join(this.questDirectory, id) + return callback(id, questPath) + }) + ) + } + + private async loadGlobalExceptions() { + const exceptionsPath = path.join(this.questDirectory, 'exceptions.yml') + const body = await fs.readFile(exceptionsPath, 'utf-8') + return yaml.parse(body) + } + + async loadAndUpdateQuests() { + const { exceptions } = await this.loadGlobalExceptions() + return this.mapQuests(async (id, questPath) => { + const files = await fs.readdir(questPath) + if (!files.includes('game.yml')) { + throw new QuestValidationError(`Missing game.yml`, id, null) + } + + try { + 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' + const quest = questSchema.parse({ + _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) + } + }) + } + + async getQuestDockerfiles() { + const questlist = await this.mapQuests(async (id, questPath) => { + const files = await fs.readdir(questPath) + return files.includes('Dockerfile') ? { id, questPath } : null + }) + return questlist.filter((quest) => quest !== null) + } +} + +// eslint-disable-next-line no-unused-vars +type QuestCallback = (id: string, questPath: string) => Promise + +export const questManager = new QuestManager() diff --git a/server/src/utils/quest.ts b/server/src/utils/quest.ts deleted file mode 100644 index 9fda0b6..0000000 --- a/server/src/utils/quest.ts +++ /dev/null @@ -1,84 +0,0 @@ -import fs from 'fs/promises' -import path from 'path' -import yaml from 'yaml' - -import { Quest } from '@linux-odyssey/models' -import logger from './logger.js' - -const questDirectory = path.join(process.cwd(), '..', 'quests') - -class QuestValidationError extends Error { - questId: string - error: any - - constructor(message: string, questId: string, error: any) { - super(message) - this.name = 'QuestValidationError' - this.questId = questId - this.error = error - } -} - -// eslint-disable-next-line no-unused-vars -type QuestCallback = (id: string, questPath: string) => Promise - -async function mapQuests(callback: QuestCallback) { - const questNames = await fs.readdir(questDirectory, { - withFileTypes: true, - }) - - return Promise.all( - questNames - .filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith('.')) - .map((dirent) => { - const id = dirent.name - const questPath = path.join(questDirectory, id) - return callback(id, questPath) - }) - ) -} - -async function loadGlobalExceptions() { - const exceptionsPath = path.join(questDirectory, 'exceptions.yml') - const body = await fs.readFile(exceptionsPath, 'utf-8') - return yaml.parse(body) -} - -export async function loadAndUpdateQuests() { - await Quest.deleteMany({}) - const { exceptions } = await loadGlobalExceptions() - return mapQuests(async (id, questPath) => { - const files = await fs.readdir(questPath) - if (!files.includes('game.yml')) { - throw new QuestValidationError(`Missing game.yml`, id, null) - } - - try { - 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' - const quest = new Quest({ - _id: id, - image, - ...questData, - }) - quest.exceptions.push(...exceptions) - await quest.save() - } catch (error) { - logger.error(`Error parsing quest`, { id, error }) - throw new QuestValidationError(`Error parsing quest`, id, error) - } - }) -} - -export async function getQuestDockerfiles() { - const quests = await mapQuests(async (id, questPath) => { - const files = await fs.readdir(questPath) - return files.includes('Dockerfile') ? { id, questPath } : null - }) - return quests.filter((quest) => quest !== null) -}