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 }], }, } 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 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/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/packages/cli/cli.js b/packages/cli/cli.js new file mode 100644 index 0000000..39d5ecd --- /dev/null +++ b/packages/cli/cli.js @@ -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) diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..d8c4598 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,7 @@ +{ + "name": "cli", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": {} +} 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/Dockerfile b/server/quests/helloworld/Dockerfile index 01f3814..8a8942d 100644 --- a/server/quests/helloworld/Dockerfile +++ b/server/quests/helloworld/Dockerfile @@ -1,16 +1,19 @@ -FROM ubuntu:latest +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 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" ] diff --git a/server/quests/helloworld/game.yml b/server/quests/helloworld/game.yml new file mode 100644 index 0000000..2dca81c --- /dev/null +++ b/server/quests/helloworld/game.yml @@ -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 diff --git a/server/quests/helloworld/home/.bashrc b/server/quests/helloworld/home/.bashrc deleted file mode 100644 index 3a0355f..0000000 --- a/server/quests/helloworld/home/.bashrc +++ /dev/null @@ -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 diff --git a/server/quests/helloworld/home/.zshrc b/server/quests/helloworld/home/.zshrc new file mode 100644 index 0000000..d8275fd --- /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 + 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 +} + 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/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 之王。」 diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index a886d23..add1214 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -2,10 +2,16 @@ 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) - 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 try { @@ -27,11 +33,14 @@ export async function newCommand(req, res) { session, command, pwd, - exit_code, output, + error, }) await c.save() - res.status(201).end() + const response = await commandHandler(session, c) + console.log(response) + + res.status(201).json(response) } 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/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..4dd0df5 --- /dev/null +++ b/server/src/game/commandHandler.js @@ -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 diff --git a/server/src/models/quest.js b/server/src/models/quest.js index 753eb7f..ea592f1 100644 --- a/server/src/models/quest.js +++ b/server/src/models/quest.js @@ -1,12 +1,56 @@ 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( 'Quest', new Schema({ _id: String, title: String, order: Number, - content: String, + instruction: String, + stages: [stageSchema], }) ) diff --git a/server/src/models/session.js b/server/src/models/session.js index 62ccdf6..5ae0865 100644 --- a/server/src/models/session.js +++ b/server/src/models/session.js @@ -14,6 +14,9 @@ const Session = model( }, containerId: String, finishedAt: Date, + progress: String, + completion: [String], + hints: [String], }, { timestamps: true } ) diff --git a/server/src/utils/docker.js b/server/src/utils/docker.js index 50fe918..730a6e1 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', @@ -11,13 +11,15 @@ const containerOptions = { AttachStdout: true, AttachStderr: true, Tty: true, - Cmd: ['/bin/bash'], OpenStdin: true, StdinOnce: false, 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`, + ], }, } @@ -56,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..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() { @@ -17,27 +11,24 @@ export default async function loadAndUpdateQuests() { const quests = Promise.all( 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 { // 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, diff --git a/server/swagger.yaml b/server/swagger.yaml index 2527225..5b671bb 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,46 @@ 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: + $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: diff --git a/yarn.lock b/yarn.lock index dc22e73..2a3ac1c 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"