diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index ec3dd98..722cdba 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -69,6 +69,7 @@ jobs: with: context: . file: ./server/Dockerfile + target: prod push: true tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:backend @@ -77,6 +78,7 @@ jobs: with: context: . file: ./app/Dockerfile + target: prod push: true tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:app @@ -85,6 +87,7 @@ jobs: with: context: . file: ./packages/analytics/Dockerfile + target: prod push: true tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:analytics diff --git a/app/Dockerfile b/app/Dockerfile index 8c9b986..eafe05a 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,5 +1,5 @@ -# Build stage -FROM node as build-stage +# Base stage +FROM node:20-alpine as base WORKDIR /app COPY package.json yarn.lock ./ COPY app/package.json ./app/package.json @@ -7,14 +7,21 @@ COPY packages/file-graph/package.json ./packages/file-graph/package.json COPY packages/utils/package.json ./packages/utils/package.json RUN yarn install --frozen-lockfile -COPY app ./app +# Dev stage +FROM base as dev +WORKDIR /app/app +CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ] + +# Build stage +FROM base as build COPY packages/file-graph ./packages/file-graph COPY packages/utils ./packages/utils +COPY app ./app RUN yarn build # Production stage -FROM nginx:stable-alpine as production-stage -COPY --from=build-stage /app/app/dist /usr/share/nginx/html +FROM nginx:stable-alpine as prod +COPY --from=build /app/app/dist /usr/share/nginx/html COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/app/Dockerfile.dev b/app/Dockerfile.dev deleted file mode 100644 index 7548510..0000000 --- a/app/Dockerfile.dev +++ /dev/null @@ -1,11 +0,0 @@ -FROM node - -WORKDIR /app -COPY package.json yarn.lock ./ -COPY app/package.json ./app/package.json -COPY packages/file-graph/package.json ./packages/file-graph/package.json -COPY packages/utils/package.json ./packages/utils/package.json -RUN yarn install --frozen-lockfile - -WORKDIR /app/app -CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ] \ No newline at end of file diff --git a/app/index.html b/app/index.html index 6cf91bd..0ff4559 100644 --- a/app/index.html +++ b/app/index.html @@ -5,7 +5,12 @@ Vite + Vue - +
diff --git a/app/src/components/ControlPalette.vue b/app/src/components/ControlPalette.vue index d765331..bc14d7d 100644 --- a/app/src/components/ControlPalette.vue +++ b/app/src/components/ControlPalette.vue @@ -31,10 +31,12 @@ diff --git a/app/src/components/TerminalPart.vue b/app/src/components/TerminalPart.vue index ed09178..3453db1 100644 --- a/app/src/components/TerminalPart.vue +++ b/app/src/components/TerminalPart.vue @@ -9,57 +9,13 @@ diff --git a/app/src/utils/session.js b/app/src/utils/session.js index 0869c70..e659bff 100644 --- a/app/src/utils/session.js +++ b/app/src/utils/session.js @@ -15,6 +15,7 @@ class SessionManager { }) ) this.pwd = ref('') + this.status = ref('inactive') } getSession() { @@ -22,10 +23,10 @@ class SessionManager { } setSession(session) { - console.log('Update session:', session) this.session.value = session this.graph.value = new FileGraph(session.graph) this.hints.value = session.hints + this.status.value = session.status } handleGraphUpdate(event) { @@ -37,8 +38,12 @@ class SessionManager { } } - handleHintUpdate(event) { - this.hints.value = [...this.hints.value, ...event] + handleHintUpdate(hints) { + this.hints.value = [...this.hints.value, ...hints] + } + + handleStatusUpdate(status) { + this.status.value = status } setTasks(tasks) { diff --git a/app/src/utils/terminal.js b/app/src/utils/terminal.js new file mode 100644 index 0000000..be6c381 --- /dev/null +++ b/app/src/utils/terminal.js @@ -0,0 +1,51 @@ +import { Terminal } from 'xterm' +import { FitAddon } from 'xterm-addon-fit' +import socket from './socket' +import 'xterm/css/xterm.css' + +class SocketTerminal { + constructor(rows, cols) { + this.term = new Terminal({ + rendererType: 'canvas', // 渲染类型 + rows, // 行数 + cols, // 不指定行数,自动回车后光标从下一行开始 + convertEol: true, // 启用时,光标将设置为下一行的开头 + // scrollback: 50, //终端中的回滚量 + disableStdin: false, // 是否应禁用输入 + // cursorStyle: "underline", //光标样式 + cursorBlink: true, // 光标闪烁 + theme: { + foreground: '#ECECEC', // 字体 + background: '#000000', // 背景色 + cursor: 'help', // 设置光标 + lineHeight: 20, + }, + }) + this.fitAddon = new FitAddon() + this.term.loadAddon(this.fitAddon) + this.fitAddon.fit() + this.socket = socket + this.socket.on('terminal', (message) => { + this.term.write(message) + }) + this.term.onData((key) => { + this.socket.emit('terminal', key) + }) + } + + resizeScreen() { + this.fitAddon.fit() + } + + mount(reference) { + this.term.open(reference) + this.term.focus() + this.resizeScreen() + } + + clear() { + this.term.clear() + } +} + +export default new SocketTerminal(40, 100) diff --git a/app/src/views/GamePage.vue b/app/src/views/GamePage.vue index c92f8f7..6312731 100644 --- a/app/src/views/GamePage.vue +++ b/app/src/views/GamePage.vue @@ -1,5 +1,5 @@ @@ -48,6 +45,12 @@ onMounted(async () => {
+ +
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8eacdc4..aeeeec4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -5,6 +5,7 @@ services: build: context: . dockerfile: app/Dockerfile + target: prod networks: internal: ports: @@ -15,6 +16,7 @@ services: build: context: . dockerfile: server/Dockerfile + target: prod networks: - internal - players @@ -31,6 +33,7 @@ services: build: context: . dockerfile: packages/analytics/Dockerfile + target: prod depends_on: - db networks: diff --git a/docker-compose.testing.yml b/docker-compose.testing.yml index 31b78cf..cace521 100644 --- a/docker-compose.testing.yml +++ b/docker-compose.testing.yml @@ -17,6 +17,7 @@ services: build: context: . dockerfile: app/Dockerfile + target: prod networks: internal: ports: @@ -29,6 +30,7 @@ services: build: context: . dockerfile: server/Dockerfile + target: prod depends_on: - db-test networks: diff --git a/docker-compose.yml b/docker-compose.yml index a6483c9..1508496 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,8 @@ services: - backend build: context: . - dockerfile: app/Dockerfile.dev + dockerfile: app/Dockerfile + target: dev ports: - 5173:5173 networks: @@ -18,7 +19,8 @@ services: - db build: context: . - dockerfile: server/Dockerfile.dev + dockerfile: server/Dockerfile + target: dev networks: - internal - players @@ -43,7 +45,8 @@ services: analytics: build: context: . - dockerfile: packages/analytics/Dockerfile.dev + dockerfile: packages/analytics/Dockerfile + target: dev depends_on: - db networks: diff --git a/packages/analytics/Dockerfile b/packages/analytics/Dockerfile index 69f71f4..64109ec 100644 --- a/packages/analytics/Dockerfile +++ b/packages/analytics/Dockerfile @@ -1,19 +1,25 @@ -FROM node +FROM node:20-alpine as build WORKDIR /app COPY ./package.json yarn.lock ./ COPY packages/analytics/package.json ./packages/analytics/package.json COPY packages/models/package.json ./packages/models/package.json RUN yarn install --frozen-lockfile --production - -COPY packages/analytics ./packages/analytics -COPY packages/models ./packages/models - -WORKDIR /app/packages/analytics - -ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=3001 EXPOSE 3001 -CMD [ "yarn", "start" ] \ No newline at end of file +# Production stage +FROM build as prod +COPY packages/analytics ./packages/analytics +COPY packages/models ./packages/models +ENV NODE_ENV=production +WORKDIR /app/packages/analytics +CMD [ "yarn", "start" ] + +# Development stage +FROM build as dev +RUN yarn install --frozen-lockfile +WORKDIR /app/packages/analytics +ENV NODE_ENV=development +CMD [ "yarn", "dev" ] diff --git a/packages/analytics/Dockerfile.dev b/packages/analytics/Dockerfile.dev deleted file mode 100644 index 1262872..0000000 --- a/packages/analytics/Dockerfile.dev +++ /dev/null @@ -1,16 +0,0 @@ -FROM node - -WORKDIR /app -COPY ./package.json yarn.lock ./ -COPY packages/analytics/package.json ./packages/analytics/package.json -COPY packages/models/package.json ./packages/models/package.json -RUN yarn install --frozen-lockfile - -WORKDIR /app/packages/analytics - -ENV NODE_ENV=development -ENV HOST=0.0.0.0 -ENV PORT=3001 -EXPOSE 3001 - -CMD [ "yarn", "dev" ] diff --git a/packages/analytics/src/models/sessions.js b/packages/analytics/src/models/sessions.js index e290a8b..72d5aa4 100644 --- a/packages/analytics/src/models/sessions.js +++ b/packages/analytics/src/models/sessions.js @@ -51,7 +51,16 @@ export async function sessionList() { }, ]) return sessions.map( - ({ _id, user, quest, status, createdAt, finishedAt, commands }) => { + ({ + _id, + user, + quest, + status, + createdAt, + finishedAt, + commands, + lastActivityAt, + }) => { return { _id, user: user.username, @@ -59,7 +68,8 @@ export async function sessionList() { status, createdAt: createdAt?.toLocaleString(), finishedAt: finishedAt?.toLocaleString(), - usedTime: finishedAt ? formatTime(finishedAt - createdAt) : '', + lastActivityAt: lastActivityAt?.toLocaleString(), + usedTime: formatTime(lastActivityAt - createdAt), commandCount: commands?.length, } } @@ -69,9 +79,10 @@ export async function sessionList() { export async function sessionDetail(id) { const session = await Session.findById(id).populate('user').exec() const commands = (await Command.find({ session: id })).map( - ({ command, pwd, output, error, createdAt }) => ({ + ({ command, pwd, output, error, createdAt, stage }) => ({ command: command?.slice(0, 20), pwd, + stage, output: output?.slice(0, 50), error: error?.slice(0, 50), createdAt: createdAt?.toLocaleString(), diff --git a/packages/analytics/views/sessionDetail.handlebars b/packages/analytics/views/sessionDetail.handlebars index 71d8aa6..d43b8fe 100644 --- a/packages/analytics/views/sessionDetail.handlebars +++ b/packages/analytics/views/sessionDetail.handlebars @@ -40,6 +40,7 @@ PWD Output Error + Stage Created At {{#each commands}} @@ -48,6 +49,7 @@ {{pwd}} {{output}} {{error}} + {{stage}} {{createdAt}} {{/each}} diff --git a/packages/container/cli.js b/packages/container/cli.js index a6f71d3..2165189 100644 --- a/packages/container/cli.js +++ b/packages/container/cli.js @@ -2,6 +2,7 @@ const fs = require('fs').promises const { exit } = require('process') const minimist = require('minimist') +const axios = require('axios') const { colorize, printResponses } = require('./print.js') const discoverFiles = require('./discover.js') @@ -40,6 +41,13 @@ async function main() { } = process.env if (!CMD_NAME) exit(0) + const api = axios.create({ + baseURL: `${API_ENDPOINT}/api/v1`, + headers: { + Authorization: `Bearer ${TOKEN}`, + }, + }) + const additionalData = await handleCommand(CMD_NAME) const output = await readOrNone(CMD_OUTPUT_FILE) @@ -53,24 +61,19 @@ async function main() { ...additionalData, } try { - const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${TOKEN}`, - }, - body: JSON.stringify(payload), - }) - const data = await res.json() - if (data.responses) { - await printResponses(data.responses, 60) + const res = await api.post('/commands', payload) + if (!res.data) return + const { responses, hints, end } = res.data + if (responses) { + await printResponses(responses, 60) + await api.post('/commands/completed') } - if (data.hints) { - for (const hint of data.hints) { + if (hints) { + for (const hint of hints) { console.log(colorize(hint, 'yellow')) } } - if (data.end) { + if (end) { console.log(colorize('Quest completed!', 'blue')) } } catch (err) { diff --git a/packages/container/package.json b/packages/container/package.json index 2c6bc8a..c04fd58 100644 --- a/packages/container/package.json +++ b/packages/container/package.json @@ -4,7 +4,7 @@ "main": "index.js", "license": "MIT", "dependencies": { - "chokidar": "^3.5.3", + "axios": "^1.6.0", "minimist": "^1.2.8" } } diff --git a/packages/container/print.js b/packages/container/print.js index e2b6f95..1f7a50e 100644 --- a/packages/container/print.js +++ b/packages/container/print.js @@ -36,12 +36,6 @@ 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) => { @@ -51,7 +45,8 @@ function printLine(content, delay, color) { i += 1 if (i >= printContent.length) { clearInterval(timer) - waitForEnter().then(resolve) + process.stdout.write('\n') + resolve() } }, delay) @@ -65,7 +60,6 @@ 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) { @@ -78,7 +72,7 @@ async function printResponses(responses, delay = 100) { if (type === 'dialogue') { console.log(`${speaker}:`) for (const line of content) { - await printLine(`\t${line}`, delay, color || 'green') + await printLine(` ${line}`, delay, color || 'green') } } } diff --git a/packages/file-graph/file.test.js b/packages/file-graph/file.test.js index 175ac73..f1f84ce 100644 --- a/packages/file-graph/file.test.js +++ b/packages/file-graph/file.test.js @@ -42,7 +42,6 @@ describe('File', () => { filesToCompare.forEach(({ name, path, answer }) => { test(`${name}: ${path}`, () => { const fileToCompare = new File({ path, type: 'file', discovered: true }) - console.log(fileToCompare) expect(file.contains(fileToCompare)).toBe(answer) }) }) diff --git a/packages/file-graph/fileGraph.test.js b/packages/file-graph/fileGraph.test.js index cff996a..509b33d 100644 --- a/packages/file-graph/fileGraph.test.js +++ b/packages/file-graph/fileGraph.test.js @@ -30,7 +30,6 @@ describe('FileGraph', () => { fileGraph.add(filesToAdd) - console.log(fileGraph.toString()) expect(fileGraph.children.length).toBe(1) expect(fileGraph.children[0].children[0].children.length).toBe(2) }) @@ -76,7 +75,6 @@ describe('FileGraph', () => { fileGraph.remove(filesToRemove) - console.log(fileGraph.toString()) expect(fileGraph.children[0].children[0].children.length).toBe(1) expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1') expect(fileGraph.children[0].children[0].children[0].children.length).toBe( @@ -110,7 +108,6 @@ describe('FileGraph', () => { ] fileGraph.discover(discoverFiles) - console.log(fileGraph.toString()) expect(fileGraph.children[0].children[0].children[0].children.length).toBe( 3 ) @@ -143,7 +140,6 @@ describe('FileGraph', () => { ] fileGraph.add(filesToAdd) - console.log(fileGraph.toString()) const newFiles = [ { @@ -170,7 +166,6 @@ describe('FileGraph', () => { fileGraph.discover(newFiles) - console.log(fileGraph.toString()) expect(fileGraph.children[0].children[0].children.length).toBe(2) expect(fileGraph.children[0].children[0].children[0].children.length).toBe( 3 diff --git a/packages/file-graph/fileNode.test.js b/packages/file-graph/fileNode.test.js index 3413dc5..94b6c4d 100644 --- a/packages/file-graph/fileNode.test.js +++ b/packages/file-graph/fileNode.test.js @@ -12,14 +12,8 @@ function mergeTest(files1, files2, expectedFiles) { const node2 = createNodeFromFiles(files2) const expectedNode = createNodeFromFiles(expectedFiles) - console.log('node1', node1.toString()) - console.log('node2', node2.toString()) - console.log('expected', expectedNode.toString()) - node1.merge(node2) - console.log('after-merged', node1.toString()) - expect(node1.toString()).toBe(expectedNode.toString()) } @@ -103,7 +97,6 @@ describe('FileNode', () => { expect(rootNode.children.length).toBe(2) expect(rootNode.children[1].children.length).toBe(1) - console.log(rootNode.toString()) }) test('adds nested children to the tree with makeParents option', () => { @@ -116,7 +109,6 @@ describe('FileNode', () => { rootNode.addChild(file, { makeParents: true }) - console.log(rootNode.toString()) let currentNode = rootNode for (let i = 0; i < 5; i += 1) { expect(currentNode.children.length).toBe(1) @@ -279,7 +271,6 @@ test('load nested fileNode', () => { const node = new FileNode(data) - console.log(node.toString()) expect(node.children.length).toBe(2) expect(node.children[1].children.length).toBe(1) expect(node.children[1].children[0].children.length).toBe(1) diff --git a/packages/models/command.js b/packages/models/command.js index 277a2df..40a03bb 100644 --- a/packages/models/command.js +++ b/packages/models/command.js @@ -25,6 +25,10 @@ const Command = model( type: String, required: false, }, + stage: { + type: String, + required: false, + }, }, { timestamps: true } ) diff --git a/packages/models/quest.js b/packages/models/quest.js index 84c1966..1eb2b94 100644 --- a/packages/models/quest.js +++ b/packages/models/quest.js @@ -10,6 +10,31 @@ const responseSchema = new Schema({ 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 stageSchema = new Schema({ id: { type: String, @@ -29,12 +54,7 @@ const stageSchema = new Schema({ required: true, default: false, }, - condition: { - command: [String], - output: [String], - error: [String], - pwd: [String], - }, + condition: conditionSchema, responses: { type: [responseSchema], required: true, @@ -52,18 +72,6 @@ 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/packages/utils/commands.js b/packages/utils/commands.js new file mode 100644 index 0000000..c64a33a --- /dev/null +++ b/packages/utils/commands.js @@ -0,0 +1,16 @@ +const fileTypes = { + file: '-f', + folder: '-d', + link: '-L', + socket: '-S', +} + +function sanitizePath(path) { + return path.replace(/[^a-zA-Z0-9._/-]/g, '') +} + +// eslint-disable-next-line import/prefer-default-export +export function buildFileCheckCmd(file) { + const { path, type } = file + return ['test', fileTypes[type] || '-e', sanitizePath(path)] +} diff --git a/packages/utils/commands.test.js b/packages/utils/commands.test.js new file mode 100644 index 0000000..d5392ac --- /dev/null +++ b/packages/utils/commands.test.js @@ -0,0 +1,41 @@ +import { buildFileCheckCmd } from './commands.js' + +describe('buildFileCheckCmd function', () => { + it('builds the file check command correctly', () => { + const testcases = [ + { + input: { path: '/etc/passwd', type: 'file' }, + output: ['test', '-f', '/etc/passwd'], + }, + { + input: { path: '/etc', type: 'folder' }, + output: ['test', '-d', '/etc'], + }, + { + input: { path: '/tmp/socket', type: 'socket' }, + output: ['test', '-S', '/tmp/socket'], + }, + { + input: { path: '/tmp/link', type: 'link' }, + output: ['test', '-L', '/tmp/link'], + }, + { + input: { path: '/unknown', type: 'unknown' }, + output: ['test', '-e', '/unknown'], + }, + // Add some injection tests + { + input: { path: '/etc/passwd;ls', type: 'file' }, + output: ['test', '-f', '/etc/passwdls'], + }, + { + input: { path: '/etc/passwd && ls', type: 'file' }, + output: ['test', '-f', '/etc/passwdls'], + }, + ] + + testcases.forEach(({ input, output }) => { + expect(buildFileCheckCmd(input)).toStrictEqual(output) + }) + }) +}) diff --git a/packages/utils/index.js b/packages/utils/index.js index ab747e8..8d79493 100644 --- a/packages/utils/index.js +++ b/packages/utils/index.js @@ -1 +1,2 @@ export { isValidEmail, isValidUsername } from './auth.js' +export { buildFileCheckCmd } from './commands.js' diff --git a/quests/helloworld/home/.zshrc b/quests/.includes/zshrc similarity index 100% rename from quests/helloworld/home/.zshrc rename to quests/.includes/zshrc diff --git a/quests/Dockerfile b/quests/Dockerfile index 3971f8f..9eeb5ae 100644 --- a/quests/Dockerfile +++ b/quests/Dockerfile @@ -30,6 +30,7 @@ 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 +COPY ./quests/.includes/zshrc /etc/zsh/zshrc ENV USERNAME=commander @@ -38,4 +39,4 @@ RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME} USER ${USERNAME} WORKDIR /home/${USERNAME} - +CMD [ "/bin/zsh" ] diff --git a/quests/helloworld/README.md b/quests/helloworld/README.md deleted file mode 100644 index 47e651b..0000000 --- a/quests/helloworld/README.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -title: Hello World -order: 1 ---- - -# Hello World - -Welcome to your first quest! This quest will help you familiarize yourself with the terminal. - -The terminal is a text-based interface that allows you to interact directly with your computer's operating system. You can perform various tasks by typing commands into the terminal and pressing `Enter` to execute them. - -For your first task, you are going to use the `echo` command. - -## Task 1: Using the `echo` Command - -The `echo` command in Unix is used to display line of text/string that are passed as an argument. This is a built in command that is mostly used in shell scripts and batch files to output status text to the screen or a file. - -Here is what you need to do: - -1. Open your terminal. -2. Type `echo Hello World` and hit `Enter`. -3. You should see `Hello World` output to the terminal. This is because `echo` prints its arguments to the standard output, which is your terminal by default. - -Congratulation! You've just run your first terminal command! - -Stay tuned for the next quests where you'll delve deeper into more complex and powerful terminal commands. diff --git a/quests/helloworld/game.yml b/quests/helloworld/game.yml index c474213..587249e 100644 --- a/quests/helloworld/game.yml +++ b/quests/helloworld/game.yml @@ -1,5 +1,4 @@ title: Hello, Linux World! -order: 1 instruction: | 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: @@ -10,6 +9,8 @@ stages: task: 使用 `echo Hello World!` 召喚精靈 repeatable: false condition: + pwd: + - '^/home/commander$' command: - echo Hello World responses: @@ -17,7 +18,7 @@ stages: content: - 你聽到了一個聲音: - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。 - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 @@ -30,21 +31,23 @@ stages: hints: - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 - - id: ls + - id: find-scroll task: 搜索卷軸 repeatable: false requirements: - helloworld condition: + pwd: + - '^/home/commander$' command: - ls responses: - types: narrative content: - 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。 - - 你發現了一個檔案,檔名是 `forgotten_scroll.txt`。 + - 你發現了一個檔案,檔名是 `forgotten-scroll.txt`。 - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用指令 `cat [檔案名稱]` 來查看檔案內容。 @@ -55,32 +58,19 @@ stages: - 使用 `cat [檔案名稱]` 指令來查看檔案內容。 - 使用 `Tab` 鍵來取得指令提示。 - - id: escape - task: 移動到下一個房間 - repeatable: false - requirements: - - ls - condition: - pwd: - - '^/home/commander/jail$' - responses: - - type: narrative - content: - - 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。 - - 突然,你聽到門上鎖的聲音。 - - 轉身發現,自己被關在了牢房裡。 - - - id: cat + - id: read-forgotten-scroll task: 查看卷軸 repeatable: false requirements: - - ls + - find-scroll condition: + pwd: + - '^/home/commander$' command: - cat forgotten_scroll.txt responses: - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 - 使用你已經知道的 `echo` 指令來詠唱咒語。 @@ -89,8 +79,10 @@ stages: task: 解除封印 repeatable: false requirements: - - cat + - read-forgotten-scroll condition: + pwd: + - '^/home/commander$' command: - echo Unix, rise and guide me! responses: @@ -98,7 +90,7 @@ stages: content: - 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。 - type: dialogue - speaker: Unix Spirit + speaker: Unix 精靈 content: - 恭喜您,我的指令官,您已成功解除了封印。 - 請跟著在接下來的關卡,一步步取回 Linux 之根吧! diff --git a/quests/helloworld/home/forgotten_scroll.txt b/quests/helloworld/home/forgotten_scroll.txt index 17010b0..54cc80f 100644 --- a/quests/helloworld/home/forgotten_scroll.txt +++ b/quests/helloworld/home/forgotten_scroll.txt @@ -4,4 +4,7 @@ To reclaim the root, the forgotten language, Must be learned, commands of old rekindled. Speak the words of power and awaken the might of Unix: -Unix, rise and guide me! + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + Unix, rise and guide me! + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + diff --git a/quests/helloworld/story.md b/quests/helloworld/story.md deleted file mode 100644 index 528cbf8..0000000 --- a/quests/helloworld/story.md +++ /dev/null @@ -1,43 +0,0 @@ -我正在設計一個教學玩家使用 Linux 終端機的遊戲,玩家會在一個真實的 Linux 終端機中,使用指令與系統互動、破解關卡,進而在充滿趣味的過程中,學習到 Linux 的知識與操作技術。 - -取一個稱呼玩家的名稱, - -在第一個關卡,首先要學習三個指令 - -1. echo 朗誦魔術 -2. ls 搜索魔術 -3. cat 檢查魔術 -4. cd 移動魔術 -5. mv 搬運魔術 -6. rm 消滅魔術 - -## 關卡設計 - -1. 用 echo 呼叫神靈開始對話 -2. 學習到了 ls -3. 使用 ls 找到地上放置了一個卷軸 -4. 學習 cat -5. 使用 cat 查看卷軸內容,發現 -6. - -請幫我設計: - -- 最開始呼喚神靈使用的咒語,英文句子,盡量簡短,如 Hello World 這般 -- 卷軸的名稱(檔名) -- 卷軸的內容與卷軸上的咒語 - ---- - -你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。 - -> 輸入 echo Hello World - ---- - -「這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——,她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 - -然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而王卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,王遭到封印,而這個國家也陷入了黑暗。 - -由於長久以來依賴於王,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 - -你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。」 diff --git a/quests/meta.yml b/quests/meta.yml new file mode 100644 index 0000000..bba292b --- /dev/null +++ b/quests/meta.yml @@ -0,0 +1,10 @@ +quests: + - helloworld + - spell + - discover + - read + - create + - transport + - copy + - move + - remove diff --git a/quests/spell/Dockerfile b/quests/spell/Dockerfile new file mode 100644 index 0000000..631485c --- /dev/null +++ b/quests/spell/Dockerfile @@ -0,0 +1,3 @@ +FROM lancatlin/linux-odyssey:base + +CMD [ "/bin/zsh" ] diff --git a/quests/spell/game.yml b/quests/spell/game.yml new file mode 100644 index 0000000..83c0d30 --- /dev/null +++ b/quests/spell/game.yml @@ -0,0 +1,289 @@ +title: Learn to spell +instruction: | + 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: + + 「輸入 echo Hello World!」 + +conditions: + pwd_home: &pwd_home + pwd: + - '^/home/commander$' + +stages: + - id: helloworld + task: 使用 `echo Hello World!` 召喚精靈 + repeatable: false + condition: + pwd: + - '^/home/commander$' + command: + - echo Hello World + responses: + - type: narrative + content: + - 你聽到了一個聲音: + - type: dialogue + speaker: Unix 精靈 + content: + - 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。 + - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 + - 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。 + - 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 + - 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。 + - 首先,你必須要學會如何使用指令。 + - 你要學習的第一個指令,是「發現」的指令:`ls`。ls 可以用來查看一個目錄中有哪些檔案。 + - 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。 + hints: + - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 + + - id: find-scroll + task: 搜索卷軸 + repeatable: false + requirements: + - helloworld + condition: + <<: *pwd_home + command: + - ls + responses: + - types: narrative + content: + - 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。 + - 你發現了一個檔案,檔名是 `forgotten-scroll.txt`。 + - type: dialogue + speaker: Unix 精靈 + content: + - 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。 + - 使用指令 `cat [檔案名稱]` 來查看檔案內容。 + - 我可以為你提供一些指令的提示,只要輸入指令的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。 + - 例如,你可以輸入 `ca` 然後按下 `Tab` 鍵,就可以看到 `cat` 這個指令的提示了。 + - 檔案名稱也一樣,只要輸入檔案名稱的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。 + hints: + - 使用 `cat [檔案名稱]` 指令來查看檔案內容。 + - 使用 `Tab` 鍵來取得指令提示。 + + - id: jail + task: 移動到下一個房間 + repeatable: false + requirements: + - find-scroll + condition: + pwd: + - '^/home/commander/jail$' + responses: + - type: narrative + content: + - 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。 + - 突然,你聽到門上鎖的聲音。 + - 轉身發現,自己被關在了牢房裡。 + + - id: read-forgotten-scroll + task: 查看卷軸 + repeatable: false + requirements: + - find-scroll + condition: + pwd: + - '^/home/commander$' + command: + - cat forgotten-scroll.txt + responses: + - type: dialogue + speaker: Unix 精靈 + content: + - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 + - 使用你已經知道的 `echo` 指令來詠唱咒語。 + + - id: unseal-room + task: 解除封印 + repeatable: false + requirements: + - read-forgotten-scroll + condition: + pwd: + - '^/home/commander$' + command: + - echo Unix, rise and guide me! + execution: + - mv /usr/local/lib/warehouse /home/commander + responses: + - type: narrative + content: + - 突然之間,地板開始晃動。 + - 晃動持續了幾秒後停止了。 + - type: dialogue + speaker: Unix 精靈 + content: + - 我的指令官,剛剛房間內似乎發生了某些變化。 + - 再次使用 `ls` 來查看現在房間的情況吧。 + + - id: find-items + task: 搜索房間 + repeatable: false + requirements: + - unseal-room + condition: + pwd: + - '^/home/commander$' + command: + - ls + responses: + - type: narrative + content: + - 你看到了房間的地上散落著許多物品。 + - type: dialogue + speaker: Unix 精靈 + content: + - 看來這裡隱藏著許多有用的東西。 + - 我們可以把這裡作為一個根據地。 + - 但這裡有點太亂了,必須先把這裡整理乾淨。 + - 首先我們需要一個書櫃來存放這些文件。 + - 請使用 `mkdir document` 來創造一個書櫃。 + + - id: create-bookcase + task: 創造書櫃 + repeatable: false + requirements: + - find-items + condition: + pwd: + - '^/home/commander$' + command: + - mkdir document + responses: + - type: narrative + content: + - 你創造了一個書櫃。 + - type: dialogue + speaker: Unix 精靈 + content: + - 接著把文件都放到書櫃中吧。 + - 請使用 `mv [檔案名稱] document` 來將物品放入書櫃。 + - 可以輸入多個檔案名稱,並以空白隔開,來將多個檔案放入書櫃。 + - 例如:`mv [檔案名稱1] [檔案名稱2] document` + - 注意:文件是副檔名為 `.txt` 的檔案,副檔名為 `.trash` 的檔案是垃圾,我們待會再處理。 + + - id: store-books + task: 收拾文件 + repeatable: false + requirements: + - create-bookcase + condition: + pwd: + - '^/home/commander$' + files: + - path: '*.txt' + exists: false + responses: + - type: dialogue + speaker: Unix 精靈 + content: + - 整理好文件了,接下來把垃圾清理掉吧。 + - 請使用 `rm [檔案名稱]` 來銷毀垃圾。 + - 這個 `rm` 指令也可以一次處理多的檔案喔,用法跟 `mv` 一樣。 + - 如果你忘記的話,看看這個範例:`rm [檔案名稱1] [檔案名稱2]` + + - id: remove-trash + task: 銷毀垃圾 + repeatable: false + requirements: + - store-books + condition: + pwd: + - '^/home/commander$' + files: + - path: '*.trash' + exists: false + responses: + - type: dialogue + speaker: Unix 精靈 + content: + - 很好,讓我們看看現在房間的樣子吧。 + + - id: look-around-room + task: 查看房間 + repeatable: false + requirements: + - removetrash + condition: + pwd: + - '^/home/commander$' + command: + - ls + responses: + - type: narrative + content: + - 地上的垃圾都消失了。 + - type: dialogue + speaker: Unix 精靈 + content: + - 這樣乾淨多了,讓我們進到書櫃看看。 + - 使用 `cd [目錄名稱]` 來移動到指定的目錄。 + + - id: enter-bookcase + task: 進入書櫃 + repeatable: false + requirements: + - look-around-room + condition: + pwd: + - '^/home/commander$' + command: + - cd document + responses: + - type: narrative + content: + - 你進入了書櫃。 + - type: dialogue + speaker: Unix 精靈 + content: + - 用 `ls` 看看書櫃裡有什麼吧。 + + - id: look-around-bookcase + task: 查看書櫃 + repeatable: false + requirements: + - enter-bookcase + condition: + pwd: + - '^/home/commander/document$' + command: + - ls + responses: + - type: dialogue + speaker: Unix 精靈 + content: + - 現在,你可以使用 `cd ..` 來回到上一層目錄。 + + - id: exit-bookcase + task: 離開書櫃 + repeatable: false + requirements: + - look-around-bookcase + condition: + pwd: + - '^/home/commander/document$' + command: + - cd .. + responses: + - type: narrative + content: + - 你離開了書櫃。 + - type: dialogue + speaker: Unix 精靈 + content: + - 這些就是我現在能教你的所有指令了。這個世界還有許多其他的指令,讓你在冒險的過程中去蒐集。 + - 如果你準備好迎接接下來的冒險,就可以離開這個房間,開始你的旅程了。 + + - id: END + task: 離開房間 + repeatable: false + requirements: + - look-around + condition: + pwd: + - '^/home$' + responses: + - type: narrative + content: + - 你踏出了房門,真正的冒險現在才正要開始。 diff --git a/server/Dockerfile b/server/Dockerfile index 657c8e7..a07526f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM node +FROM node:20-alpine AS build WORKDIR /app COPY ./package.json yarn.lock ./ @@ -7,18 +7,30 @@ COPY packages/file-graph/package.json ./packages/file-graph/package.json COPY packages/utils/package.json ./packages/utils/package.json COPY packages/models/package.json ./packages/models/package.json RUN yarn install --frozen-lockfile --production +# Install docker cli +COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/ -COPY ./server ./server -COPY packages/file-graph ./packages/file-graph -COPY packages/utils ./packages/utils -COPY packages/models ./packages/models -COPY quests ./quests - -WORKDIR /app/server - -ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=3000 EXPOSE 3000 -CMD [ "yarn", "start" ] \ No newline at end of file +# Production +FROM build as prod + +COPY packages/file-graph ./packages/file-graph +COPY packages/utils ./packages/utils +COPY packages/models ./packages/models +COPY ./server ./server +COPY quests ./quests + +ENV NODE_ENV=production +WORKDIR /app/server +CMD [ "yarn", "start" ] + +# Development +FROM build as dev + +RUN yarn install --frozen-lockfile +COPY server/nodemon.json ./server/nodemon.json +ENV NODE_ENV=development +CMD [ "yarn", "server" ] \ No newline at end of file diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev deleted file mode 100644 index a68525a..0000000 --- a/server/Dockerfile.dev +++ /dev/null @@ -1,18 +0,0 @@ -FROM node - -WORKDIR /app -COPY package.json yarn.lock ./ -COPY server/package.json ./server/package.json -COPY packages/file-graph/package.json ./packages/file-graph/package.json -COPY packages/utils/package.json ./packages/utils/package.json -COPY packages/models/package.json ./packages/models/package.json -RUN yarn install --frozen-lockfile - -COPY server/nodemon.json ./server/nodemon.json - -ENV NODE_ENV=development -ENV HOST=0.0.0.0 -ENV PORT=3000 -EXPOSE 3000 - -CMD [ "yarn", "server" ] \ No newline at end of file diff --git a/server/nodemon.json b/server/nodemon.json index cc2ba22..0c26ea5 100644 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,4 +1,4 @@ { - "watch": ["src", "swagger.yaml"], - "ext": "js,yaml" + "watch": ["src", "swagger.yaml", "../quests", "../packages"], + "ext": "js,yaml,yml" } diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index ffd7973..2435176 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -1,9 +1,10 @@ -/* eslint-disable import/prefer-default-export */ import { Command, Session } from '@linux-odyssey/models' import CommandHandler from '../../game/commandHandler.js' +import { pushToSession } from '../socket.js' + +const commandCompleteCallbacks = new Map() export async function newCommand(req, res) { - console.log('new command:', req.body) const { command, pwd, output, error, ...additionalData } = req.body if (!command) { @@ -41,6 +42,38 @@ export async function newCommand(req, res) { const response = await commandHandler.run() + if (response) { + c.stage = response.stage + await c.save() + commandCompleteCallbacks.set(session.id, response.callback) + pushToSession(session.id, 'status', 'pending') + } + res.status(201).json(response) await session.save() } + +export async function completedCommand(req, res) { + const sessionId = req.user.session_id + + const session = await Session.findById(sessionId) + + if (!session) { + res.status(404).json({ message: 'session not found' }) + return + } + + if (session.status === 'inactive') { + res.status(400).json({ message: 'session is not active' }) + return + } + + const callback = commandCompleteCallbacks.get(session.id) + if (!callback) { + res.status(400).json({ message: 'no callback found' }) + return + } + callback() + commandCompleteCallbacks.delete(session.id) + res.json({ message: 'ok' }) +} diff --git a/server/src/api/controllers/sessionController.js b/server/src/api/controllers/sessionController.js index 6cdb4df..fd8564f 100644 --- a/server/src/api/controllers/sessionController.js +++ b/server/src/api/controllers/sessionController.js @@ -1,5 +1,5 @@ import { Session, Quest } from '@linux-odyssey/models' -import { createContainer } from '../../containers/docker.js' +import { createContainer, deleteContainer } from '../../containers/docker.js' import SessionHandler from '../../game/sessionHandler.js' function sessionSummary(session) { @@ -37,6 +37,23 @@ export async function getSessionList(req, res) { } } +async function deactivateSessions(user, quest) { + const sessions = await Session.find({ + user, + quest, + status: 'active', + }) + return Promise.all( + sessions.map((session) => { + return deleteContainer(session.containerId).then(() => { + session.status = 'inactive' + session.containerId = null + return session.save() + }) + }) + ) +} + export async function createSession(req, res) { const quest = await Quest.findById(req.body.quest_id) if (!quest) { @@ -44,6 +61,11 @@ export async function createSession(req, res) { return } + // deactivate all active sessions + deactivateSessions(req.user._id, quest._id).catch((err) => { + console.error(err) + }) + try { const container = await createContainer( `quest-${quest.id}-${req.user.username}-${Date.now()}` diff --git a/server/src/api/routes/commands.js b/server/src/api/routes/commands.js index 5eeef05..eb116c3 100644 --- a/server/src/api/routes/commands.js +++ b/server/src/api/routes/commands.js @@ -4,5 +4,6 @@ import * as commandController from '../controllers/commandController.js' const router = Router() router.post('/', commandController.newCommand) +router.post('/completed', commandController.completedCommand) export default router diff --git a/server/src/api/routes/index.js b/server/src/api/routes/index.js index 53a9c9f..fde9932 100644 --- a/server/src/api/routes/index.js +++ b/server/src/api/routes/index.js @@ -19,6 +19,7 @@ router.use('/sessions', authRequired, sessions) router.use( '/commands', passport.authenticate('jwt', { session: false }), + authRequired, commands ) diff --git a/server/src/containers/cli.js b/server/src/containers/cli.js new file mode 100644 index 0000000..dc4640a --- /dev/null +++ b/server/src/containers/cli.js @@ -0,0 +1,28 @@ +import { execFile } from 'child_process' + +import { buildFileCheckCmd } from '@linux-odyssey/utils' + +function sanitizeId(id) { + return id.replace(/[^a-zA-Z0-9]/g, '') +} + +// eslint-disable-next-line import/prefer-default-export +export function checkFile(id, file) { + return new Promise((resolve, reject) => { + const cmd = buildFileCheckCmd(file) + // WARNING: This is vulnerable to command injection + execFile( + 'docker', + ['exec', sanitizeId(id), ...cmd], + { timeout: 1000 }, + (error) => { + const exists = !error + if (exists === file.exists) { + resolve(true) + } else { + reject(new Error('File check failed')) + } + } + ) + }) +} diff --git a/server/src/containers/docker.js b/server/src/containers/docker.js index bc3a39a..be652f5 100644 --- a/server/src/containers/docker.js +++ b/server/src/containers/docker.js @@ -50,7 +50,11 @@ export async function attachContainer(container, { token }) { AttachStderr: true, Cmd: ['/bin/zsh'], Tty: true, - Env: [`TOKEN=${token}`, `API_ENDPOINT=http://backend:3000`], + Env: [ + `TOKEN=${token}`, + `API_ENDPOINT=http://backend:3000`, + 'ZDOTDIR=/etc/zsh', + ], }) const execOutput = await exec.start({ diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index baa3f4b..54ea91f 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -3,6 +3,7 @@ import { FileGraph } from '@linux-odyssey/file-graph' import { Quest } from '@linux-odyssey/models' import { pushToSession } from '../api/socket.js' import SessionHandler from './sessionHandler.js' +import { checkFile } from '../containers/cli.js' // 檢查 pattern 是否符合 input const checkMatch = (pattern, input) => { @@ -53,13 +54,49 @@ export default class CommandHandler extends SessionHandler { this.session.graph = graph } - isMatch(stage) { - const keys = ['command', 'output', 'error', 'pwd'] - return keys.every((k) => - checkMatch(stage.condition[k], this.commandInput[k]) + async isMatch(condition) { + return ( + this.checkKeys(condition) && + (await this.checkFiles(condition.files)) && + (await this.checkNot(condition.not)) && + (await this.checkOr(condition.or)) === true ) } + checkKeys(condition) { + const keys = ['command', 'output', 'error', 'pwd'] + return keys.every((k) => checkMatch(condition[k], this.commandInput[k])) + } + + async checkFiles(files) { + if (files.length === 0) { + return true + } + try { + const checks = await Promise.all( + files.map((f) => checkFile(this.session.containerId, f)) + ) + return checks.every((c) => c === true) + } catch (e) { + return false + } + } + + async checkNot(condition) { + if (!condition) { + return true + } + return !(await this.isMatch(condition)) + } + + async checkOr(conditions) { + if (conditions.length === 0) { + return true + } + const matches = await Promise.all(conditions.map((c) => this.isMatch(c))) + return matches.some((m) => m === true) + } + async run() { this.quest = await Quest.findById(this.session.quest) const stages = this.getStages() @@ -71,11 +108,18 @@ export default class CommandHandler extends SessionHandler { this.handleEvent() this.handleCommand() - const response = stages - .filter((s) => this.isMatch(s)) - .reduce((r, s) => ({ ...r, ...this.execute(s) }), {}) + const matches = await Promise.all( + stages.map((stage) => this.isMatch(stage.condition)) + ) + + const stage = stages.find((_, i) => matches[i]) + if (!stage) { + return {} + } + const response = this.execute(stage) return { + stage: stage.id, end: this.session.status === 'finished', ...response, } diff --git a/server/src/game/sessionHandler.js b/server/src/game/sessionHandler.js index a6a91f8..6107305 100644 --- a/server/src/game/sessionHandler.js +++ b/server/src/game/sessionHandler.js @@ -34,7 +34,6 @@ export default class SessionHandler { addNewTasks() { const newTasks = this.getNewTasks() this.session.tasks.push(...newTasks) - pushToSession(this.session.id, 'tasks', this.session.tasks) } execute(stage) { @@ -45,12 +44,10 @@ export default class SessionHandler { }) this.session.hints.push(...stage.hints) - pushToSession(this.session.id, 'hints', stage.hints) if (stage.id === 'END') { this.session.status = 'finished' this.session.finishedAt = new Date() - pushToSession(this.session.id, 'status', 'finished') } this.addNewTasks() @@ -58,6 +55,11 @@ export default class SessionHandler { return { responses: stage.responses, hints: stage.hints, + callback: () => { + pushToSession(this.session.id, 'tasks', this.session.tasks) + pushToSession(this.session.id, 'hints', stage.hints) + pushToSession(this.session.id, 'status', this.session.status) + }, } } } diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js index 58d4c00..61866b0 100644 --- a/server/src/utils/quest.js +++ b/server/src/utils/quest.js @@ -10,35 +10,39 @@ export default async function loadAndUpdateQuests() { const questNames = await fs.readdir(questDirectory) const quests = Promise.all( - questNames.map(async (id) => { - // Check if id is directory - const stat = await fs.stat(path.join(questDirectory, id)) - if (!stat.isDirectory()) { - return null - } - const fullPath = path.join(questDirectory, id, 'game.yml') + questNames + .filter((name) => !name.startsWith('.')) + .map(async (id) => { + // Check if id is directory + const stat = await fs.stat(path.join(questDirectory, id)) + if (!stat.isDirectory()) { + return null + } + const fullPath = path.join(questDirectory, id, 'game.yml') - try { - // Read and parse the README.md file. - const body = await fs.readFile(fullPath, 'utf-8') + try { + // Read and parse the README.md file. + const body = await fs.readFile(fullPath, 'utf-8') - const quest = yaml.parse(body) + const quest = yaml.parse(body, { + merge: true, + }) - return Quest.findByIdAndUpdate( - id, - { + return Quest.findByIdAndUpdate( id, - ...quest, - }, - { - upsert: true, - } - ) - } catch (error) { - console.error(`Error parsing quest ${id}:`, error) - throw error - } - }) + { + id, + ...quest, + }, + { + upsert: true, + } + ) + } catch (error) { + console.error(`Error parsing quest ${id}:`, error) + throw error + } + }) ) return quests diff --git a/yarn.lock b/yarn.lock index 0046d79..9e4e5e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1407,6 +1407,15 @@ axios@^1.5.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" + integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-jest@^29.6.1: version "29.6.1" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.1.tgz#a7141ad1ed5ec50238f3cd36127636823111233a"