Feat global exception (#83)

- add global exceptions
- refactor quest models to reusable parts

other improvements:
- improve error handling
This commit is contained in:
CHEN, TAI-LIN authored and GitHub committed 2023-11-26 22:33:11 +08:00
commit f657f633b4
25 files changed
+231 -201

No files matched your search

+19
View File
@@ -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
+28
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
import { Schema } from 'mongoose'
import commandResponderSchema from './commandResponder.js'
const exceptionSchema = new Schema({})
exceptionSchema.add(commandResponderSchema)
export default exceptionSchema
+10
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
import { Schema } from 'mongoose'
const hintSchema = new Schema({
hints: {
type: [String],
required: true,
},
})
export default hintSchema
+6 -94
View File
@@ -1,98 +1,6 @@
import { model, Schema } from 'mongoose' import { model, Schema } from 'mongoose'
import stageSchema from './stage.js'
const responseSchema = new Schema({ import globalExceptionSchema from './globalException.js'
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,
},
})
const Quest = model( const Quest = model(
'Quest', 'Quest',
@@ -126,6 +34,10 @@ const Quest = model(
type: [stageSchema], type: [stageSchema],
required: true, required: true,
}, },
exceptions: {
type: [globalExceptionSchema],
required: true,
},
}) })
) )
+12
View File
@@ -0,0 +1,12 @@
import { Schema } from 'mongoose'
const requirementSchema = new Schema({
requirements: [
{
type: String,
required: true,
},
],
})
export default requirementSchema
+15
View File
@@ -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
+24
View File
@@ -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
+14
View File
@@ -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
+8 -8
View File
@@ -9,14 +9,6 @@ requirements:
- get-started - get-started
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始教程 task: 輸入 `echo start` 來開始教程
condition: condition:
@@ -73,3 +65,11 @@ stages:
- 你成功的在終端機上顯示了 `How are you?`。 - 你成功的在終端機上顯示了 `How are you?`。
- 相信你已經了解如何使用 `echo` 指令來顯示文字了。 - 相信你已經了解如何使用 `echo` 指令來顯示文字了。
- 之後如果要在終端機顯示文字,記得可以使用 `echo` 指令。 - 之後如果要在終端機顯示文字,記得可以使用 `echo` 指令。
exceptions:
- repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
-8
View File
@@ -9,14 +9,6 @@ requirements:
- create - create
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始 task: 輸入 `echo start` 來開始
condition: condition:
+1 -8
View File
@@ -10,14 +10,6 @@ requirements:
- read - read
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始 task: 輸入 `echo start` 來開始
condition: condition:
@@ -68,6 +60,7 @@ stages:
- ➤ 輸入 `cat hello.txt` 來查看 `hello.txt` 檔案的內容 - ➤ 輸入 `cat hello.txt` 來查看 `hello.txt` 檔案的內容
- id: cat - id: cat
task: 使用 `cat` 指令來查看 `hello.txt` 檔案的內容
condition: condition:
command: command:
- ^cat hello.txt$ - ^cat hello.txt$
+6
View File
@@ -0,0 +1,6 @@
exceptions:
- condition:
command:
- '`'
hints:
- '!! 不需要輸入上下引號 !!'
+1 -9
View File
@@ -10,14 +10,6 @@ requirements:
- get-started - get-started
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始教程 task: 輸入 `echo start` 來開始教程
condition: condition:
@@ -115,7 +107,7 @@ stages:
pwd: pwd:
- ^/home/commander/folder1$ - ^/home/commander/folder1$
command: command:
- ls - ^ls$
responses: responses:
- content: - content:
- 你現在可以看到 `folder1` 資料夾中有兩的檔案,分別是 `file1.txt` 跟 `file2.txt`。 - 你現在可以看到 `folder1` 資料夾中有兩的檔案,分別是 `file1.txt` 跟 `file2.txt`。
-16
View File
@@ -9,22 +9,6 @@ instruction: |
請你在終端機中的 `commander:~ $` 後面輸入 `echo start` 並按下 Enter 來開始教程。 請你在終端機中的 `commander:~ $` 後面輸入 `echo start` 並按下 Enter 來開始教程。
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: warning-02
repeatable: true
condition:
command:
- '`'
hints:
- '!! 不需要輸入上下引號 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始教程 task: 輸入 `echo start` 來開始教程
condition: condition:
-8
View File
@@ -9,14 +9,6 @@ requirements:
- create - create
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始 task: 輸入 `echo start` 來開始
condition: condition:
-8
View File
@@ -11,14 +11,6 @@ requirements:
- explore - explore
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始教程 task: 輸入 `echo start` 來開始教程
condition: condition:
-8
View File
@@ -9,14 +9,6 @@ requirements:
- path - path
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始教程 task: 輸入 `echo start` 來開始教程
condition: condition:
-8
View File
@@ -13,14 +13,6 @@ requirements:
- move - move
stages: stages:
- id: warning-01
repeatable: true
condition:
command:
- ' '
hints:
- '!! 請勿使用全形空格 !!'
- id: start - id: start
task: 輸入 `echo start` 來開始 task: 輸入 `echo start` 來開始
condition: condition:
+32 -15
View File
@@ -56,7 +56,7 @@ export default class CommandHandler extends SessionHandler {
this.session.graph = graph this.session.graph = graph
} catch (e) { } catch (e) {
logger.error('Update file graph failed', { logger.error('Update file graph failed', {
error: e, error: e.message,
session: this.session.id, session: this.session.id,
graph: this.session.graph, graph: this.session.graph,
discover: this.params.discover, discover: this.params.discover,
@@ -122,9 +122,9 @@ export default class CommandHandler extends SessionHandler {
async run() { async run() {
try { try {
this.quest = await Quest.findById(this.session.quest) this.quest = await Quest.findById(this.session.quest)
const stages = this.getStages() const stages = this.getUnlockedComponents(this.quest.stages)
if (stages.length === 0) { if (stages.length === 0) {
logger.warn('stage not found', this.session.tasks) logger.warn('stage not found', { session: this.session.id })
return {} return {}
} }
@@ -136,16 +136,7 @@ export default class CommandHandler extends SessionHandler {
) )
const stage = stages.find((_, i) => matches[i]) const stage = stages.find((_, i) => matches[i])
if (!stage) { if (stage) {
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)
}
}
return {}
}
const response = this.execute(stage) const response = this.execute(stage)
return { return {
@@ -153,10 +144,36 @@ export default class CommandHandler extends SessionHandler {
end: this.session.status === 'finished', end: this.session.status === 'finished',
...response, ...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
if (await this.isMatch(exception.condition)) {
return this.executeException(exception)
}
}
}
return {}
} catch (err) { } catch (err) {
logger.error('CommandHandler error', { logger.error('CommandHandler error', {
error: err, error: err.message,
session: this.session.id, session: this.session._id,
quest: this.quest._id,
command: this.commandInput, command: this.commandInput,
}) })
return {} return {}
+4 -6
View File
@@ -19,16 +19,14 @@ export default class SessionHandler {
return task ? task.completed : false return task ? task.completed : false
} }
getStages() { getUnlockedComponents(components) {
return this.quest.stages.filter( return components.filter((component) =>
(stage) => component.requirements.every((r) => this.isCompleted({ id: r }))
stage.requirements.every((r) => this.isCompleted({ id: r })) &&
(stage.repeatable || !this.isCompleted(stage))
) )
} }
getNewTasks() { getNewTasks() {
const stages = this.getStages() const stages = this.getUnlockedComponents(this.quest.stages)
return stages return stages
.filter((s) => s.task) .filter((s) => s.task)
.filter((s) => !this.session.tasks.some((t) => t.id === s.id)) .filter((s) => !this.session.tasks.some((t) => t.id === s.id))
+5
View File
@@ -38,7 +38,12 @@ async function main() {
await setupTest() await setupTest()
} }
try {
await loadAndUpdateQuests() await loadAndUpdateQuests()
} catch (err) {
logger.error('Failed to load quests', err)
return
}
expiryRemovalScheduler() expiryRemovalScheduler()
const file = await fs.readFile('./swagger.yaml', 'utf8') const file = await fs.readFile('./swagger.yaml', 'utf8')
+6 -2
View File
@@ -1,9 +1,13 @@
import winston from 'winston' import winston, { format } from 'winston'
import config from '../config.js' import config from '../config.js'
const logger = winston.createLogger({ const logger = winston.createLogger({
level: 'info', level: 'info',
format: winston.format.json(), format: format.combine(
format.errors({ stack: true }),
format.timestamp(),
format.json()
),
defaultMeta: { service: 'backend', timestamp: new Date().toISOString() }, defaultMeta: { service: 'backend', timestamp: new Date().toISOString() },
transports: [ transports: [
// //
+22 -3
View File
@@ -7,6 +7,15 @@ import logger from './logger.js'
const questDirectory = path.join(process.cwd(), '..', 'quests') 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) { async function mapQuests(callback) {
const questNames = await fs.readdir(questDirectory, { const questNames = await fs.readdir(questDirectory, {
withFileTypes: true, 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() { export async function loadAndUpdateQuests() {
await Quest.deleteMany({}) await Quest.deleteMany({})
const { exceptions } = await loadGlobalExceptions()
console.log(exceptions)
return mapQuests(async (id, questPath) => { return mapQuests(async (id, questPath) => {
const files = await fs.readdir(questPath) const files = await fs.readdir(questPath)
if (!files.includes('game.yml')) { if (!files.includes('game.yml')) {
throw new Error(`Quest ${id} is missing game.yml`) throw new QuestValidationError(`Missing game.yml`, id)
} }
try { try {
@@ -44,10 +61,12 @@ export async function loadAndUpdateQuests() {
image, image,
...questData, ...questData,
}) })
quest.exceptions.push(...exceptions)
await quest.save() await quest.save()
} catch (error) { } catch (error) {
logger.error(`Error parsing quest ${id}:`, error) console.error(error)
throw error logger.error(`Error parsing quest`, { id, error })
throw new QuestValidationError(`Error parsing quest`, id, error)
} }
}) })
} }