feat: parse quest using zod, basic

This commit is contained in:
Justin Lin committed 2025-04-16 17:37:42 +08:00
1 parent 963655c1f7
commit ed4d04b658
22 files changed
+390 -95

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'
export * from './quest.js'
export * from './quest/index.js'
export * from './session.js'
export const passwordPolicy = {
-6
View File
@@ -1,6 +0,0 @@
export interface QuestDetailResponse {
_id: string
title: string
instruction: string
requirements: string[]
}
@@ -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<typeof commandResponderSchema>
+12
View File
@@ -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<typeof conditionSchema>
@@ -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<typeof fileConditionSchema>
@@ -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<typeof globalExceptionSchema>
+5
View File
@@ -0,0 +1,5 @@
import { z } from 'zod'
export const hintSchema = z.array(z.string())
export type Hint = z.infer<typeof hintSchema>
+11
View File
@@ -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'
@@ -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<typeof questDetailResponseSchema>
+16
View File
@@ -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<typeof questSchema>
@@ -0,0 +1,5 @@
import { z } from 'zod'
export const requirementSchema = z.array(z.string()).default([])
export type Requirement = z.infer<typeof requirementSchema>
+8
View File
@@ -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<typeof responseSchema>
@@ -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<typeof stageExceptionSchema>
+18
View File
@@ -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<typeof stageSchema>
@@ -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()
})
})
+93
View File
@@ -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()
})
})
+22
View File
@@ -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()
})
})
+29
View File
@@ -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()
})
})
+2 -2
View File
@@ -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 }) => {
+2 -2
View File
@@ -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
+102
View File
@@ -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<string, Quest>()
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<any>
export const questManager = new QuestManager()
-84
View File
@@ -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<any>
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)
}