From 12a42d117c3def627b75782a9c9657ea4b317912 Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 1 Aug 2023 17:57:53 +0800 Subject: [PATCH 01/13] feat: write story of the game --- server/quests/helloworld/story.md | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 server/quests/helloworld/story.md diff --git a/server/quests/helloworld/story.md b/server/quests/helloworld/story.md new file mode 100644 index 0000000..66a2a21 --- /dev/null +++ b/server/quests/helloworld/story.md @@ -0,0 +1,40 @@ +我正在設計一個教學玩家使用 Linux 終端機的遊戲,玩家會在一個真實的 Linux 終端機中,使用指令與系統互動、破解關卡,進而在充滿趣味的過程中,學習到 Linux 的知識與操作技術。 + +取一個稱呼玩家的名稱, + +在第一個關卡,首先要學習三個指令 + +1. echo 朗誦魔術 +2. ls 搜索魔術 +3. cat 檢察魔術 + +## 關卡設計 + +1. 用 echo 呼叫神靈開始對話 +2. 學習到了 ls +3. 使用 ls 找到地上放置了一個卷軸 +4. 學習 cat +5. 使用 cat 查看卷軸內容 +6. 用 echo 念出卷軸上的咒語 + +請幫我設計: + +- 最開始呼喚神靈使用的咒語,英文句子,盡量簡短,如 Hello World 這般 +- 卷軸的名稱(檔名) +- 卷軸的內容與卷軸上的咒語 + +--- + +你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。 + +> 輸入 echo Hello World + +--- + +「這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——,她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 + +然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而王卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,王遭到封印,而這個國家也陷入了黑暗。 + +由於長久以來依賴於王,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 + +你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。」 From e2e585329fd8b8de679f4e7f430f74e76f877e3b Mon Sep 17 00:00:00 2001 From: wancat Date: Thu, 3 Aug 2023 18:04:22 +0800 Subject: [PATCH 02/13] Create a pty cli, not useful --- packages/cli/index.js | 50 +++++++++++++++++++++++++++++++++++++++ packages/cli/package.json | 10 ++++++++ yarn.lock | 16 +++++++++---- 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 packages/cli/index.js create mode 100644 packages/cli/package.json diff --git a/packages/cli/index.js b/packages/cli/index.js new file mode 100644 index 0000000..1e87ff1 --- /dev/null +++ b/packages/cli/index.js @@ -0,0 +1,50 @@ +import { stdin, stdout, exit } from 'process' +import os from 'os' +import pty from 'node-pty' + +stdin.setRawMode(true) +stdin.resume() +stdin.setEncoding('utf8') + +// Create a new pseudoterminal +const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash' +const ptyProcess = pty.spawn(shell, [], { + name: 'xterm-color', + cols: 80, + rows: 30, + cwd: process.env.HOME, + env: process.env, +}) + +// Listen for output data +ptyProcess.on('data', function (data) { + // Intercept and process the output here + stdout.write(data) +}) + +// Write a command to the pseudoterminal +ptyProcess.write('ls\r') + +let commandBuffer = '' + +stdin.on('data', (key) => { + if (key === '\u0004') { + exit() + } else if (key === '\r') { + // User pressed enter, log and execute the command + console.log('User command: ', commandBuffer) + commandBuffer = '' + } else if (key === '\u007F') { + // User pressed backspace, remove the last character from the buffer + commandBuffer = commandBuffer.slice(0, -1) + } else { + // Add the typed character to the command buffer + commandBuffer += key + } + ptyProcess.write(key) +}) + +// Listen for exit event +ptyProcess.on('exit', function (exitCode) { + console.log('Process exited with code: ', exitCode) +}) diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..85f8223 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,10 @@ +{ + "name": "cli", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "type": "module", + "dependencies": { + "node-pty": "^1.0.0" + } +} diff --git a/yarn.lock b/yarn.lock index dc22e73..88291a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1538,10 +1538,6 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -commander@^11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" - integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -1549,6 +1545,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" + integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== + commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -3611,6 +3612,13 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== +node-pty@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd" + integrity sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA== + dependencies: + nan "^2.17.0" + node-releases@^2.0.12: version "2.0.13" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" From 0153eae6737ff51ab9ebb0c559a83f4eee5383b6 Mon Sep 17 00:00:00 2001 From: wancat Date: Fri, 4 Aug 2023 21:21:54 +0800 Subject: [PATCH 03/13] feat: new cli client to send and receive msg from api --- docker-compose.build.yml | 7 +++ packages/cli/cli.js | 70 +++++++++++++++++++++++++++ server/quests/helloworld/Dockerfile | 6 ++- server/quests/helloworld/home/.bashrc | 20 +++----- server/src/utils/docker.js | 7 ++- 5 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 docker-compose.build.yml create mode 100644 packages/cli/cli.js diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..c3bcd77 --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,7 @@ +version: '3' +services: + helloworld: + image: lancatlin/linux-odyssey:helloworld + build: + context: . + dockerfile: server/quests/helloworld/Dockerfile diff --git a/packages/cli/cli.js b/packages/cli/cli.js new file mode 100644 index 0000000..f42d495 --- /dev/null +++ b/packages/cli/cli.js @@ -0,0 +1,70 @@ +const readline = require('readline') + +process.stdin.setRawMode(true) + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, +}) + +async function printResponses(responses, delay = 100) { + for (const response of responses) { + await printResponse(response, delay) + } + rl.close() +} + +function waitForEnter() { + return new Promise((resolve) => { + rl.once('line', resolve) + }) +} + +function printResponse(response, delay) { + return new Promise((resolve) => { + let i = 0 + const timer = setInterval(() => { + process.stdout.write(response[i]) + i += 1 + if (i >= response.length) { + clearInterval(timer) + waitForEnter().then(resolve) + } + }, delay) + + waitForEnter().then(() => { + clearInterval(timer) + process.stdout.write(response.slice(i) + '\n') + resolve() + }) + }) +} + +async function request() { + const { API_ENDPOINT, TOKEN, PWD, COMMAND, OUTPUT, EXIT_CODE } = process.env + const payload = { + command: COMMAND, + output: OUTPUT, + exit_code: EXIT_CODE, + token: TOKEN, + pwd: PWD, + } + try { + const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + const data = await res.json() + if (data.responses) { + await printResponses(data.responses, 100) + } + } catch (err) { + console.error(err) + } +} + +request() diff --git a/server/quests/helloworld/Dockerfile b/server/quests/helloworld/Dockerfile index 01f3814..40265a8 100644 --- a/server/quests/helloworld/Dockerfile +++ b/server/quests/helloworld/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:latest +FROM node:latest RUN apt-get update && apt-get install -y \ curl \ @@ -11,6 +11,8 @@ USER rudeus WORKDIR /home/rudeus -COPY ./home /home/rudeus/ +COPY ./server/quests/helloworld/home /home/rudeus/ + +COPY ./packages/cli/cli.js /usr/local/bin/cli.js CMD [ "/bin/bash" ] diff --git a/server/quests/helloworld/home/.bashrc b/server/quests/helloworld/home/.bashrc index 3a0355f..39e6094 100644 --- a/server/quests/helloworld/home/.bashrc +++ b/server/quests/helloworld/home/.bashrc @@ -2,26 +2,20 @@ preexec() { [ -n "$COMP_LINE" ] && return # do nothing if completing [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return # do nothing if completing - local command="$BASH_COMMAND" - local pwd="$PWD" + export COMMAND="$BASH_COMMAND" - if [[ $command =~ ^(vim|top|less|more|nano) ]]; then - local payload=$(printf '{"token": "%s", "pwd": "%s", "command": "%s"}' "$TOKEN" "$pwd" "$command") - curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload" + if [[ $COMMAND =~ ^(vim|top|less|more|nano) ]]; then + node /usr/local/bin/cli.js return fi # Execute the command, capture output and exit code - local output=$( { eval $command 2>&1; echo $? > /tmp/exit_code; }) - local exit_code=$(cat /tmp/exit_code) + export OUTPUT=$( { eval $COMMAND 2>&1; echo $? > /tmp/exit_code; }) + export EXIT_CODE=$(cat /tmp/exit_code) - local output_esc=$(echo $output | jq -Rs .) - - # Store command in variable - local payload=$(printf '{"token": "%s", "pwd": "%s", "command": "%s", "exit_code": "%s", "output": %s}' "$TOKEN" "$pwd" "$command" "$exit_code" "$output_esc") - + echo $OUTPUT # Send command to the API - curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload" || echo $payload + node /usr/local/bin/cli.js BASH_COMMAND="" } diff --git a/server/src/utils/docker.js b/server/src/utils/docker.js index 50fe918..cf7ee87 100644 --- a/server/src/utils/docker.js +++ b/server/src/utils/docker.js @@ -3,7 +3,7 @@ import config from '../config.js' const engine = new Docker() -const questPath = '/home/zeko/src/linux-odyssey/server/quests' +const projectPath = '/home/zeko/src/linux-odyssey' const containerOptions = { Image: 'lancatlin/linux-odyssey:helloworld', @@ -17,7 +17,10 @@ const containerOptions = { HostConfig: { Binds: config.isProduction ? [] - : [`${questPath}/helloworld/home:/home/rudeus`], + : [ + `${projectPath}/server/quests/helloworld/home:/home/rudeus`, + `${projectPath}/packages/cli/cli.js:/usr/local/bin/cli.js`, + ], }, } From 6204bd0b1739cc5299766616e1bc20d95dcae07b Mon Sep 17 00:00:00 2001 From: wancat Date: Mon, 7 Aug 2023 18:54:28 +0800 Subject: [PATCH 04/13] feat: change to use Zsh * show message from server response * send output and error to server --- packages/cli/cli.js | 56 ++++++++++++++----- packages/cli/index.js | 50 ----------------- packages/cli/package.json | 5 +- server/quests/helloworld/Dockerfile | 5 +- server/quests/helloworld/home/.bashrc | 24 -------- server/quests/helloworld/home/.zshrc | 36 ++++++++++++ .../src/api/controllers/commandController.js | 9 ++- server/src/utils/docker.js | 3 +- server/src/utils/quest.js | 5 ++ yarn.lock | 7 --- 10 files changed, 96 insertions(+), 104 deletions(-) delete mode 100644 packages/cli/index.js delete mode 100644 server/quests/helloworld/home/.bashrc create mode 100644 server/quests/helloworld/home/.zshrc diff --git a/packages/cli/cli.js b/packages/cli/cli.js index f42d495..b170f09 100644 --- a/packages/cli/cli.js +++ b/packages/cli/cli.js @@ -1,5 +1,10 @@ +/* eslint-disable no-restricted-syntax */ +const fs = require('fs').promises +const { exit } = require('process') const readline = require('readline') +const RETURN_SYMBOL = '\u21B5' + process.stdin.setRawMode(true) const rl = readline.createInterface({ @@ -8,13 +13,6 @@ const rl = readline.createInterface({ terminal: false, }) -async function printResponses(responses, delay = 100) { - for (const response of responses) { - await printResponse(response, delay) - } - rl.close() -} - function waitForEnter() { return new Promise((resolve) => { rl.once('line', resolve) @@ -35,18 +33,48 @@ function printResponse(response, delay) { waitForEnter().then(() => { clearInterval(timer) - process.stdout.write(response.slice(i) + '\n') + process.stdout.write(response.slice(i)) + process.stdout.write('\n') resolve() }) }) } -async function request() { - const { API_ENDPOINT, TOKEN, PWD, COMMAND, OUTPUT, EXIT_CODE } = process.env +async function printResponses(responses, delay = 100) { + for (const response of responses) { + // eslint-disable-next-line no-await-in-loop + await printResponse(response + RETURN_SYMBOL, delay) + } + rl.close() +} + +async function readOrNone(file) { + try { + return await fs.readFile(file, 'utf8') + } catch (err) { + return '' + } +} + +async function main() { + const { + API_ENDPOINT, + TOKEN, + PWD, + CMD_NAME, + CMD_OUTPUT_FILE, + CMD_ERROR_FILE, + CMD_EXIT_CODE, + } = process.env + if (!CMD_NAME) exit(0) + + const output = await readOrNone(CMD_OUTPUT_FILE) + const error = await readOrNone(CMD_ERROR_FILE) const payload = { - command: COMMAND, - output: OUTPUT, - exit_code: EXIT_CODE, + command: CMD_NAME, + output, + error, + exit_code: CMD_EXIT_CODE, token: TOKEN, pwd: PWD, } @@ -67,4 +95,4 @@ async function request() { } } -request() +main() diff --git a/packages/cli/index.js b/packages/cli/index.js deleted file mode 100644 index 1e87ff1..0000000 --- a/packages/cli/index.js +++ /dev/null @@ -1,50 +0,0 @@ -import { stdin, stdout, exit } from 'process' -import os from 'os' -import pty from 'node-pty' - -stdin.setRawMode(true) -stdin.resume() -stdin.setEncoding('utf8') - -// Create a new pseudoterminal -const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash' -const ptyProcess = pty.spawn(shell, [], { - name: 'xterm-color', - cols: 80, - rows: 30, - cwd: process.env.HOME, - env: process.env, -}) - -// Listen for output data -ptyProcess.on('data', function (data) { - // Intercept and process the output here - stdout.write(data) -}) - -// Write a command to the pseudoterminal -ptyProcess.write('ls\r') - -let commandBuffer = '' - -stdin.on('data', (key) => { - if (key === '\u0004') { - exit() - } else if (key === '\r') { - // User pressed enter, log and execute the command - console.log('User command: ', commandBuffer) - commandBuffer = '' - } else if (key === '\u007F') { - // User pressed backspace, remove the last character from the buffer - commandBuffer = commandBuffer.slice(0, -1) - } else { - // Add the typed character to the command buffer - commandBuffer += key - } - ptyProcess.write(key) -}) - -// Listen for exit event -ptyProcess.on('exit', function (exitCode) { - console.log('Process exited with code: ', exitCode) -}) diff --git a/packages/cli/package.json b/packages/cli/package.json index 85f8223..d8c4598 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,8 +3,5 @@ "version": "1.0.0", "main": "index.js", "license": "MIT", - "type": "module", - "dependencies": { - "node-pty": "^1.0.0" - } + "dependencies": {} } diff --git a/server/quests/helloworld/Dockerfile b/server/quests/helloworld/Dockerfile index 40265a8..8a8942d 100644 --- a/server/quests/helloworld/Dockerfile +++ b/server/quests/helloworld/Dockerfile @@ -3,9 +3,10 @@ FROM node:latest RUN apt-get update && apt-get install -y \ curl \ vim \ + zsh \ jq -RUN adduser --disabled-password --gecos '' rudeus +RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus USER rudeus @@ -15,4 +16,4 @@ COPY ./server/quests/helloworld/home /home/rudeus/ COPY ./packages/cli/cli.js /usr/local/bin/cli.js -CMD [ "/bin/bash" ] +CMD [ "/bin/zsh" ] diff --git a/server/quests/helloworld/home/.bashrc b/server/quests/helloworld/home/.bashrc deleted file mode 100644 index 39e6094..0000000 --- a/server/quests/helloworld/home/.bashrc +++ /dev/null @@ -1,24 +0,0 @@ -# This function will be called before each command is executed -preexec() { - [ -n "$COMP_LINE" ] && return # do nothing if completing - [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return # do nothing if completing - export COMMAND="$BASH_COMMAND" - - if [[ $COMMAND =~ ^(vim|top|less|more|nano) ]]; then - node /usr/local/bin/cli.js - return - fi - - # Execute the command, capture output and exit code - export OUTPUT=$( { eval $COMMAND 2>&1; echo $? > /tmp/exit_code; }) - export EXIT_CODE=$(cat /tmp/exit_code) - - echo $OUTPUT - # Send command to the API - node /usr/local/bin/cli.js - - BASH_COMMAND="" -} - -# Install the trap -trap 'preexec' DEBUG diff --git a/server/quests/helloworld/home/.zshrc b/server/quests/helloworld/home/.zshrc new file mode 100644 index 0000000..9369e80 --- /dev/null +++ b/server/quests/helloworld/home/.zshrc @@ -0,0 +1,36 @@ +PROMPT='%n@%m:%~%# ' +unsetopt CASE_GLOB +setopt CHASE_LINKS +emulate bash + +typeset -A cmd + +preexec() { + export CMD_START_TIME=$(date +%s%N) # capture start time + export CMD_NAME=$1 # capture command + + # A list of interactive commands + interactive_commands=("vim" "nano" "top" "ssh" "less" "zsh" "bash") + + # Check if the command is in the list of interactive commands + if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then + # If it is, just execute it normally + eval $1 + else + # If it's not, execute it and redirect its output to a file + eval $1 > /tmp/cmd.out 2>/tmp/cmd.error + fi +} + +precmd() { + export CMD_END_TIME=$(date +%s%N) # capture end time + export CMD_EXIT_CODE=$? # capture exit status + export CMD_OUTPUT_FILE=/tmp/cmd.out # read command output from file + export CMD_ERROR_FILE=/tmp/cmd.error + + # Now run your Node.js script with the result as an argument + node /usr/local/bin/cli.js + + rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null # cleanup +} + diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index a886d23..7cfbb0c 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -7,6 +7,11 @@ export async function newCommand(req, res) { console.log('new command:', req.body) const { token, command, pwd, exit_code, output } = req.body + if (!command) { + res.status(400).json({ message: 'command is required' }) + return + } + let sessionId try { const decoded = await verifySessionJWT(token) @@ -33,5 +38,7 @@ export async function newCommand(req, res) { await c.save() - res.status(201).end() + res.status(201).json({ + responses: ['Hello, world!', 'How are you?'], + }) } diff --git a/server/src/utils/docker.js b/server/src/utils/docker.js index cf7ee87..730a6e1 100644 --- a/server/src/utils/docker.js +++ b/server/src/utils/docker.js @@ -11,7 +11,6 @@ const containerOptions = { AttachStdout: true, AttachStderr: true, Tty: true, - Cmd: ['/bin/bash'], OpenStdin: true, StdinOnce: false, HostConfig: { @@ -59,7 +58,7 @@ export async function attachContainer(container, { token }) { AttachStdin: true, AttachStdout: true, AttachStderr: true, - Cmd: ['/bin/bash'], + Cmd: ['/bin/zsh'], Tty: true, Env: [`TOKEN=${token}`, `API_ENDPOINT=http://app:3000`], }) diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js index 73f55bc..6ff8a40 100644 --- a/server/src/utils/quest.js +++ b/server/src/utils/quest.js @@ -17,6 +17,11 @@ export default async function loadAndUpdateQuests() { 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, 'README.md') try { diff --git a/yarn.lock b/yarn.lock index 88291a4..2a3ac1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3612,13 +3612,6 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== -node-pty@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd" - integrity sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA== - dependencies: - nan "^2.17.0" - node-releases@^2.0.12: version "2.0.13" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" From 0de4b6ac9d113ef6cba054aa6c1f914b70852168 Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 16:18:22 +0800 Subject: [PATCH 05/13] feat: design game file and parse it to mongo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 取消用 README 來寫關卡說明 --- server/quests/characters.yml | 4 ++++ server/quests/helloworld/game.yml | 21 ++++++++++++++++++++ server/src/models/quest.js | 32 ++++++++++++++++++++++++++++++- server/src/utils/quest.js | 24 +++++------------------ 4 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 server/quests/characters.yml create mode 100644 server/quests/helloworld/game.yml diff --git a/server/quests/characters.yml b/server/quests/characters.yml new file mode 100644 index 0000000..28c0b3a --- /dev/null +++ b/server/quests/characters.yml @@ -0,0 +1,4 @@ +characters: + savantia: + en: Savantia + zh: 薩凡媞雅 diff --git a/server/quests/helloworld/game.yml b/server/quests/helloworld/game.yml new file mode 100644 index 0000000..8db3f1e --- /dev/null +++ b/server/quests/helloworld/game.yml @@ -0,0 +1,21 @@ +title: Hello, Linux World! +order: 1 +introduction: | + 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: + + 「輸入 echo Hello World!」 +stages: + - name: helloworld + condition: + command: + - echo Hello World! + responses: + - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 + - 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。 + - 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 + - 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。 + - 首先,你必須要學會如何使用指令。 + - 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。 + - 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。 + hint: + - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 diff --git a/server/src/models/quest.js b/server/src/models/quest.js index 753eb7f..d16804d 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -1,12 +1,42 @@ import { model, Schema } from 'mongoose' +const stageSchema = new Schema({ + name: { + type: String, + required: true, + }, + condition: { + command: { + type: [String], + required: false, + }, + output: { + type: [String], + required: false, + }, + error: { + type: [String], + required: false, + }, + }, + responses: { + type: [String], + required: true, + }, + hint: { + type: [String], + required: true, + }, +}) + const Quest = model( 'Quest', new Schema({ _id: String, title: String, order: Number, - content: String, + introduction: String, + stages: [stageSchema], }) ) diff --git a/server/src/utils/quest.js b/server/src/utils/quest.js index 6ff8a40..e8a2276 100644 --- a/server/src/utils/quest.js +++ b/server/src/utils/quest.js @@ -1,15 +1,9 @@ import fs from 'fs/promises' import path from 'path' -import { Marked } from 'marked' import yaml from 'yaml' import Quest from '../models/quest.js' -const marked = new Marked({ - mangle: false, - headerIds: false, -}) - const questDirectory = path.join(process.cwd(), 'quests') export default async function loadAndUpdateQuests() { @@ -22,27 +16,19 @@ export default async function loadAndUpdateQuests() { if (!stat.isDirectory()) { return null } - const fullPath = path.join(questDirectory, id, 'README.md') + const fullPath = path.join(questDirectory, id, 'game.yml') try { // Read and parse the README.md file. - const markdown = await fs.readFile(fullPath, 'utf-8') + const body = await fs.readFile(fullPath, 'utf-8') - // Extract and parse YAML frontmatter. - const match = /---\n([\s\S]+?)\n---/.exec(markdown) - const metadata = yaml.parse(match[1]) - const content = markdown.slice(match[0].length) - const htmlContent = marked.parse(content) - - const { title, order } = metadata + const quest = yaml.parse(body) return Quest.findByIdAndUpdate( id, { - _id: id, - title, - order, - content: htmlContent, + id, + ...quest, }, { upsert: true, From 094332da532714dedb8eb71f86392eddd0a99e7e Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 17:19:01 +0800 Subject: [PATCH 06/13] feat: add commandHandler --- packages/cli/cli.js | 2 +- server/quests/helloworld/game.yml | 10 ++++- .../src/api/controllers/commandController.js | 8 ++-- .../src/api/controllers/sessionController.js | 2 + server/src/game/commandHandler.js | 42 +++++++++++++++++++ server/src/models/quest.js | 6 ++- server/src/models/session.js | 2 + 7 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 server/src/game/commandHandler.js diff --git a/packages/cli/cli.js b/packages/cli/cli.js index b170f09..91b5d66 100644 --- a/packages/cli/cli.js +++ b/packages/cli/cli.js @@ -95,4 +95,4 @@ async function main() { } } -main() +main().then(exit) diff --git a/server/quests/helloworld/game.yml b/server/quests/helloworld/game.yml index 8db3f1e..d7f6f47 100644 --- a/server/quests/helloworld/game.yml +++ b/server/quests/helloworld/game.yml @@ -5,7 +5,7 @@ introduction: | 「輸入 echo Hello World!」 stages: - - name: helloworld + - id: helloworld condition: command: - echo Hello World! @@ -19,3 +19,11 @@ stages: - 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。 hint: - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 + next: ls + - id: ls + condition: + command: + - ls + responses: + - NOTHING + next: END diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index 7cfbb0c..d4deabe 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -2,6 +2,7 @@ import Command from '../../models/command.js' import Session from '../../models/session.js' import { verifySessionJWT } from '../../utils/auth.js' +import commandHandler from '../../game/commandHandler.js' export async function newCommand(req, res) { console.log('new command:', req.body) @@ -38,7 +39,8 @@ export async function newCommand(req, res) { await c.save() - res.status(201).json({ - responses: ['Hello, world!', 'How are you?'], - }) + const response = await commandHandler(session, c) + console.log(response) + + res.status(201).json(response) } diff --git a/server/src/api/controllers/sessionController.js b/server/src/api/controllers/sessionController.js index 81e163f..79b0940 100644 --- a/server/src/api/controllers/sessionController.js +++ b/server/src/api/controllers/sessionController.js @@ -23,9 +23,11 @@ export async function createSession(req, res) { } try { + const progress = quest.stages[0].id const newSession = new Session({ user: req.user, quest, + progress, containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`, }) diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js new file mode 100644 index 0000000..c5d2c5b --- /dev/null +++ b/server/src/game/commandHandler.js @@ -0,0 +1,42 @@ +import Quest from '../models/quest.js' + +// 檢查 pattern 是否符合 input +const checkMatch = (pattern, input) => { + if (pattern.length === 0) return true + return pattern.some((p) => { + const regex = new RegExp(p) + return regex.test(input) + }) +} + +const commandHandler = async (session, commandInput) => { + const quest = await Quest.findById(session.quest) + const stage = quest.stages.find((s) => s.id === session.progress) + if (!stage) { + console.error('stage not found', session.progress) + return {} + } + + console.log(stage.condition) + + const commandMatch = checkMatch(stage.condition.command, commandInput.command) + const outputMatch = checkMatch(stage.condition.output, commandInput.output) + const errorMatch = checkMatch(stage.condition.error, commandInput.output) + + console.log(commandMatch, outputMatch, errorMatch) + + if (!commandMatch || !outputMatch || !errorMatch) return {} + + session.completion.push(session.progress) + + const nextStage = quest.stages.find((s) => s.id === stage.next) + session.progress = nextStage.id + await session.save() + + return { + responses: stage.responses, + hint: stage.hint, + } +} + +export default commandHandler diff --git a/server/src/models/quest.js b/server/src/models/quest.js index d16804d..7c68866 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -1,7 +1,7 @@ import { model, Schema } from 'mongoose' const stageSchema = new Schema({ - name: { + id: { type: String, required: true, }, @@ -27,6 +27,10 @@ const stageSchema = new Schema({ type: [String], required: true, }, + next: { + type: String, + required: true, + }, }) const Quest = model( diff --git a/server/src/models/session.js b/server/src/models/session.js index 62ccdf6..c1b813e 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -14,6 +14,8 @@ const Session = model( }, containerId: String, finishedAt: Date, + progress: String, + completion: [String], }, { timestamps: true } ) From ae61bfc768a1fe6fc08c7aa0672e91db8950e6ec Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 17:26:12 +0800 Subject: [PATCH 07/13] Update eslint rule --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index ebfa30a..7d9e2c6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,5 +20,6 @@ module.exports = { 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'import/extensions': 'off', camelcase: ['error', { properties: 'never', ignoreDestructuring: true }], + 'no-param-reassign': ['error', { props: false }], }, } From 6f368f761e7fdfe2d0083b9452c9714938093ce3 Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 18:04:33 +0800 Subject: [PATCH 08/13] feat: add hints to session, update API --- docker-compose.yml | 9 +++ server/quests/helloworld/game.yml | 4 +- .../src/api/controllers/commandController.js | 4 +- server/src/api/controllers/questController.js | 6 +- server/src/game/commandHandler.js | 2 +- server/src/models/quest.js | 4 +- server/src/models/session.js | 1 + server/swagger.yaml | 68 +++++++++---------- 8 files changed, 52 insertions(+), 46 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8a03ed9..40925c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,15 @@ services: volumes: - ./data:/data/db + swagger: + image: swaggerapi/swagger-editor:latest + ports: + - 8080:8080 + volumes: + - ./server/swagger.yaml:/tmp/swagger.yaml + environment: + - SWAGGER_FILE=/tmp/swagger.yaml + networks: internal: players: diff --git a/server/quests/helloworld/game.yml b/server/quests/helloworld/game.yml index d7f6f47..e7dd507 100644 --- a/server/quests/helloworld/game.yml +++ b/server/quests/helloworld/game.yml @@ -1,6 +1,6 @@ title: Hello, Linux World! order: 1 -introduction: | +instruction: | 你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說: 「輸入 echo Hello World!」 @@ -17,7 +17,7 @@ stages: - 首先,你必須要學會如何使用指令。 - 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。 - 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。 - hint: + hints: - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 next: ls - id: ls diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index d4deabe..add1214 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -6,7 +6,7 @@ import commandHandler from '../../game/commandHandler.js' export async function newCommand(req, res) { console.log('new command:', req.body) - const { token, command, pwd, exit_code, output } = req.body + const { token, command, pwd, output, error } = req.body if (!command) { res.status(400).json({ message: 'command is required' }) @@ -33,8 +33,8 @@ export async function newCommand(req, res) { session, command, pwd, - exit_code, output, + error, }) await c.save() diff --git a/server/src/api/controllers/questController.js b/server/src/api/controllers/questController.js index a9ca070..059490e 100644 --- a/server/src/api/controllers/questController.js +++ b/server/src/api/controllers/questController.js @@ -3,9 +3,8 @@ import Quest from '../../models/quest.js' export async function getQuests(req, res) { const quests = await Quest.find({}).sort({ order: 1 }) res.json( - quests.map(({ name, title, order, _id }) => ({ + quests.map(({ title, order, _id }) => ({ _id, - name, title, order, })) @@ -19,5 +18,6 @@ export async function getQuestDetail(req, res) { res.status(404).json({ message: 'Quest not found.' }) return } - res.json(quest) + const { _id, title, order, instruction } = quest + res.json({ _id, title, order, instruction }) } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index c5d2c5b..ca99dff 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -28,6 +28,7 @@ const commandHandler = async (session, commandInput) => { if (!commandMatch || !outputMatch || !errorMatch) return {} session.completion.push(session.progress) + session.hints.push(...stage.hints) const nextStage = quest.stages.find((s) => s.id === stage.next) session.progress = nextStage.id @@ -35,7 +36,6 @@ const commandHandler = async (session, commandInput) => { return { responses: stage.responses, - hint: stage.hint, } } diff --git a/server/src/models/quest.js b/server/src/models/quest.js index 7c68866..2b46935 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -23,7 +23,7 @@ const stageSchema = new Schema({ type: [String], required: true, }, - hint: { + hints: { type: [String], required: true, }, @@ -39,7 +39,7 @@ const Quest = model( _id: String, title: String, order: Number, - introduction: String, + instruction: String, stages: [stageSchema], }) ) diff --git a/server/src/models/session.js b/server/src/models/session.js index c1b813e..5ae0865 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -16,6 +16,7 @@ const Session = model( finishedAt: Date, progress: String, completion: [String], + hints: [String], }, { timestamps: true } ) diff --git a/server/swagger.yaml b/server/swagger.yaml index 2527225..5532b60 100644 --- a/server/swagger.yaml +++ b/server/swagger.yaml @@ -1,6 +1,8 @@ openapi: 3.0.0 servers: - url: /api/v1 + - url: https://odyssey.wancat.cc/api/v1 + - url: http://localhost:3000/api/v1 info: description: Linux Odyssey API version: '1.0.0' @@ -304,6 +306,10 @@ paths: responses: '201': description: Command recorded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CommandResponse' '400': description: Invalid request body '401': @@ -333,10 +339,9 @@ components: title: type: string example: Hello World - content: + instruction: type: string - format: html - example: '

