Merge pull request #23 from linux-odyssey/server-quest

Add Tasks to quest
This commit is contained in:
Annie Huang authored and GitHub committed 2023-09-10 11:12:35 +08:00
commit 221ffae37a
23 files changed
+396 -203

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
.bash_history
.viminfo
*.swp
home
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+3
View File
@@ -23,6 +23,9 @@ onMounted(async () => {
socket.on('graph', (event) => {
sessionManager.handleGraphUpdate(event)
})
socket.on('tasks', (tasks) => {
sessionManager.setTasks(tasks)
})
})
</script>
+26 -10
View File
@@ -1,19 +1,19 @@
<script setup>
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import api from '../utils/api'
import sessionManager from '../utils/session'
const questData = ref(null)
const questErr = ref(null)
api
.get('/quests/helloworld')
.then((res) => {
console.log(res.data)
onMounted(async () => {
try {
const res = await api.get('/quests/helloworld')
questData.value = res.data
})
.catch((err) => {
} catch (err) {
questErr.value = err
})
}
})
</script>
<template>
@@ -26,8 +26,24 @@ api
</h1>
<hr class="border-text-line border-2 my-2" />
<div>
<p class="text-red-500" v-if="questErr">{{ questErr.message }}</p>
<p class="text-text" v-else-if="questData">{{ questData.instruction }}</p>
<div v-if="questData">
<p class="text-text">{{ questData.instruction }}</p>
<br />
<p class="text-text">Tasks:</p>
<ul v-if="sessionManager.session.value">
<li v-for="task in sessionManager.session.value.tasks" :key="task.id">
<p class="text-text">
<input
type="checkbox"
class="form-checkbox"
disabled
:checked="task.completed"
/>{{ task.name }}
</p>
</li>
</ul>
</div>
<p class="text-red-500" v-else-if="questErr">{{ questErr.message }}</p>
<p class="text-text" v-else>Loading...</p>
</div>
</div>
+4
View File
@@ -35,6 +35,10 @@ class SessionManager {
}
}
setTasks(tasks) {
this.session.value.tasks = tasks
}
async createSession() {
console.log('Creating a new session...')
const res = await api.post('/sessions', { quest_id: this.questId })
+6
View File
@@ -54,6 +54,12 @@ services:
context: .
dockerfile: quests/helloworld/Dockerfile
base:
image: lancatlin/linux-odyssey:base
build:
context: .
dockerfile: ./quests/Dockerfile
networks:
internal:
players:
+32 -16
View File
@@ -1,8 +1,11 @@
/* eslint-disable no-underscore-dangle */
import axios from 'axios'
import { stdin, stdout, exit } from 'process'
import { io } from 'socket.io-client'
import { program } from 'commander'
let api = null
function get(key, defaultValue) {
const value = process.env[key]
if (value != null) {
@@ -14,26 +17,29 @@ stdin.setRawMode(true)
stdin.resume()
stdin.setEncoding('utf8')
async function createdSession() {
function debug(...args) {
if (program.opts().debug) {
console.debug(...args)
}
}
async function createSession() {
console.log('Creating a new session...')
const res = await fetch(`${program.opts().host}/api/v1/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
quest_id: 'helloworld',
}),
const res = await api.post('/sessions', {
quest_id: 'helloworld',
})
const data = await res.json()
const { data } = await res
if (!data._id) {
console.error(data)
exit(1)
}
console.log('Created Session ID:', data._id)
return data
}
async function getSessionList() {
const res = await fetch(`${program.opts().host}/api/v1/sessions`)
const data = await res.json()
return data
const res = await api.get('/sessions')
return res.data
}
async function lastSession() {
@@ -73,8 +79,11 @@ async function connect(sessionId) {
})
socket.on('graph', (data) => {
console.log('receive graph:')
console.log(data)
debug('receive graph:', data)
})
socket.on('tasks', (data) => {
debug('receive tasks:', data)
})
socket.on('close', function close() {
@@ -93,12 +102,18 @@ async function connect(sessionId) {
}
async function main() {
api = axios.create({
baseURL: `${program.opts().host}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
})
console.log(program.opts().session)
// main()
const sessionId =
program.opts().session ||
(program.opts().create ? (await createdSession())._id : null) ||
(program.opts().create ? (await createSession())._id : null) ||
(await lastSession())._id
await connect(sessionId)
@@ -107,6 +122,7 @@ async function main() {
program
.option('-s, --session <string>', 'Session ID')
.option('-c, --create', 'Create a new session')
.option('-d, --debug', 'Debug mode')
.option(
'-h, --host <string>',
'Server host',
+1
View File
@@ -8,6 +8,7 @@
"start": "node index.js"
},
"dependencies": {
"axios": "^1.5.0",
"commander": "^11.0.0",
"socket.io-client": "^4.7.2"
}
+9
View File
@@ -36,6 +36,12 @@ function waitForEnter() {
})
}
function waitTime(time) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}
function printLine(content, delay, color) {
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
return new Promise((resolve) => {
@@ -59,6 +65,9 @@ function printLine(content, delay, color) {
}
async function printResponses(responses, delay = 100) {
await waitTime(1000)
console.log()
console.log(colorize('-------------', 'white'))
for (const response of responses) {
const { type, content, speaker, color } = response
if (type === 'narrative') {
+41
View File
@@ -0,0 +1,41 @@
FROM ubuntu:22.04
LABEL maintainer="wancat@wancat.cc"
RUN apt-get update && apt-get install -y \
locales \
curl \
vim \
zsh \
ca-certificates \
gnupg
RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=zh_TW.UTF-8
ENV LANG zh_TW.UTF-8
ENV LANGUAGE zh_TW:zh
ENV LC_ALL zh_TW.UTF-8
ENV NODE_MAJOR=20
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
RUN apt-get update && apt-get install nodejs -y
WORKDIR /usr/local/lib/container
RUN npm install -g yarn
COPY ./packages/container/package.json ./yarn.lock ./
RUN yarn install --modules-folder /usr/src/node_modules
ENV NODE_PATH=/usr/src/node_modules
COPY ./packages/container /usr/local/lib/container
ENV USERNAME=commander
RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME}
USER ${USERNAME}
WORKDIR /home/${USERNAME}
+2 -32
View File
@@ -1,35 +1,5 @@
FROM node:latest
FROM lancatlin/linux-odyssey:base
RUN apt-get update && apt-get install -y \
locales \
curl \
vim \
zsh
RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=zh_TW.UTF-8
ENV LANG zh_TW.UTF-8
ENV LANGUAGE zh_TW:zh
ENV LC_ALL zh_TW.UTF-8
WORKDIR /usr/local/lib/container
COPY ./packages/container/package.json ./yarn.lock ./
RUN yarn install --modules-folder /usr/src/node_modules
ENV NODE_PATH=/usr/src/node_modules
COPY ./packages/container /usr/local/lib/container
RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus
USER rudeus
WORKDIR /home/rudeus
COPY ./quests/helloworld/home /home/rudeus/
COPY ./quests/helloworld/home ${HOME}
CMD [ "/bin/zsh" ]
+38 -9
View File
@@ -4,11 +4,14 @@ instruction: |
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
「輸入 echo Hello World!」
stages:
- id: helloworld
task: 使用 `echo Hello World!` 召喚精靈
repeatable: false
condition:
command:
- echo Hello World!
- echo Hello World
responses:
- type: narrative
content:
@@ -22,12 +25,16 @@ stages:
- 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
- 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
- 首先,你必須要學會如何使用指令。
- 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。
- 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。
- 你要學習的第一個指令,是「發現」的指令:`ls`。ls 可以用來查看一個目錄中有哪些檔案。
- 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。
hints:
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
next: ls
- id: ls
task: 搜索卷軸
repeatable: false
requirements:
- helloworld
condition:
command:
- ls
@@ -47,8 +54,27 @@ stages:
hints:
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
- 使用 `Tab` 鍵來取得指令提示。
next: cat
- id: escape
task: 移動到下一個房間
repeatable: false
requirements:
- ls
condition:
pwd:
- '^/home/commander/jail$'
responses:
- type: narrative
content:
- 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。
- 突然,你聽到門上鎖的聲音。
- 轉身發現,自己被關在了牢房裡。
- id: cat
task: 查看卷軸
repeatable: false
requirements:
- ls
condition:
command:
- cat forgotten_scroll.txt
@@ -58,8 +84,12 @@ stages:
content:
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
- 使用你已經知道的 `echo` 指令來詠唱咒語。
next: echo
- id: echo
- id: END
task: 解除封印
repeatable: false
requirements:
- cat
condition:
command:
- echo Unix, rise and guide me!
@@ -71,5 +101,4 @@ stages:
speaker: Unix Spirit
content:
- 恭喜您,我的指令官,您已成功解除了封印。
- 請跟著在接下了關卡,一步步取回 Linux 之根吧!
next: END
- 請跟著在接下來的關卡,一步步取回 Linux 之根吧!
+6 -3
View File
@@ -6,7 +6,10 @@
1. echo 朗誦魔術
2. ls 搜索魔術
3. cat 檢察魔術
3. cat 檢查魔術
4. cd 移動魔術
5. mv 搬運魔術
6. rm 消滅魔術
## 關卡設計
@@ -14,8 +17,8 @@
2. 學習到了 ls
3. 使用 ls 找到地上放置了一個卷軸
4. 學習 cat
5. 使用 cat 查看卷軸內容
6. 用 echo 念出卷軸上的咒語
5. 使用 cat 查看卷軸內容,發現
6.
請幫我設計:
@@ -7,7 +7,6 @@ import CommandHandler from '../../game/commandHandler.js'
export async function newCommand(req, res) {
console.log('new command:', req.body)
const { token, command, pwd, output, error, ...additionalData } = req.body
console.log('additionalData:', additionalData)
if (!command) {
res.status(400).json({ message: 'command is required' })
@@ -36,7 +35,6 @@ export async function newCommand(req, res) {
}
session.lastActivityAt = new Date()
await session.save()
const c = new Command({
session,
@@ -51,7 +49,7 @@ export async function newCommand(req, res) {
const commandHandler = new CommandHandler(session, c, additionalData)
const response = await commandHandler.run()
console.log(response)
res.status(201).json(response)
await session.save()
}
@@ -1,6 +1,7 @@
import Session from '../../models/session.js'
import Quest from '../../models/quest.js'
import { createContainer } from '../../containers/docker.js'
import SessionHandler from '../../game/sessionHandler.js'
export async function getSessionList(req, res) {
try {
@@ -45,20 +46,22 @@ export async function createSession(req, res) {
}
try {
const progress = quest.stages[0].id
const container = await createContainer(
`quest-${quest.id}-${req.user.username}-${Date.now()}`
)
const newSession = new Session({
user: req.user,
quest,
progress,
containerId: container.id,
})
const session = await newSession.save()
res.status(201).json(session)
const session = new SessionHandler(newSession)
session.addNewTasks()
await newSession.save()
res.status(201).json(newSession)
} catch (err) {
console.error(err)
res.status(400).json({ message: err.message })
}
}
+74 -58
View File
@@ -5,19 +5,85 @@ import { defaultUser, genSessionJWT } from '../utils/auth.js'
const sessions = new Map()
function listenToSession(sessionId, event, callback) {
const callbacks = sessions.get((sessionId, event)) || []
sessions.set((sessionId, event), callbacks.concat(callback))
function listenToSession(sessionId, callback) {
const callbacks = sessions.get(sessionId) || []
sessions.set(sessionId, callbacks.concat(callback))
}
export function pushToSession(sessionId, event, data) {
console.log('Push:', sessionId, event)
const callbacks = sessions.get((sessionId, event))
function removeFromSession(sessionId, event, callback) {
const callbacks = sessions.get((sessionId, event)) || []
sessions.set(
(sessionId, event),
callbacks.filter((cb) => cb !== callback)
)
}
export function pushToSession(sessionId, event, ...args) {
const callbacks = sessions.get(sessionId)
if (callbacks) {
callbacks.forEach((callback) => callback(data))
callbacks.forEach((callback) => callback(event, ...args))
}
}
async function connectClient(socket) {
console.log('Connected to the client.')
const sessionId = socket.handshake.query.session_id
if (!sessionId) {
socket.send('Session ID not found.')
socket.disconnect()
return
}
const session = await Session.findOne({
_id: sessionId,
user: socket.user,
status: 'active',
})
if (!session) {
socket.send('Session not found.')
socket.disconnect()
return
}
const token = await genSessionJWT(session)
let container
let stream
try {
container = await getAndStartContainer(session.containerId)
stream = await attachContainer(container, { token })
} catch (err) {
socket.send(err.message)
socket.disconnect()
return
}
stream.socket.on('data', (chunk) => {
socket.emit('terminal', chunk.toString())
})
socket.on('message', console.log)
socket.on('terminal', function incoming(message) {
stream.socket.write(message)
})
const socketCallback = (event, ...data) => {
socket.emit(event, ...data)
}
listenToSession(session.id, socketCallback)
socket.on('disconnect', () => {
console.log('Disconnected from the client.')
stream.socket.write('exit\n')
removeFromSession(session.id, socketCallback)
stream.destroy()
})
socket.send(`${container.id}\n`)
}
export default (server) => {
const io = new Server(server)
@@ -34,55 +100,5 @@ export default (server) => {
}
})
io.on('connection', async (socket) => {
console.log('Connected to the client.')
const sessionId = socket.handshake.query.session_id
if (!sessionId) {
socket.send('Session ID not found.')
socket.disconnect()
return
}
let session
try {
session = await Session.findOne({
_id: sessionId,
user: socket.user,
status: 'active',
})
if (!session) {
socket.send('Session not found.')
socket.disconnect()
return
}
} catch (err) {
socket.send(err.message)
socket.disconnect()
return
}
const token = await genSessionJWT(session)
const container = await getAndStartContainer(session.containerId)
const stream = await attachContainer(container, { token })
stream.on('data', (chunk) => {
socket.emit('terminal', chunk.toString())
})
socket.on('message', console.log)
socket.on('terminal', function incoming(message) {
stream.write(message)
})
socket.on('close', () => {
console.log('Disconnected from the client.')
})
listenToSession(session.id, 'graph', (data) => {
socket.emit('graph', data)
})
socket.send(`${container.id}\n`)
})
io.on('connection', connectClient)
}
+6 -11
View File
@@ -15,7 +15,7 @@ const containerOptions = {
Binds:
!config.isProduction && config.hostPwd
? [
`${config.hostPwd}/quests/helloworld/home:/home/rudeus`,
`${config.hostPwd}/quests/helloworld/home:/home/commander`,
`${config.hostPwd}/packages/container:/usr/local/lib/container`,
]
: [],
@@ -35,16 +35,11 @@ export async function createContainer(name) {
export async function getAndStartContainer(id) {
console.log(`Getting container: ${id}`)
let container
try {
container = engine.getContainer(id)
if (!(await container.inspect()).State.Running) {
await container.start()
}
return container
} catch (error) {
throw new Error(`Container not found: ${id}`)
const container = engine.getContainer(id)
if (!(await container.inspect()).State.Running) {
await container.start()
}
return container
}
export async function attachContainer(container, { token }) {
@@ -63,7 +58,7 @@ export async function attachContainer(container, { token }) {
Tty: true,
})
return execOutput.socket
return execOutput
}
export async function deleteContainer(id) {
+20 -37
View File
@@ -2,19 +2,20 @@ import minimist from 'minimist'
import { FileGraph } from '@linux-odyssey/file-graph'
import Quest from '../models/quest.js'
import { pushToSession } from '../api/socket.js'
import SessionHandler from './sessionHandler.js'
// 檢查 pattern 是否符合 input
const checkMatch = (pattern, input) => {
if (pattern.length === 0) return true
if (!pattern || pattern.length === 0) return true
return pattern.some((p) => {
const regex = new RegExp(p)
return regex.test(input)
})
}
export default class CommandHandler {
export default class CommandHandler extends SessionHandler {
constructor(session, commandInput, additionalData) {
this.session = session
super(session)
this.commandInput = commandInput
this.argv = minimist(this.commandInput.command.split(' '))
@@ -50,51 +51,33 @@ export default class CommandHandler {
const graph = new FileGraph(this.session.graph)
graph.discover(this.additionalData.discover)
this.session.graph = graph
await this.session.save()
}
isMatch(stage) {
const keys = ['command', 'output', 'error', 'pwd']
return keys.every((k) =>
checkMatch(stage.condition[k], this.commandInput[k])
)
}
async run() {
const quest = await Quest.findById(this.session.quest)
const stage = quest.stages.find((s) => s.id === this.session.progress)
if (!stage) {
console.error('stage not found', this.session.progress)
this.quest = await Quest.findById(this.session.quest)
const stages = this.getStages()
if (stages.length === 0) {
console.error('stage not found', this.session.tasks)
return {}
}
this.handleEvent()
this.handleCommand()
const commandMatch = checkMatch(
stage.condition.command,
this.commandInput.command
)
const outputMatch = checkMatch(
stage.condition.output,
this.commandInput.output
)
const errorMatch = checkMatch(
stage.condition.error,
this.commandInput.output
)
if (!commandMatch || !outputMatch || !errorMatch) return {}
this.session.completion.push(this.session.progress)
this.session.hints.push(...stage.hints)
this.session.progress = stage.next
await this.session.save()
if (stage.next === 'END') {
this.session.status = 'finished'
this.session.finishedAt = new Date()
await this.session.save()
}
const response = stages
.filter((s) => this.isMatch(s))
.reduce((r, s) => ({ ...r, ...this.execute(s) }), {})
return {
responses: stage.responses,
hints: stage.hints,
end: stage.next === 'END',
end: this.session.status === 'finished',
...response,
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import { pushToSession } from '../api/socket.js'
export default class SessionHandler {
constructor(session) {
this.session = session
this.quest = session.quest
}
getTask(stage) {
return this.session.tasks.find((t) => t.id === stage.id)
}
isCompleted(stage) {
const task = this.getTask(stage)
return task ? task.completed : false
}
getStages() {
return this.quest.stages.filter(
(stage) =>
stage.requirements.every((r) => this.isCompleted({ id: r })) &&
(stage.repeatable || !this.isCompleted(stage))
)
}
getNewTasks() {
const stages = this.getStages()
return stages
.filter((s) => s.task)
.filter((s) => !this.session.tasks.some((t) => t.id === s.id))
.map((s) => ({ id: s.id, name: s.task, completed: false }))
}
addNewTasks() {
const newTasks = this.getNewTasks()
this.session.tasks.push(...newTasks)
pushToSession(this.session.id, 'tasks', this.session.tasks)
}
execute(stage) {
this.session.tasks
.filter((t) => t.id === stage.id)
.forEach((t) => {
t.completed = true
})
this.session.hints.push(...stage.hints)
pushToSession(this.session.id, 'hints', stage.hints)
if (stage.id === 'END') {
this.session.status = 'finished'
this.session.finishedAt = new Date()
}
this.addNewTasks()
return {
responses: stage.responses,
hints: stage.hints,
}
}
}
+30 -16
View File
@@ -15,19 +15,25 @@ const stageSchema = new Schema({
type: String,
required: true,
},
task: {
type: String,
},
requirements: [
{
type: String,
required: true,
},
],
repeatable: {
type: Boolean,
required: true,
default: false,
},
condition: {
command: {
type: [String],
required: false,
},
output: {
type: [String],
required: false,
},
error: {
type: [String],
required: false,
},
command: [String],
output: [String],
error: [String],
pwd: [String],
},
responses: {
type: [responseSchema],
@@ -37,10 +43,6 @@ const stageSchema = new Schema({
type: [String],
required: true,
},
next: {
type: String,
required: true,
},
})
const Quest = model(
@@ -50,6 +52,18 @@ const Quest = model(
title: String,
order: Number,
instruction: String,
tasks: {
id: String,
content: String,
display: {
type: Boolean,
default: false,
},
completed: {
type: Boolean,
default: false,
},
},
stages: [stageSchema],
})
)
+16 -2
View File
@@ -14,6 +14,21 @@ nodeSchema.add({
children: [nodeSchema],
})
const taskSchema = new Schema({
id: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
completed: {
type: Boolean,
default: false,
},
})
const Session = model(
'Session',
new Schema(
@@ -39,8 +54,7 @@ const Session = model(
default: Date.now,
required: true,
},
progress: String,
completion: [String],
tasks: [taskSchema],
hints: [String],
graph: {
type: nodeSchema,
+1 -1
View File
@@ -19,7 +19,7 @@ export function genSessionJWT(session) {
jwt.sign(
{ session_id: session.id },
config.jwtSecret,
{ expiresIn: '1h' },
{ expiresIn: config.expiry },
(err, token) => {
if (err) {
reject(err)
+1 -1
View File
@@ -392,7 +392,7 @@ components:
type: string
description: current stage id
example: helloworld
completion:
completed:
type: array
description: list of completed stage ids
items:
+9
View File
@@ -1207,6 +1207,15 @@ axios@^1.4.0:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axios@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267"
integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
babel-jest@^29.6.1:
version "29.6.1"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.1.tgz#a7141ad1ed5ec50238f3cd36127636823111233a"