Merge pull request #45 from linux-odyssey/quest

Add new functions for quest:

* new condition logic `or` & `not`: able to create nested condition
* new condition `files`: checks if a file or folder exists in the container
  * not support globbing yet
* send "pending" status when executing story, and set it back after execution
* include completed stage id in command

Dockerfile refactor:
* use multi-staged building
* remove Dockerfile.dev

Container:

* use axios instead of `fetch`

Other improvements:

* deactivate other sessions when creating a new one
* clear terminal on reset
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-10-30 14:42:34 +08:00
commit f8254b55c9
49 files changed
+784 -329

No files matched your search

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