mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-08 03:20:12 +08:00
Merge pull request #7 from lancatlin/server-quest-helloworld
Game architecture design
This commit is contained in:
23 files changed
+567
-121
No files matched your search
@@ -20,5 +20,6 @@ module.exports = {
|
|||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||||
'import/extensions': 'off',
|
'import/extensions': 'off',
|
||||||
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
|
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
|
||||||
|
'no-param-reassign': ['error', { props: false }],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,8 @@ yarn install
|
|||||||
## Create database
|
## Create database
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose up -d
|
docker compose pull
|
||||||
|
docker compose up -d db
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
@@ -25,10 +26,23 @@ docker compose up -d
|
|||||||
Source: `server/`
|
Source: `server/`
|
||||||
|
|
||||||
```
|
```
|
||||||
yarn server
|
docker compose -f docker-compose.build.yml pull
|
||||||
|
docker compose up -d
|
||||||
# Open http://localhost:3000
|
# 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
|
## App
|
||||||
|
|
||||||
Source: `app/`
|
Source: `app/`
|
||||||
@@ -38,24 +52,46 @@ yarn app
|
|||||||
# Open http://localhost:5173/
|
# Open http://localhost:5173/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Swagger
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d swagger
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8080 to open Swagger
|
||||||
|
|
||||||
## CLI terminal client
|
## CLI terminal client
|
||||||
|
|
||||||
Open server first, then create a session by `POST /api/v1/sessions`
|
```
|
||||||
|
yarn cli
|
||||||
|
Usage: client [options] [command]
|
||||||
|
|
||||||
```bash
|
Options:
|
||||||
curl -X 'POST' \
|
-s, --session <string> Session ID
|
||||||
'http://localhost:3000/api/v1/sessions' \
|
-c, --create Create a new session
|
||||||
-H 'accept: application/json' \
|
-h, --host <string> Server host (default: "http://localhost:3000")
|
||||||
-H 'Content-Type: application/json' \
|
--help display help for command
|
||||||
-d '{
|
|
||||||
"quest_id": "helloworld"
|
Commands:
|
||||||
}'
|
list List all sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
Then use the `_id` field in response
|
connet to recent session:
|
||||||
|
|
||||||
```
|
yarn cli
|
||||||
yarn cli [_id]
|
|
||||||
# example
|
connect to new session:
|
||||||
yarn cli 64be226a39c0043cd1cdf7c2
|
|
||||||
```
|
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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
helloworld:
|
||||||
|
image: lancatlin/linux-odyssey:helloworld
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: server/quests/helloworld/Dockerfile
|
||||||
@@ -26,6 +26,15 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data/db
|
- ./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:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
players:
|
players:
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
const fs = require('fs').promises
|
||||||
|
const { exit } = require('process')
|
||||||
|
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({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
terminal: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function waitForEnter() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.once('line', resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(printContent[i])
|
||||||
|
i += 1
|
||||||
|
if (i >= printContent.length) {
|
||||||
|
clearInterval(timer)
|
||||||
|
waitForEnter().then(resolve)
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
|
||||||
|
waitForEnter().then(() => {
|
||||||
|
clearInterval(timer)
|
||||||
|
process.stdout.write(printContent.slice(i))
|
||||||
|
process.stdout.write('\n')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printResponses(responses, delay = 100) {
|
||||||
|
for (const response of responses) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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: CMD_NAME,
|
||||||
|
output,
|
||||||
|
error,
|
||||||
|
exit_code: CMD_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, 60)
|
||||||
|
}
|
||||||
|
if (data.hints) {
|
||||||
|
for (const hint of data.hints) {
|
||||||
|
console.log(colorize(hint, 'yellow'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.end) {
|
||||||
|
console.log(colorize('Quest completed!', 'blue'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().then(exit).catch(console.error)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "cli",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
characters:
|
||||||
|
savantia:
|
||||||
|
en: Savantia
|
||||||
|
zh: 薩凡媞雅
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
FROM ubuntu:latest
|
FROM node:latest
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
curl \
|
curl \
|
||||||
vim \
|
vim \
|
||||||
|
zsh \
|
||||||
jq
|
jq
|
||||||
|
|
||||||
RUN adduser --disabled-password --gecos '' rudeus
|
RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus
|
||||||
|
|
||||||
USER rudeus
|
USER rudeus
|
||||||
|
|
||||||
WORKDIR /home/rudeus
|
WORKDIR /home/rudeus
|
||||||
|
|
||||||
COPY ./home /home/rudeus/
|
COPY ./server/quests/helloworld/home /home/rudeus/
|
||||||
|
|
||||||
CMD [ "/bin/bash" ]
|
COPY ./packages/cli/cli.js /usr/local/bin/cli.js
|
||||||
|
|
||||||
|
CMD [ "/bin/zsh" ]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
title: Hello, Linux World!
|
||||||
|
order: 1
|
||||||
|
instruction: |
|
||||||
|
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
|
||||||
|
|
||||||
|
「輸入 echo Hello World!」
|
||||||
|
stages:
|
||||||
|
- id: helloworld
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- echo Hello World!
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你聽到了一個聲音:
|
||||||
|
- type: dialogue
|
||||||
|
speaker: Unix Spirit
|
||||||
|
content:
|
||||||
|
- 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。
|
||||||
|
- 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
|
||||||
|
- 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。
|
||||||
|
- 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
|
||||||
|
- 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
|
||||||
|
- 首先,你必須要學會如何使用指令。
|
||||||
|
- 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。
|
||||||
|
- 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。
|
||||||
|
hints:
|
||||||
|
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
|
||||||
|
next: ls
|
||||||
|
- id: ls
|
||||||
|
condition:
|
||||||
|
command:
|
||||||
|
- ls
|
||||||
|
responses:
|
||||||
|
- 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
|
||||||
@@ -1,30 +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
|
|
||||||
local command="$BASH_COMMAND"
|
|
||||||
local pwd="$PWD"
|
|
||||||
|
|
||||||
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"
|
|
||||||
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)
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Send command to the API
|
|
||||||
curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload" || echo $payload
|
|
||||||
|
|
||||||
BASH_COMMAND=""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install the trap
|
|
||||||
trap 'preexec' DEBUG
|
|
||||||
@@ -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
|
||||||
|
exec
|
||||||
|
else
|
||||||
|
# If it's not, execute it and redirect its output to a file
|
||||||
|
exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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!
|
||||||
@@ -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 之王。」
|
||||||
@@ -2,10 +2,16 @@
|
|||||||
import Command from '../../models/command.js'
|
import Command from '../../models/command.js'
|
||||||
import Session from '../../models/session.js'
|
import Session from '../../models/session.js'
|
||||||
import { verifySessionJWT } from '../../utils/auth.js'
|
import { verifySessionJWT } from '../../utils/auth.js'
|
||||||
|
import commandHandler from '../../game/commandHandler.js'
|
||||||
|
|
||||||
export async function newCommand(req, res) {
|
export async function newCommand(req, res) {
|
||||||
console.log('new command:', req.body)
|
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' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let sessionId
|
let sessionId
|
||||||
try {
|
try {
|
||||||
@@ -27,11 +33,14 @@ export async function newCommand(req, res) {
|
|||||||
session,
|
session,
|
||||||
command,
|
command,
|
||||||
pwd,
|
pwd,
|
||||||
exit_code,
|
|
||||||
output,
|
output,
|
||||||
|
error,
|
||||||
})
|
})
|
||||||
|
|
||||||
await c.save()
|
await c.save()
|
||||||
|
|
||||||
res.status(201).end()
|
const response = await commandHandler(session, c)
|
||||||
|
console.log(response)
|
||||||
|
|
||||||
|
res.status(201).json(response)
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,8 @@ import Quest from '../../models/quest.js'
|
|||||||
export async function getQuests(req, res) {
|
export async function getQuests(req, res) {
|
||||||
const quests = await Quest.find({}).sort({ order: 1 })
|
const quests = await Quest.find({}).sort({ order: 1 })
|
||||||
res.json(
|
res.json(
|
||||||
quests.map(({ name, title, order, _id }) => ({
|
quests.map(({ title, order, _id }) => ({
|
||||||
_id,
|
_id,
|
||||||
name,
|
|
||||||
title,
|
title,
|
||||||
order,
|
order,
|
||||||
}))
|
}))
|
||||||
@@ -19,5 +18,6 @@ export async function getQuestDetail(req, res) {
|
|||||||
res.status(404).json({ message: 'Quest not found.' })
|
res.status(404).json({ message: 'Quest not found.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res.json(quest)
|
const { _id, title, order, instruction } = quest
|
||||||
|
res.json({ _id, title, order, instruction })
|
||||||
}
|
}
|
||||||
@@ -23,9 +23,11 @@ export async function createSession(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const progress = quest.stages[0].id
|
||||||
const newSession = new Session({
|
const newSession = new Session({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
quest,
|
quest,
|
||||||
|
progress,
|
||||||
containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`,
|
containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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 {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandMatch = checkMatch(stage.condition.command, commandInput.command)
|
||||||
|
const outputMatch = checkMatch(stage.condition.output, commandInput.output)
|
||||||
|
const errorMatch = checkMatch(stage.condition.error, commandInput.output)
|
||||||
|
|
||||||
|
if (!commandMatch || !outputMatch || !errorMatch) return {}
|
||||||
|
|
||||||
|
session.completion.push(session.progress)
|
||||||
|
session.hints.push(...stage.hints)
|
||||||
|
|
||||||
|
session.progress = stage.next
|
||||||
|
await session.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
responses: stage.responses,
|
||||||
|
hints: stage.hints,
|
||||||
|
end: stage.next === 'END',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default commandHandler
|
||||||
@@ -1,12 +1,56 @@
|
|||||||
import { model, Schema } from 'mongoose'
|
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,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
condition: {
|
||||||
|
command: {
|
||||||
|
type: [String],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
type: [String],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: [String],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
type: [responseSchema],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
hints: {
|
||||||
|
type: [String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const Quest = model(
|
const Quest = model(
|
||||||
'Quest',
|
'Quest',
|
||||||
new Schema({
|
new Schema({
|
||||||
_id: String,
|
_id: String,
|
||||||
title: String,
|
title: String,
|
||||||
order: Number,
|
order: Number,
|
||||||
content: String,
|
instruction: String,
|
||||||
|
stages: [stageSchema],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const Session = model(
|
|||||||
},
|
},
|
||||||
containerId: String,
|
containerId: String,
|
||||||
finishedAt: Date,
|
finishedAt: Date,
|
||||||
|
progress: String,
|
||||||
|
completion: [String],
|
||||||
|
hints: [String],
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import config from '../config.js'
|
|||||||
|
|
||||||
const engine = new Docker()
|
const engine = new Docker()
|
||||||
|
|
||||||
const questPath = '/home/zeko/src/linux-odyssey/server/quests'
|
const projectPath = '/home/zeko/src/linux-odyssey'
|
||||||
|
|
||||||
const containerOptions = {
|
const containerOptions = {
|
||||||
Image: 'lancatlin/linux-odyssey:helloworld',
|
Image: 'lancatlin/linux-odyssey:helloworld',
|
||||||
@@ -11,13 +11,15 @@ const containerOptions = {
|
|||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
Cmd: ['/bin/bash'],
|
|
||||||
OpenStdin: true,
|
OpenStdin: true,
|
||||||
StdinOnce: false,
|
StdinOnce: false,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Binds: config.isProduction
|
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`,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export async function attachContainer(container, { token }) {
|
|||||||
AttachStdin: true,
|
AttachStdin: true,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Cmd: ['/bin/bash'],
|
Cmd: ['/bin/zsh'],
|
||||||
Tty: true,
|
Tty: true,
|
||||||
Env: [`TOKEN=${token}`, `API_ENDPOINT=http://app:3000`],
|
Env: [`TOKEN=${token}`, `API_ENDPOINT=http://app:3000`],
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-19
@@ -1,15 +1,9 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { Marked } from 'marked'
|
|
||||||
import yaml from 'yaml'
|
import yaml from 'yaml'
|
||||||
|
|
||||||
import Quest from '../models/quest.js'
|
import Quest from '../models/quest.js'
|
||||||
|
|
||||||
const marked = new Marked({
|
|
||||||
mangle: false,
|
|
||||||
headerIds: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const questDirectory = path.join(process.cwd(), 'quests')
|
const questDirectory = path.join(process.cwd(), 'quests')
|
||||||
|
|
||||||
export default async function loadAndUpdateQuests() {
|
export default async function loadAndUpdateQuests() {
|
||||||
@@ -17,27 +11,24 @@ export default async function loadAndUpdateQuests() {
|
|||||||
|
|
||||||
const quests = Promise.all(
|
const quests = Promise.all(
|
||||||
questNames.map(async (id) => {
|
questNames.map(async (id) => {
|
||||||
const fullPath = path.join(questDirectory, id, 'README.md')
|
// 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 {
|
try {
|
||||||
// Read and parse the README.md file.
|
// 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 quest = yaml.parse(body)
|
||||||
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
|
|
||||||
|
|
||||||
return Quest.findByIdAndUpdate(
|
return Quest.findByIdAndUpdate(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
_id: id,
|
id,
|
||||||
title,
|
...quest,
|
||||||
order,
|
|
||||||
content: htmlContent,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
upsert: true,
|
upsert: true,
|
||||||
|
|||||||
+60
-36
@@ -1,6 +1,8 @@
|
|||||||
openapi: 3.0.0
|
openapi: 3.0.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
|
- url: https://odyssey.wancat.cc/api/v1
|
||||||
|
- url: http://localhost:3000/api/v1
|
||||||
info:
|
info:
|
||||||
description: Linux Odyssey API
|
description: Linux Odyssey API
|
||||||
version: '1.0.0'
|
version: '1.0.0'
|
||||||
@@ -304,6 +306,10 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Command recorded successfully
|
description: Command recorded successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CommandResponse'
|
||||||
'400':
|
'400':
|
||||||
description: Invalid request body
|
description: Invalid request body
|
||||||
'401':
|
'401':
|
||||||
@@ -333,10 +339,9 @@ components:
|
|||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
example: Hello World
|
example: Hello World
|
||||||
content:
|
instruction:
|
||||||
type: string
|
type: string
|
||||||
format: html
|
example: 'In this quest, you need to learn how to use `ls` to list the file.'
|
||||||
example: '<h1>Hello World</h1>'
|
|
||||||
order:
|
order:
|
||||||
type: number
|
type: number
|
||||||
|
|
||||||
@@ -372,20 +377,21 @@ components:
|
|||||||
finished_at:
|
finished_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
terminals:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
graphs:
|
|
||||||
$ref: '#/components/schemas/TreeNode'
|
|
||||||
progress:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/Progress'
|
|
||||||
hints:
|
hints:
|
||||||
type: array
|
type: array
|
||||||
items:
|
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:
|
SessionSummary:
|
||||||
type: object
|
type: object
|
||||||
@@ -418,28 +424,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/TreeNode'
|
$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:
|
Command:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -461,6 +445,46 @@ components:
|
|||||||
output:
|
output:
|
||||||
type: string
|
type: string
|
||||||
description: The output of the command
|
description: The output of the command
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: The error message of the command
|
||||||
|
|
||||||
|
CommandResponse:
|
||||||
|
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:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
|
|||||||
@@ -1538,10 +1538,6 @@ color-name@~1.1.4:
|
|||||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
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:
|
combined-stream@^1.0.8:
|
||||||
version "1.0.8"
|
version "1.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||||
@@ -1549,6 +1545,11 @@ combined-stream@^1.0.8:
|
|||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream "~1.0.0"
|
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:
|
commander@^4.0.0:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||||
|
|||||||
Reference in new issue
Block a user