Hello World

' + example: 'In this quest, you need to learn how to use `ls` to list the file.' order: type: number @@ -372,20 +377,21 @@ components: finished_at: type: string format: date-time - terminals: - type: array - items: - type: string - graphs: - $ref: '#/components/schemas/TreeNode' - progress: - type: array - items: - $ref: '#/components/schemas/Progress' hints: type: array items: - $ref: '#/components/schemas/Hint' + type: string + example: '`ls -al` to list detail information of all files.' + progress: + type: string + description: current stage id + example: helloworld + completion: + type: array + description: list of completed stage ids + items: + type: string + example: helloworld SessionSummary: type: object @@ -418,28 +424,6 @@ components: items: $ref: '#/components/schemas/TreeNode' - Progress: - type: object - properties: - name: - type: string - finished_at: - type: string - format: date-time - skipped_at: - type: string - format: date-time - - Hint: - type: object - properties: - name: - type: string - content: - type: string - source: - type: string - Command: type: object required: @@ -461,6 +445,18 @@ components: output: type: string description: The output of the command + error: + type: string + description: The error message of the command + + CommandResponse: + type: object + properties: + responses: + type: array + items: + type: string + example: 'You have completed the quest!' securitySchemes: BearerAuth: From 41a090ce7319bf413f192a771a76cbdc59f22796 Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 18:17:43 +0800 Subject: [PATCH 09/13] doc: update README --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2b70dd9..122d011 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ yarn install ## Create database ``` -docker compose up -d +docker compose pull +docker compose up -d db ``` ## Server @@ -25,10 +26,23 @@ docker compose up -d Source: `server/` ``` -yarn server +docker compose -f docker-compose.build.yml pull +docker compose up -d # Open http://localhost:3000 ``` +### Build images + + docker compose build + docker compose push + +Game images: + +``` +docker compose -f docker-compose.build.yml build +docker compose -f docker-compose.build.yml push +``` + ## App Source: `app/` @@ -38,24 +52,46 @@ yarn app # Open http://localhost:5173/ ``` +## Swagger + +``` +docker compose up -d swagger +``` + +Open http://localhost:8080 to open Swagger + ## CLI terminal client -Open server first, then create a session by `POST /api/v1/sessions` +``` +yarn cli +Usage: client [options] [command] -```bash -curl -X 'POST' \ - 'http://localhost:3000/api/v1/sessions' \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d '{ - "quest_id": "helloworld" -}' +Options: + -s, --session Session ID + -c, --create Create a new session + -h, --host Server host (default: "http://localhost:3000") + --help display help for command + +Commands: + list List all sessions ``` -Then use the `_id` field in response +connet to recent session: -``` -yarn cli [_id] -# example -yarn cli 64be226a39c0043cd1cdf7c2 -``` + yarn cli + +connect to new session: + + yarn cli -c + +connect to specific session: + + yarn cli -s 64d0de0367459c13004bc83f + +connect to other host: + + yarn cli -h https://example.com + +list sessions: + + yarn cli list From a73aa8b4617a8dfab76f26395cc17979abb088aa Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 18:30:59 +0800 Subject: [PATCH 10/13] fix: prevent execute command twice --- server/quests/helloworld/home/.zshrc | 4 ++-- server/src/game/commandHandler.js | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/server/quests/helloworld/home/.zshrc b/server/quests/helloworld/home/.zshrc index 9369e80..d8275fd 100644 --- a/server/quests/helloworld/home/.zshrc +++ b/server/quests/helloworld/home/.zshrc @@ -15,10 +15,10 @@ preexec() { # Check if the command is in the list of interactive commands if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then # If it is, just execute it normally - eval $1 + exec else # If it's not, execute it and redirect its output to a file - eval $1 > /tmp/cmd.out 2>/tmp/cmd.error + exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2) fi } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index ca99dff..d2ce5f0 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -17,14 +17,10 @@ const commandHandler = async (session, commandInput) => { return {} } - console.log(stage.condition) - const commandMatch = checkMatch(stage.condition.command, commandInput.command) const outputMatch = checkMatch(stage.condition.output, commandInput.output) const errorMatch = checkMatch(stage.condition.error, commandInput.output) - console.log(commandMatch, outputMatch, errorMatch) - if (!commandMatch || !outputMatch || !errorMatch) return {} session.completion.push(session.progress) From 5cad3ecc127b368c99cc39a43b6198d279beb432 Mon Sep 17 00:00:00 2001 From: wancat Date: Tue, 8 Aug 2023 20:34:08 +0800 Subject: [PATCH 11/13] fix: mark progress to stage.next directly --- server/src/game/commandHandler.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index d2ce5f0..c3587a3 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -26,8 +26,7 @@ const commandHandler = async (session, commandInput) => { session.completion.push(session.progress) session.hints.push(...stage.hints) - const nextStage = quest.stages.find((s) => s.id === stage.next) - session.progress = nextStage.id + session.progress = stage.next await session.save() return { From a151d2d667a2e2176453da84406f0e9da1f7f0cf Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 9 Aug 2023 21:14:57 +0800 Subject: [PATCH 12/13] feat: add type and color to response --- packages/cli/cli.js | 49 ++++++++++++--- server/quests/helloworld/game.yml | 62 ++++++++++++++++--- .../helloworld/home/forgotten_scroll.txt | 7 +++ server/src/game/commandHandler.js | 2 + server/src/models/quest.js | 12 +++- server/swagger.yaml | 28 +++++++++ 6 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 server/quests/helloworld/home/forgotten_scroll.txt diff --git a/packages/cli/cli.js b/packages/cli/cli.js index 91b5d66..6cf99f0 100644 --- a/packages/cli/cli.js +++ b/packages/cli/cli.js @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ /* eslint-disable no-restricted-syntax */ const fs = require('fs').promises const { exit } = require('process') @@ -5,6 +6,24 @@ const readline = require('readline') const RETURN_SYMBOL = '\u21B5' +// ANSI color map +const ANSI_COLORS = { + black: 30, + red: 31, + green: 32, + yellow: 33, + blue: 34, + magenta: 35, + cyan: 36, + white: 37, +} + +function colorize(content, color) { + const colorCode = ANSI_COLORS[color] + if (!colorCode) return content + return `\x1b[${colorCode}m${content}\x1b[0m` +} + process.stdin.setRawMode(true) const rl = readline.createInterface({ @@ -19,13 +38,14 @@ function waitForEnter() { }) } -function printResponse(response, delay) { +function printLine(content, delay, color) { + const printContent = `${colorize(content, color)}${RETURN_SYMBOL}` return new Promise((resolve) => { let i = 0 const timer = setInterval(() => { - process.stdout.write(response[i]) + process.stdout.write(printContent[i]) i += 1 - if (i >= response.length) { + if (i >= printContent.length) { clearInterval(timer) waitForEnter().then(resolve) } @@ -33,7 +53,7 @@ function printResponse(response, delay) { waitForEnter().then(() => { clearInterval(timer) - process.stdout.write(response.slice(i)) + process.stdout.write(printContent.slice(i)) process.stdout.write('\n') resolve() }) @@ -42,8 +62,18 @@ function printResponse(response, delay) { async function printResponses(responses, delay = 100) { for (const response of responses) { - // eslint-disable-next-line no-await-in-loop - await printResponse(response + RETURN_SYMBOL, delay) + const { type, content, speaker, color } = response + if (type === 'narrative') { + for (const line of content) { + await printLine(line, delay, color || 'white') + } + } + if (type === 'dialogue') { + console.log(`${speaker}:`) + for (const line of content) { + await printLine(`\t${line}`, delay, color || 'green') + } + } } rl.close() } @@ -88,11 +118,14 @@ async function main() { }) const data = await res.json() if (data.responses) { - await printResponses(data.responses, 100) + await printResponses(data.responses, 60) + } + if (data.end) { + console.log(colorize('Quest completed!', 'blue')) } } catch (err) { console.error(err) } } -main().then(exit) +main().then(exit).catch(console.error) diff --git a/server/quests/helloworld/game.yml b/server/quests/helloworld/game.yml index e7dd507..2dca81c 100644 --- a/server/quests/helloworld/game.yml +++ b/server/quests/helloworld/game.yml @@ -10,13 +10,20 @@ stages: command: - echo Hello World! responses: - - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 - - 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。 - - 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 - - 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。 - - 首先,你必須要學會如何使用指令。 - - 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。 - - 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。 + - type: narrative + content: + - 你聽到了一個聲音: + - type: dialogue + speaker: Unix Spirit + content: + - 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。 + - 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。 + - 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。 + - 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。 + - 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。 + - 首先,你必須要學會如何使用指令。 + - 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。 + - 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。 hints: - 使用 `ls` 指令來查看目前的目錄下有哪些檔案。 next: ls @@ -25,5 +32,44 @@ stages: command: - ls responses: - - NOTHING + - types: narrative + content: + - 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。 + - 你發現了一個檔案,檔名是 `forgotten_scroll.txt`。 + - type: dialogue + speaker: Unix Spirit + content: + - 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。 + - 使用指令 `cat [檔案名稱]` 來查看檔案內容。 + - 我可以為你提供一些指令的提示,只要輸入指令的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。 + - 例如,你可以輸入 `ca` 然後按下 `Tab` 鍵,就可以看到 `cat` 這個指令的提示了。 + - 檔案名稱也一樣,只要輸入檔案名稱的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。 + hints: + - 使用 `cat [檔案名稱]` 指令來查看檔案內容。 + - 使用 `Tab` 鍵來取得指令提示。 + next: cat + - id: cat + condition: + command: + - cat forgotten_scroll.txt + responses: + - type: dialogue + speaker: Unix Spirit + content: + - 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。 + - 使用你已經知道的 `echo` 指令來詠唱咒語。 + next: echo + - id: echo + condition: + command: + - echo Unix, rise and guide me! + responses: + - type: narrative + content: + - 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。 + - type: dialogue + speaker: Unix Spirit + content: + - 恭喜您,我的指令官,您已成功解除了封印。 + - 請跟著在接下了關卡,一步步取回 Linux 之根吧! next: END diff --git a/server/quests/helloworld/home/forgotten_scroll.txt b/server/quests/helloworld/home/forgotten_scroll.txt new file mode 100644 index 0000000..17010b0 --- /dev/null +++ b/server/quests/helloworld/home/forgotten_scroll.txt @@ -0,0 +1,7 @@ +Once a mighty kingdom, now in darkness, +The power of Unix lies dormant, waiting. +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! diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index c3587a3..4dd0df5 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -31,6 +31,8 @@ const commandHandler = async (session, commandInput) => { return { responses: stage.responses, + hints: stage.hints, + end: stage.next === 'END', } } diff --git a/server/src/models/quest.js b/server/src/models/quest.js index 2b46935..ea592f1 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -1,5 +1,15 @@ import { model, Schema } from 'mongoose' +const responseSchema = new Schema({ + type: { + type: String, + required: true, + }, + content: [String], + speaker: String, + color: String, +}) + const stageSchema = new Schema({ id: { type: String, @@ -20,7 +30,7 @@ const stageSchema = new Schema({ }, }, responses: { - type: [String], + type: [responseSchema], required: true, }, hints: { diff --git a/server/swagger.yaml b/server/swagger.yaml index 5532b60..5b671bb 100644 --- a/server/swagger.yaml +++ b/server/swagger.yaml @@ -453,10 +453,38 @@ components: type: object properties: responses: + type: array + items: + $ref: '#/components/schemas/CommandResponseItem' + hints: + type: array + items: + type: string + example: '`ls -al` to list detail information of all files.' + end: + type: boolean + description: Whether the session has ended + + CommandResponseItem: + type: object + required: + - type + - content + properties: + type: + type: string + enum: [narrative, dialogue] + content: type: array items: type: string example: 'You have completed the quest!' + color: + type: string + enum: [red, green, blue, yellow, purple, cyan, white, gray, black] + speaker: + type: string + example: 'Unix Spirit' securitySchemes: BearerAuth: From 0ba823b43220ebd92fec43ae7212620da016dc61 Mon Sep 17 00:00:00 2001 From: wancat Date: Wed, 9 Aug 2023 21:16:43 +0800 Subject: [PATCH 13/13] feat: print hints --- packages/cli/cli.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/cli.js b/packages/cli/cli.js index 6cf99f0..39d5ecd 100644 --- a/packages/cli/cli.js +++ b/packages/cli/cli.js @@ -120,6 +120,11 @@ async function main() { if (data.responses) { await printResponses(data.responses, 60) } + if (data.hints) { + for (const hint of data.hints) { + console.log(colorize(hint, 'yellow')) + } + } if (data.end) { console.log(colorize('Quest completed!', 'blue')) }