diff --git a/packages/models/commandResponder.js b/packages/models/commandResponder.js new file mode 100644 index 0000000..0078cb7 --- /dev/null +++ b/packages/models/commandResponder.js @@ -0,0 +1,19 @@ +import { Schema } from 'mongoose' +import conditionSchema from './condition.js' +import responseSchema from './response.js' +import hintSchema from './hint.js' + +const commandResponderSchema = new Schema({ + condition: { + type: conditionSchema, + required: false, + }, + responses: { + type: [responseSchema], + required: true, + }, +}) + +commandResponderSchema.add(hintSchema) + +export default commandResponderSchema diff --git a/packages/models/condition.js b/packages/models/condition.js new file mode 100644 index 0000000..8f236af --- /dev/null +++ b/packages/models/condition.js @@ -0,0 +1,28 @@ +import { Schema } from 'mongoose' + +const fileConditionSchema = new Schema({ + path: { + type: String, + required: true, + }, + type: String, + exists: { + type: Boolean, + default: true, + }, +}) + +const conditionSchema = new Schema({ + command: [String], + output: [String], + error: [String], + pwd: [String], + files: [fileConditionSchema], +}) + +conditionSchema.add({ + or: [conditionSchema], + not: conditionSchema, +}) + +export default conditionSchema diff --git a/packages/models/exception.js b/packages/models/exception.js new file mode 100644 index 0000000..60e90b6 --- /dev/null +++ b/packages/models/exception.js @@ -0,0 +1,8 @@ +import { Schema } from 'mongoose' +import commandResponderSchema from './commandResponder.js' + +const exceptionSchema = new Schema({}) + +exceptionSchema.add(commandResponderSchema) + +export default exceptionSchema diff --git a/packages/models/globalException.js b/packages/models/globalException.js new file mode 100644 index 0000000..5fd191d --- /dev/null +++ b/packages/models/globalException.js @@ -0,0 +1,10 @@ +import { Schema } from 'mongoose' +import exceptionSchema from './exception.js' +import requirementSchema from './requirement.js' + +const globalExceptionSchema = new Schema({}) + +globalExceptionSchema.add(exceptionSchema) +globalExceptionSchema.add(requirementSchema) + +export default globalExceptionSchema diff --git a/packages/models/hint.js b/packages/models/hint.js new file mode 100644 index 0000000..a46eab9 --- /dev/null +++ b/packages/models/hint.js @@ -0,0 +1,10 @@ +import { Schema } from 'mongoose' + +const hintSchema = new Schema({ + hints: { + type: [String], + required: true, + }, +}) + +export default hintSchema diff --git a/packages/models/quest.js b/packages/models/quest.js index 2f808cb..69f4c80 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -1,98 +1,6 @@ import { model, Schema } from 'mongoose' - -const responseSchema = new Schema({ - type: { - type: String, - enum: ['narrative', 'dialogue'], - default: 'dialogue', - required: true, - }, - content: [String], - speaker: { type: String, default: 'Ada' }, - color: String, -}) - -const fileConditionSchema = new Schema({ - path: { - type: String, - required: true, - }, - type: String, - exists: { - type: Boolean, - default: true, - }, -}) - -const conditionSchema = new Schema({ - command: [String], - output: [String], - error: [String], - pwd: [String], - files: [fileConditionSchema], -}) - -conditionSchema.add({ - or: [conditionSchema], - not: conditionSchema, -}) - -const exceptionSchema = new Schema({ - condition: { - type: conditionSchema, - required: false, - }, - catchAll: { - type: Boolean, - required: true, - default: false, - }, - responses: { - type: [responseSchema], - required: true, - }, - hints: { - type: [String], - required: true, - }, -}) - -const stageSchema = new Schema({ - id: { - type: String, - required: true, - }, - task: { - type: String, - }, - requirements: [ - { - type: String, - required: true, - }, - ], - repeatable: { - type: Boolean, - required: true, - default: false, - }, - condition: { - type: conditionSchema, - required: true, - }, - responses: { - type: [responseSchema], - required: true, - }, - hints: { - type: [String], - required: true, - }, - exceptions: { - type: [exceptionSchema], - required: true, - }, -}) +import stageSchema from './stage.js' +import globalExceptionSchema from './globalException.js' const Quest = model( 'Quest', @@ -126,6 +34,10 @@ const Quest = model( type: [stageSchema], required: true, }, + exceptions: { + type: [globalExceptionSchema], + required: true, + }, }) ) diff --git a/packages/models/requirement.js b/packages/models/requirement.js new file mode 100644 index 0000000..9edf61e --- /dev/null +++ b/packages/models/requirement.js @@ -0,0 +1,12 @@ +import { Schema } from 'mongoose' + +const requirementSchema = new Schema({ + requirements: [ + { + type: String, + required: true, + }, + ], +}) + +export default requirementSchema diff --git a/packages/models/response.js b/packages/models/response.js new file mode 100644 index 0000000..a2bf5b0 --- /dev/null +++ b/packages/models/response.js @@ -0,0 +1,15 @@ +import { Schema } from 'mongoose' + +const responseSchema = new Schema({ + type: { + type: String, + enum: ['narrative', 'dialogue'], + default: 'dialogue', + required: true, + }, + content: [String], + speaker: { type: String, default: 'Ada' }, + color: String, +}) + +export default responseSchema diff --git a/packages/models/stage.js b/packages/models/stage.js new file mode 100644 index 0000000..73c6070 --- /dev/null +++ b/packages/models/stage.js @@ -0,0 +1,24 @@ +import { Schema } from 'mongoose' +import commandResponderSchema from './commandResponder.js' +import stageExceptionSchema from './stageException.js' +import requirementSchema from './requirement.js' + +const stageSchema = new Schema({ + id: { + type: String, + required: true, + }, + task: { + type: String, + required: true, + }, + exceptions: { + type: [stageExceptionSchema], + required: true, + }, +}) + +stageSchema.add(commandResponderSchema) +stageSchema.add(requirementSchema) + +export default stageSchema diff --git a/packages/models/stageException.js b/packages/models/stageException.js new file mode 100644 index 0000000..f11d7d7 --- /dev/null +++ b/packages/models/stageException.js @@ -0,0 +1,14 @@ +import { Schema } from 'mongoose' +import exceptionSchema from './exception.js' + +const stageExceptionSchema = new Schema({ + catchAll: { + type: Boolean, + required: true, + default: false, + }, +}) + +stageExceptionSchema.add(exceptionSchema) + +export default stageExceptionSchema diff --git a/quests/cast/game.yml b/quests/cast/game.yml index 4c6f229..3482ee4 100644 --- a/quests/cast/game.yml +++ b/quests/cast/game.yml @@ -9,14 +9,6 @@ requirements: - get-started stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始教程 condition: @@ -73,3 +65,11 @@ stages: - 你成功的在終端機上顯示了 `How are you?`。 - 相信你已經了解如何使用 `echo` 指令來顯示文字了。 - 之後如果要在終端機顯示文字,記得可以使用 `echo` 指令。 + +exceptions: + - repeatable: true + condition: + command: + - ' ' + hints: + - '!! 請勿使用全形空格 !!' diff --git a/quests/copy/game.yml b/quests/copy/game.yml index d48d0e8..15eda11 100644 --- a/quests/copy/game.yml +++ b/quests/copy/game.yml @@ -9,14 +9,6 @@ requirements: - create stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始 condition: diff --git a/quests/create/game.yml b/quests/create/game.yml index 41bcb13..647b41e 100644 --- a/quests/create/game.yml +++ b/quests/create/game.yml @@ -10,14 +10,6 @@ requirements: - read stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始 condition: @@ -68,6 +60,7 @@ stages: - ➤ 輸入 `cat hello.txt` 來查看 `hello.txt` 檔案的內容 - id: cat + task: 使用 `cat` 指令來查看 `hello.txt` 檔案的內容 condition: command: - ^cat hello.txt$ diff --git a/quests/exceptions.yml b/quests/exceptions.yml new file mode 100644 index 0000000..6ffe5f5 --- /dev/null +++ b/quests/exceptions.yml @@ -0,0 +1,6 @@ +exceptions: + - condition: + command: + - '`' + hints: + - '!! 不需要輸入上下引號 !!' diff --git a/quests/explore/game.yml b/quests/explore/game.yml index 707c39b..5aa292e 100644 --- a/quests/explore/game.yml +++ b/quests/explore/game.yml @@ -10,14 +10,6 @@ requirements: - get-started stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始教程 condition: @@ -115,7 +107,7 @@ stages: pwd: - ^/home/commander/folder1$ command: - - ls + - ^ls$ responses: - content: - 你現在可以看到 `folder1` 資料夾中有兩的檔案,分別是 `file1.txt` 跟 `file2.txt`。 diff --git a/quests/get-started/game.yml b/quests/get-started/game.yml index 3d09d66..9fd00fb 100644 --- a/quests/get-started/game.yml +++ b/quests/get-started/game.yml @@ -9,22 +9,6 @@ instruction: | 請你在終端機中的 `commander:~ $` 後面輸入 `echo start` 並按下 Enter 來開始教程。 stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - - id: warning-02 - repeatable: true - condition: - command: - - '`' - hints: - - '!! 不需要輸入上下引號 !!' - - id: start task: 輸入 `echo start` 來開始教程 condition: diff --git a/quests/move/game.yml b/quests/move/game.yml index f694ae6..413bc35 100644 --- a/quests/move/game.yml +++ b/quests/move/game.yml @@ -9,14 +9,6 @@ requirements: - create stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始 condition: diff --git a/quests/path/game.yml b/quests/path/game.yml index 0aedf6c..dca6f75 100644 --- a/quests/path/game.yml +++ b/quests/path/game.yml @@ -11,14 +11,6 @@ requirements: - explore stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始教程 condition: diff --git a/quests/read/game.yml b/quests/read/game.yml index ad317bc..6407d17 100644 --- a/quests/read/game.yml +++ b/quests/read/game.yml @@ -9,14 +9,6 @@ requirements: - path stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始教程 condition: diff --git a/quests/remove/game.yml b/quests/remove/game.yml index 4560ba3..d6532fc 100644 --- a/quests/remove/game.yml +++ b/quests/remove/game.yml @@ -13,14 +13,6 @@ requirements: - move stages: - - id: warning-01 - repeatable: true - condition: - command: - - ' ' - hints: - - '!! 請勿使用全形空格 !!' - - id: start task: 輸入 `echo start` 來開始 condition: diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index f45a839..4b271ff 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -56,7 +56,7 @@ export default class CommandHandler extends SessionHandler { this.session.graph = graph } catch (e) { logger.error('Update file graph failed', { - error: e, + error: e.message, session: this.session.id, graph: this.session.graph, discover: this.params.discover, @@ -122,9 +122,9 @@ export default class CommandHandler extends SessionHandler { async run() { try { this.quest = await Quest.findById(this.session.quest) - const stages = this.getStages() + const stages = this.getUnlockedComponents(this.quest.stages) if (stages.length === 0) { - logger.warn('stage not found', this.session.tasks) + logger.warn('stage not found', { session: this.session.id }) return {} } @@ -136,27 +136,44 @@ export default class CommandHandler extends SessionHandler { ) const stage = stages.find((_, i) => matches[i]) - if (!stage) { - for (const s of stages) { + if (stage) { + const response = this.execute(stage) + + return { + stage: stage.id, + end: this.session.status === 'finished', + ...response, + } + } + + // stage exception + for (const s of stages) { + // eslint-disable-next-line no-await-in-loop + const exception = await this.checkException(s) + if (exception) { + return this.executeException(exception) + } + } + + // global exception + if (this.quest.exceptions.length > 0) { + const globalExceptions = this.getUnlockedComponents( + this.quest.exceptions + ) + for (const exception of globalExceptions) { // eslint-disable-next-line no-await-in-loop - const exception = await this.checkException(s) - if (exception) { + if (await this.isMatch(exception.condition)) { return this.executeException(exception) } } - return {} } - const response = this.execute(stage) - return { - stage: stage.id, - end: this.session.status === 'finished', - ...response, - } + return {} } catch (err) { logger.error('CommandHandler error', { - error: err, - session: this.session.id, + error: err.message, + session: this.session._id, + quest: this.quest._id, command: this.commandInput, }) return {} diff --git a/server/src/game/sessionHandler.js b/server/src/game/sessionHandler.js index 2312c4c..a9b89f0 100644 --- a/server/src/game/sessionHandler.js +++ b/server/src/game/sessionHandler.js @@ -19,16 +19,14 @@ export default class SessionHandler { return task ? task.completed : false } - getStages() { - return this.quest.stages.filter( - (stage) => - stage.requirements.every((r) => this.isCompleted({ id: r })) && - (stage.repeatable || !this.isCompleted(stage)) + getUnlockedComponents(components) { + return components.filter((component) => + component.requirements.every((r) => this.isCompleted({ id: r })) ) } getNewTasks() { - const stages = this.getStages() + const stages = this.getUnlockedComponents(this.quest.stages) return stages .filter((s) => s.task) .filter((s) => !this.session.tasks.some((t) => t.id === s.id)) diff --git a/server/src/index.js b/server/src/index.js index 0004af7..67c102f 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -38,7 +38,12 @@ async function main() { await setupTest() } - await loadAndUpdateQuests() + try { + await loadAndUpdateQuests() + } catch (err) { + logger.error('Failed to load quests', err) + return + } expiryRemovalScheduler() const file = await fs.readFile('./swagger.yaml', 'utf8') diff --git a/server/src/utils/logger.js b/server/src/utils/logger.js index a540c27..2eaf76e 100644 --- a/server/src/utils/logger.js +++ b/server/src/utils/logger.js @@ -1,9 +1,13 @@ -import winston from 'winston' +import winston, { format } from 'winston' import config from '../config.js' const logger = winston.createLogger({ level: 'info', - format: winston.format.json(), + format: format.combine( + format.errors({ stack: true }), + format.timestamp(), + format.json() + ), defaultMeta: { service: 'backend', timestamp: new Date().toISOString() }, transports: [ // diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js index 056769e..dc96f73 100644 --- a/server/src/utils/quest.js +++ b/server/src/utils/quest.js @@ -7,6 +7,15 @@ import logger from './logger.js' const questDirectory = path.join(process.cwd(), '..', 'quests') +class QuestValidationError extends Error { + constructor(message, questId, error) { + super(message) + this.name = 'QuestValidationError' + this.questId = questId + this.error = error + } +} + async function mapQuests(callback) { const questNames = await fs.readdir(questDirectory, { withFileTypes: true, @@ -23,12 +32,20 @@ async function mapQuests(callback) { ) } +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() + console.log(exceptions) return mapQuests(async (id, questPath) => { const files = await fs.readdir(questPath) if (!files.includes('game.yml')) { - throw new Error(`Quest ${id} is missing game.yml`) + throw new QuestValidationError(`Missing game.yml`, id) } try { @@ -44,10 +61,12 @@ export async function loadAndUpdateQuests() { image, ...questData, }) + quest.exceptions.push(...exceptions) await quest.save() } catch (error) { - logger.error(`Error parsing quest ${id}:`, error) - throw error + console.error(error) + logger.error(`Error parsing quest`, { id, error }) + throw new QuestValidationError(`Error parsing quest`, id, error) } }) }