From e16b328892705b462bc6f3228a1adf93685bf7c1 Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 30 Aug 2023 18:05:11 +0800 Subject: [PATCH 01/11] feat: refactor stage to multiple route design --- quests/helloworld/game.yml | 18 ++++--- .../src/api/controllers/commandController.js | 2 +- server/src/game/commandHandler.js | 52 ++++++++++++------- server/src/models/quest.js | 6 +++ server/src/models/session.js | 3 +- server/swagger.yaml | 2 +- 6 files changed, 51 insertions(+), 32 deletions(-) diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index 2dca81c..fcc4d7f 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -8,7 +8,7 @@ stages: - id: helloworld condition: command: - - echo Hello World! + - echo Hello World responses: - type: narrative content: @@ -22,12 +22,13 @@ stages: - 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 - 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。 - 首先,你必須要學會如何使用指令。 - - 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。 - - 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。 + - 你要學習的第一個指令,是「發現」的指令:`ls`。ls 可以用來查看一個目錄中有哪些檔案。 + - 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。 hints: - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 - next: ls - id: ls + requirements: + - helloworld condition: command: - ls @@ -47,8 +48,9 @@ stages: hints: - 使用 `cat [檔案名稱]` 指令來查看檔案內容。 - 使用 `Tab` 鍵來取得指令提示。 - next: cat - id: cat + requirements: + - ls condition: command: - cat forgotten_scroll.txt @@ -58,8 +60,9 @@ stages: content: - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用你已經知道的 `echo` 指令來詠唱咒語。 - next: echo - - id: echo + - id: END + requirements: + - cat condition: command: - echo Unix, rise and guide me! @@ -72,4 +75,3 @@ stages: content: - 恭喜您,我的指令官,您已成功解除了封印。 - 請跟著在接下了關卡,一步步取回 Linux 之根吧! - next: END diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index acfdeb2..def7b1b 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -36,7 +36,6 @@ export async function newCommand(req, res) { } session.lastActivityAt = new Date() - await session.save() const c = new Command({ session, @@ -54,4 +53,5 @@ export async function newCommand(req, res) { console.log(response) res.status(201).json(response) + await session.save() } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index 62eb626..e1aa818 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -50,20 +50,15 @@ export default class CommandHandler { const graph = new FileGraph(this.session.graph) graph.discover(this.additionalData.discover) this.session.graph = graph - await this.session.save() } - async run() { - const quest = await Quest.findById(this.session.quest) - const stage = quest.stages.find((s) => s.id === this.session.progress) - if (!stage) { - console.error('stage not found', this.session.progress) - return {} - } - - this.handleEvent() - this.handleCommand() + getStages() { + return this.quest.stages.filter((s) => + s.requirements.every((r) => this.session.completed.includes(r)) + ) + } + matchStage(stage) { const commandMatch = checkMatch( stage.condition.command, this.commandInput.command @@ -79,22 +74,39 @@ export default class CommandHandler { if (!commandMatch || !outputMatch || !errorMatch) return {} - this.session.completion.push(this.session.progress) + this.session.completed.push(stage.id) this.session.hints.push(...stage.hints) - - this.session.progress = stage.next - await this.session.save() - - if (stage.next === 'END') { + if (stage.id === 'END') { this.session.status = 'finished' this.session.finishedAt = new Date() - await this.session.save() } - return { responses: stage.responses, hints: stage.hints, - end: stage.next === 'END', + } + } + + async run() { + this.quest = await Quest.findById(this.session.quest) + const stages = this.getStages() + if (stages.length === 0) { + console.error('stage not found', this.session.progress) + return {} + } + + console.log('stages', stages) + + this.handleEvent() + this.handleCommand() + + const response = stages.reduce( + (r, s) => ({ ...r, ...this.matchStage(s) }), + {} + ) + + return { + end: this.session.status === 'finished', + ...response, } } } diff --git a/server/src/models/quest.js b/server/src/models/quest.js index ea592f1..434a9e5 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -15,6 +15,12 @@ const stageSchema = new Schema({ type: String, required: true, }, + requirements: [ + { + type: String, + required: true, + }, + ], condition: { command: { type: [String], diff --git a/server/src/models/session.js b/server/src/models/session.js index 27d7efa..5aeac2f 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -39,8 +39,7 @@ const Session = model( default: Date.now, required: true, }, - progress: String, - completion: [String], + completed: [String], hints: [String], graph: { type: nodeSchema, diff --git a/server/swagger.yaml b/server/swagger.yaml index 34f9bf1..69e65a8 100644 --- a/server/swagger.yaml +++ b/server/swagger.yaml @@ -392,7 +392,7 @@ components: type: string description: current stage id example: helloworld - completion: + completed: type: array description: list of completed stage ids items: From d9836318db6b06b989ac417480b9afd26bafe96a Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 30 Aug 2023 19:52:02 +0800 Subject: [PATCH 02/11] feat: add debug mode to client --- packages/client/index.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/client/index.js b/packages/client/index.js index 5363631..486e5e0 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -14,6 +14,12 @@ stdin.setRawMode(true) stdin.resume() stdin.setEncoding('utf8') +function debug(...args) { + if (program.opts().debug) { + console.debug(...args) + } +} + async function createdSession() { console.log('Creating a new session...') const res = await fetch(`${program.opts().host}/api/v1/sessions`, { @@ -73,8 +79,7 @@ async function connect(sessionId) { }) socket.on('graph', (data) => { - console.log('receive graph:') - console.log(data) + debug('receive graph:', data) }) socket.on('close', function close() { @@ -107,6 +112,7 @@ async function main() { program .option('-s, --session ', 'Session ID') .option('-c, --create', 'Create a new session') + .option('-d, --debug', 'Debug mode') .option( '-h, --host ', 'Server host', From 99da610d9ea66e2346f44df54a31860d2700cdc5 Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 30 Aug 2023 19:52:40 +0800 Subject: [PATCH 03/11] feat: exit shell on disconnect --- .../src/api/controllers/commandController.js | 2 -- server/src/api/socket.js | 28 ++++++++++++++----- server/src/containers/docker.js | 2 +- server/src/game/commandHandler.js | 2 -- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index def7b1b..fd583e1 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -7,7 +7,6 @@ import CommandHandler from '../../game/commandHandler.js' export async function newCommand(req, res) { console.log('new command:', req.body) const { token, command, pwd, output, error, ...additionalData } = req.body - console.log('additionalData:', additionalData) if (!command) { res.status(400).json({ message: 'command is required' }) @@ -50,7 +49,6 @@ export async function newCommand(req, res) { const commandHandler = new CommandHandler(session, c, additionalData) const response = await commandHandler.run() - console.log(response) res.status(201).json(response) await session.save() diff --git a/server/src/api/socket.js b/server/src/api/socket.js index 25d26d6..ead74bf 100644 --- a/server/src/api/socket.js +++ b/server/src/api/socket.js @@ -10,9 +10,18 @@ function listenToSession(sessionId, event, callback) { sessions.set((sessionId, event), callbacks.concat(callback)) } +function removeFromSession(sessionId, event, callback) { + const callbacks = sessions.get((sessionId, event)) || [] + sessions.set( + (sessionId, event), + callbacks.filter((cb) => cb !== callback) + ) +} + export function pushToSession(sessionId, event, data) { console.log('Push:', sessionId, event) const callbacks = sessions.get((sessionId, event)) + console.debug('Callbacks:', sessionId, callbacks.length) if (callbacks) { callbacks.forEach((callback) => callback(data)) } @@ -65,22 +74,27 @@ export default (server) => { const container = await getAndStartContainer(session.containerId) const stream = await attachContainer(container, { token }) - stream.on('data', (chunk) => { + stream.socket.on('data', (chunk) => { socket.emit('terminal', chunk.toString()) }) socket.on('message', console.log) socket.on('terminal', function incoming(message) { - stream.write(message) + stream.socket.write(message) }) - socket.on('close', () => { - console.log('Disconnected from the client.') - }) - - listenToSession(session.id, 'graph', (data) => { + const graphCallback = (data) => { socket.emit('graph', data) + } + + listenToSession(session.id, 'graph', graphCallback) + + socket.on('disconnect', () => { + console.log('Disconnected from the client.') + stream.socket.write('exit\n') + removeFromSession(session.id, 'graph', graphCallback) + stream.destroy() }) socket.send(`${container.id}\n`) diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index b687897..0b6d82f 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -63,7 +63,7 @@ export async function attachContainer(container, { token }) { Tty: true, }) - return execOutput.socket + return execOutput } export async function deleteContainer(id) { diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index e1aa818..c5e6555 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -94,8 +94,6 @@ export default class CommandHandler { return {} } - console.log('stages', stages) - this.handleEvent() this.handleCommand() From 5ec9ae52ce6ccb1981ac33cbfcb03c3d7af51ced Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 31 Aug 2023 16:44:42 +0800 Subject: [PATCH 04/11] feat: separate base image of quest * change to use Ubuntu as base image --- docker-compose.yml | 6 ++++++ quests/Dockerfile | 41 ++++++++++++++++++++++++++++++++++++ quests/helloworld/Dockerfile | 34 ++---------------------------- quests/helloworld/game.yml | 2 +- 4 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 quests/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 3492bab..47d371a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,12 @@ services: context: . dockerfile: quests/helloworld/Dockerfile + base: + image: lancatlin/linux-odyssey:base + build: + context: . + dockerfile: ./quests/Dockerfile + networks: internal: players: diff --git a/quests/Dockerfile b/quests/Dockerfile new file mode 100644 index 0000000..3971f8f --- /dev/null +++ b/quests/Dockerfile @@ -0,0 +1,41 @@ +FROM ubuntu:22.04 + +LABEL maintainer="wancat@wancat.cc" + +RUN apt-get update && apt-get install -y \ + locales \ + curl \ + vim \ + zsh \ + ca-certificates \ + gnupg + +RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=zh_TW.UTF-8 + +ENV LANG zh_TW.UTF-8 +ENV LANGUAGE zh_TW:zh +ENV LC_ALL zh_TW.UTF-8 + +ENV NODE_MAJOR=20 +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +RUN apt-get update && apt-get install nodejs -y + +WORKDIR /usr/local/lib/container + +RUN npm install -g yarn +COPY ./packages/container/package.json ./yarn.lock ./ +RUN yarn install --modules-folder /usr/src/node_modules +ENV NODE_PATH=/usr/src/node_modules +COPY ./packages/container /usr/local/lib/container + +ENV USERNAME=commander + +RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} + +USER ${USERNAME} + +WORKDIR /home/${USERNAME} + diff --git a/quests/helloworld/Dockerfile b/quests/helloworld/Dockerfile index 10e45fa..bd9c4bb 100644 --- a/quests/helloworld/Dockerfile +++ b/quests/helloworld/Dockerfile @@ -1,35 +1,5 @@ -FROM node:latest +FROM lancatlin/linux-odyssey:base -RUN apt-get update && apt-get install -y \ - locales \ - curl \ - vim \ - zsh - -RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \ - dpkg-reconfigure --frontend=noninteractive locales && \ - update-locale LANG=zh_TW.UTF-8 - -ENV LANG zh_TW.UTF-8 -ENV LANGUAGE zh_TW:zh -ENV LC_ALL zh_TW.UTF-8 - -WORKDIR /usr/local/lib/container - -COPY ./packages/container/package.json ./yarn.lock ./ - -RUN yarn install --modules-folder /usr/src/node_modules - -ENV NODE_PATH=/usr/src/node_modules - -COPY ./packages/container /usr/local/lib/container - -RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus - -USER rudeus - -WORKDIR /home/rudeus - -COPY ./quests/helloworld/home /home/rudeus/ +COPY ./quests/helloworld/home /home/${USERNAME}/ CMD [ "/bin/zsh" ] diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index fcc4d7f..6f99af0 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -74,4 +74,4 @@ stages: speaker: Unix Spirit content: - 恭喜您,我的指令官,您已成功解除了封印。 - - 請跟著在接下了關卡,一步步取回 Linux 之根吧! + - 請跟著在接下來的關卡,一步步取回 Linux 之根吧! From 3fdbca89f35849f42e58c94daecc899199b0087e Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 31 Aug 2023 16:45:03 +0800 Subject: [PATCH 05/11] feat: add waiting time before the responses --- packages/container/print.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/container/print.js b/packages/container/print.js index 76a77a5..e2b6f95 100644 --- a/packages/container/print.js +++ b/packages/container/print.js @@ -36,6 +36,12 @@ function waitForEnter() { }) } +function waitTime(time) { + return new Promise((resolve) => { + setTimeout(resolve, time) + }) +} + function printLine(content, delay, color) { const printContent = `${colorize(content, color)}${RETURN_SYMBOL}` return new Promise((resolve) => { @@ -59,6 +65,9 @@ function printLine(content, delay, color) { } async function printResponses(responses, delay = 100) { + await waitTime(1000) + console.log() + console.log(colorize('-------------', 'white')) for (const response of responses) { const { type, content, speaker, color } = response if (type === 'narrative') { From 8b5f7175a7e2baf7cf07aff2a3bdf19c9121d13a Mon Sep 17 00:00:00 2001 From: wancat Date: Fri, 1 Sep 2023 13:49:00 +0800 Subject: [PATCH 06/11] fix: error handling of container failures --- server/src/api/socket.js | 118 ++++++++++++++++---------------- server/src/containers/docker.js | 13 ++-- 2 files changed, 64 insertions(+), 67 deletions(-) diff --git a/server/src/api/socket.js b/server/src/api/socket.js index ead74bf..72f60e9 100644 --- a/server/src/api/socket.js +++ b/server/src/api/socket.js @@ -19,14 +19,71 @@ function removeFromSession(sessionId, event, callback) { } export function pushToSession(sessionId, event, data) { - console.log('Push:', sessionId, event) const callbacks = sessions.get((sessionId, event)) - console.debug('Callbacks:', sessionId, callbacks.length) if (callbacks) { callbacks.forEach((callback) => callback(data)) } } +async function connectClient(socket) { + console.log('Connected to the client.') + const sessionId = socket.handshake.query.session_id + if (!sessionId) { + socket.send('Session ID not found.') + socket.disconnect() + return + } + + const session = await Session.findOne({ + _id: sessionId, + user: socket.user, + status: 'active', + }) + if (!session) { + socket.send('Session not found.') + socket.disconnect() + return + } + + const token = await genSessionJWT(session) + + let container + let stream + try { + container = await getAndStartContainer(session.containerId) + stream = await attachContainer(container, { token }) + } catch (err) { + socket.send(err.message) + socket.disconnect() + return + } + + stream.socket.on('data', (chunk) => { + socket.emit('terminal', chunk.toString()) + }) + + socket.on('message', console.log) + + socket.on('terminal', function incoming(message) { + stream.socket.write(message) + }) + + const graphCallback = (data) => { + socket.emit('graph', data) + } + + listenToSession(session.id, 'graph', graphCallback) + + socket.on('disconnect', () => { + console.log('Disconnected from the client.') + stream.socket.write('exit\n') + removeFromSession(session.id, 'graph', graphCallback) + stream.destroy() + }) + + socket.send(`${container.id}\n`) +} + export default (server) => { const io = new Server(server) @@ -43,60 +100,5 @@ export default (server) => { } }) - io.on('connection', async (socket) => { - console.log('Connected to the client.') - const sessionId = socket.handshake.query.session_id - if (!sessionId) { - socket.send('Session ID not found.') - socket.disconnect() - return - } - let session - try { - session = await Session.findOne({ - _id: sessionId, - user: socket.user, - status: 'active', - }) - if (!session) { - socket.send('Session not found.') - socket.disconnect() - return - } - } catch (err) { - socket.send(err.message) - socket.disconnect() - return - } - - const token = await genSessionJWT(session) - - const container = await getAndStartContainer(session.containerId) - const stream = await attachContainer(container, { token }) - - stream.socket.on('data', (chunk) => { - socket.emit('terminal', chunk.toString()) - }) - - socket.on('message', console.log) - - socket.on('terminal', function incoming(message) { - stream.socket.write(message) - }) - - const graphCallback = (data) => { - socket.emit('graph', data) - } - - listenToSession(session.id, 'graph', graphCallback) - - socket.on('disconnect', () => { - console.log('Disconnected from the client.') - stream.socket.write('exit\n') - removeFromSession(session.id, 'graph', graphCallback) - stream.destroy() - }) - - socket.send(`${container.id}\n`) - }) + io.on('connection', connectClient) } diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 0b6d82f..74e2d86 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -35,16 +35,11 @@ export async function createContainer(name) { export async function getAndStartContainer(id) { console.log(`Getting container: ${id}`) - let container - try { - container = engine.getContainer(id) - if (!(await container.inspect()).State.Running) { - await container.start() - } - return container - } catch (error) { - throw new Error(`Container not found: ${id}`) + const container = engine.getContainer(id) + if (!(await container.inspect()).State.Running) { + await container.start() } + return container } export async function attachContainer(container, { token }) { From de8d49aea3975471f2d26778dae7eeb860c91a8f Mon Sep 17 00:00:00 2001 From: wancat Date: Fri, 1 Sep 2023 13:54:51 +0800 Subject: [PATCH 07/11] fix: change jwt expiry time to config * rename dockerfile env --- quests/helloworld/Dockerfile | 2 +- server/src/utils/auth.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quests/helloworld/Dockerfile b/quests/helloworld/Dockerfile index bd9c4bb..e845379 100644 --- a/quests/helloworld/Dockerfile +++ b/quests/helloworld/Dockerfile @@ -1,5 +1,5 @@ FROM lancatlin/linux-odyssey:base -COPY ./quests/helloworld/home /home/${USERNAME}/ +COPY ./quests/helloworld/home ${HOME} CMD [ "/bin/zsh" ] diff --git a/server/src/utils/auth.js b/server/src/utils/auth.js index d97de3a..49d3ac6 100644 --- a/server/src/utils/auth.js +++ b/server/src/utils/auth.js @@ -19,7 +19,7 @@ export function genSessionJWT(session) { jwt.sign( { session_id: session.id }, config.jwtSecret, - { expiresIn: '1h' }, + { expiresIn: config.expiry }, (err, token) => { if (err) { reject(err) From dd7829b3e93d0cae964843407342f03e93bcf4c3 Mon Sep 17 00:00:00 2001 From: wancat Date: Fri, 1 Sep 2023 14:56:28 +0800 Subject: [PATCH 08/11] feat: combine task with stage --- packages/client/index.js | 4 ++ quests/helloworld/game.yml | 27 ++++++++++++++ quests/helloworld/story.md | 9 +++-- server/src/api/socket.js | 20 +++++----- server/src/containers/docker.js | 2 +- server/src/game/commandHandler.js | 62 +++++++++++++++++++------------ server/src/models/quest.js | 40 ++++++++++++-------- server/src/models/session.js | 17 ++++++++- 8 files changed, 127 insertions(+), 54 deletions(-) diff --git a/packages/client/index.js b/packages/client/index.js index 486e5e0..f7988ed 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -82,6 +82,10 @@ async function connect(sessionId) { debug('receive graph:', data) }) + socket.on('tasks', (data) => { + debug('receive tasks:', data) + }) + socket.on('close', function close() { console.log('Disconnected from the server.') exit() diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index 6f99af0..c474213 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -4,8 +4,11 @@ instruction: | 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: 「輸入 echo Hello World!」 + stages: - id: helloworld + task: 使用 `echo Hello World!` 召喚精靈 + repeatable: false condition: command: - echo Hello World @@ -26,7 +29,10 @@ stages: - 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。 hints: - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 + - id: ls + task: 搜索卷軸 + repeatable: false requirements: - helloworld condition: @@ -48,7 +54,25 @@ stages: hints: - 使用 `cat [檔案名稱]` 指令來查看檔案內容。 - 使用 `Tab` 鍵來取得指令提示。 + + - id: escape + task: 移動到下一個房間 + repeatable: false + requirements: + - ls + condition: + pwd: + - '^/home/commander/jail$' + responses: + - type: narrative + content: + - 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。 + - 突然,你聽到門上鎖的聲音。 + - 轉身發現,自己被關在了牢房裡。 + - id: cat + task: 查看卷軸 + repeatable: false requirements: - ls condition: @@ -60,7 +84,10 @@ stages: content: - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用你已經知道的 `echo` 指令來詠唱咒語。 + - id: END + task: 解除封印 + repeatable: false requirements: - cat condition: diff --git a/quests/helloworld/story.md b/quests/helloworld/story.md index 66a2a21..528cbf8 100644 --- a/quests/helloworld/story.md +++ b/quests/helloworld/story.md @@ -6,7 +6,10 @@ 1. echo 朗誦魔術 2. ls 搜索魔術 -3. cat 檢察魔術 +3. cat 檢查魔術 +4. cd 移動魔術 +5. mv 搬運魔術 +6. rm 消滅魔術 ## 關卡設計 @@ -14,8 +17,8 @@ 2. 學習到了 ls 3. 使用 ls 找到地上放置了一個卷軸 4. 學習 cat -5. 使用 cat 查看卷軸內容 -6. 用 echo 念出卷軸上的咒語 +5. 使用 cat 查看卷軸內容,發現 +6. 請幫我設計: diff --git a/server/src/api/socket.js b/server/src/api/socket.js index 72f60e9..d3dd797 100644 --- a/server/src/api/socket.js +++ b/server/src/api/socket.js @@ -5,9 +5,9 @@ import { defaultUser, genSessionJWT } from '../utils/auth.js' const sessions = new Map() -function listenToSession(sessionId, event, callback) { - const callbacks = sessions.get((sessionId, event)) || [] - sessions.set((sessionId, event), callbacks.concat(callback)) +function listenToSession(sessionId, callback) { + const callbacks = sessions.get(sessionId) || [] + sessions.set(sessionId, callbacks.concat(callback)) } function removeFromSession(sessionId, event, callback) { @@ -18,10 +18,10 @@ function removeFromSession(sessionId, event, callback) { ) } -export function pushToSession(sessionId, event, data) { - const callbacks = sessions.get((sessionId, event)) +export function pushToSession(sessionId, ...args) { + const callbacks = sessions.get(sessionId) if (callbacks) { - callbacks.forEach((callback) => callback(data)) + callbacks.forEach((callback) => callback(...args)) } } @@ -68,16 +68,16 @@ async function connectClient(socket) { stream.socket.write(message) }) - const graphCallback = (data) => { - socket.emit('graph', data) + const socketCallback = (event, data) => { + socket.emit(event, data) } - listenToSession(session.id, 'graph', graphCallback) + listenToSession(session.id, socketCallback) socket.on('disconnect', () => { console.log('Disconnected from the client.') stream.socket.write('exit\n') - removeFromSession(session.id, 'graph', graphCallback) + removeFromSession(session.id, socketCallback) stream.destroy() }) diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index 74e2d86..a4c768f 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -15,7 +15,7 @@ const containerOptions = { Binds: !config.isProduction && config.hostPwd ? [ - `${config.hostPwd}/quests/helloworld/home:/home/rudeus`, + `${config.hostPwd}/quests/helloworld/home:/home/commander`, `${config.hostPwd}/packages/container:/usr/local/lib/container`, ] : [], diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index c5e6555..a166d6f 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -5,7 +5,7 @@ import { pushToSession } from '../api/socket.js' // 檢查 pattern 是否符合 input const checkMatch = (pattern, input) => { - if (pattern.length === 0) return true + if (!pattern || pattern.length === 0) return true return pattern.some((p) => { const regex = new RegExp(p) return regex.test(input) @@ -52,37 +52,53 @@ export default class CommandHandler { this.session.graph = graph } + getTask(stage) { + return this.session.tasks.find((t) => t.id === stage.id) + } + + isCompleted(stage) { + const task = this.getTask(stage) + return task ? task.completed : false + } + getStages() { - return this.quest.stages.filter((s) => - s.requirements.every((r) => this.session.completed.includes(r)) + return this.quest.stages.filter( + (stage) => + stage.requirements.every((r) => this.isCompleted({ id: r })) && + (stage.repeatable || !this.isCompleted(stage)) ) } - matchStage(stage) { - const commandMatch = checkMatch( - stage.condition.command, - this.commandInput.command - ) - const outputMatch = checkMatch( - stage.condition.output, - this.commandInput.output - ) - const errorMatch = checkMatch( - stage.condition.error, - this.commandInput.output + isMatch(stage) { + const keys = ['command', 'output', 'error', 'pwd'] + return keys.every((k) => + checkMatch(stage.condition[k], this.commandInput[k]) ) + } - if (!commandMatch || !outputMatch || !errorMatch) return {} - - this.session.completed.push(stage.id) + execute(stage) { + this.session.tasks + .filter((t) => t.id === stage.id) + .forEach((t) => { + t.completed = true + }) this.session.hints.push(...stage.hints) if (stage.id === 'END') { this.session.status = 'finished' this.session.finishedAt = new Date() } + const stages = this.getStages() + console.debug(stages) + const newTasks = stages + .filter((s) => s.task) + .map((s) => ({ id: s.id, name: s.task, completed: false })) + + this.session.tasks.push(...newTasks) + pushToSession(this.session.id, 'tasks', this.session.tasks) return { responses: stage.responses, hints: stage.hints, + tasks: newTasks, } } @@ -90,18 +106,18 @@ export default class CommandHandler { this.quest = await Quest.findById(this.session.quest) const stages = this.getStages() if (stages.length === 0) { - console.error('stage not found', this.session.progress) + console.error('stage not found', this.session.tasks) return {} } this.handleEvent() this.handleCommand() - const response = stages.reduce( - (r, s) => ({ ...r, ...this.matchStage(s) }), - {} - ) + const response = stages + .filter((s) => this.isMatch(s)) + .reduce((r, s) => ({ ...r, ...this.execute(s) }), {}) + console.log(this.session) return { end: this.session.status === 'finished', ...response, diff --git a/server/src/models/quest.js b/server/src/models/quest.js index 434a9e5..84c1966 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -15,25 +15,25 @@ const stageSchema = new Schema({ type: String, required: true, }, + task: { + type: String, + }, requirements: [ { type: String, required: true, }, ], + repeatable: { + type: Boolean, + required: true, + default: false, + }, condition: { - command: { - type: [String], - required: false, - }, - output: { - type: [String], - required: false, - }, - error: { - type: [String], - required: false, - }, + command: [String], + output: [String], + error: [String], + pwd: [String], }, responses: { type: [responseSchema], @@ -43,10 +43,6 @@ const stageSchema = new Schema({ type: [String], required: true, }, - next: { - type: String, - required: true, - }, }) const Quest = model( @@ -56,6 +52,18 @@ const Quest = model( title: String, order: Number, instruction: String, + tasks: { + id: String, + content: String, + display: { + type: Boolean, + default: false, + }, + completed: { + type: Boolean, + default: false, + }, + }, stages: [stageSchema], }) ) diff --git a/server/src/models/session.js b/server/src/models/session.js index 5aeac2f..ddb4fad 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -14,6 +14,21 @@ nodeSchema.add({ children: [nodeSchema], }) +const taskSchema = new Schema({ + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + completed: { + type: Boolean, + default: false, + }, +}) + const Session = model( 'Session', new Schema( @@ -39,7 +54,7 @@ const Session = model( default: Date.now, required: true, }, - completed: [String], + tasks: [taskSchema], hints: [String], graph: { type: nodeSchema, From a8147ecfa12a5c133cecb200d9eb3471a7f136b0 Mon Sep 17 00:00:00 2001 From: wancat Date: Fri, 1 Sep 2023 15:15:12 +0800 Subject: [PATCH 09/11] feat: display tasks --- app/src/App.vue | 3 +++ app/src/components/QuestPart.vue | 36 +++++++++++++++++++++++--------- app/src/utils/session.js | 4 ++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/src/App.vue b/app/src/App.vue index 3c94c4f..7494e8d 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -23,6 +23,9 @@ onMounted(async () => { socket.on('graph', (event) => { sessionManager.handleGraphUpdate(event) }) + socket.on('tasks', (tasks) => { + sessionManager.setTasks(tasks) + }) }) diff --git a/app/src/components/QuestPart.vue b/app/src/components/QuestPart.vue index 77da551..0733afc 100644 --- a/app/src/components/QuestPart.vue +++ b/app/src/components/QuestPart.vue @@ -1,19 +1,19 @@