Refactor game/packages/trpc (#179)

- Refactor game core logic in `packages/game`
- Use Zod to parse quests and store in memory directly
- Get quest responses dynamically. Not stored in Session anymore.
- Use tRPC to re-implement some of APIs
- Remove all package.json in `packages`, import them as regular files
- Add JSON schema for quest.yml for better editing experience
- Refine quest files
This commit is contained in:
Justin Lin authored and GitHub committed 2025-04-26 17:10:58 +08:00
commit a66b4f5adb
186 files changed
+4539 -4038

No files matched your search

+32 -7
View File
@@ -20,9 +20,12 @@ module.exports = {
settings: { settings: {
'import/resolver': { 'import/resolver': {
node: { node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'], extensions: ['.js', '.ts'],
moduleDirectory: ['node_modules', 'packages', 'server/src'],
},
typescript: {
alwaysTryTypes: true,
}, },
typescript: {},
}, },
vue: { vue: {
version: '3.0', // Explicitly specify Vue 3 version: '3.0', // Explicitly specify Vue 3
@@ -49,7 +52,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'error', 'warn',
{ {
args: 'after-used', args: 'after-used',
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
@@ -63,21 +66,43 @@ module.exports = {
// Allow missing file extensions // Allow missing file extensions
'import/extensions': [ 'import/extensions': [
'warn', 'off',
'ignorePackages', 'ignorePackages',
{ {
js: 'off', js: 'never',
jsx: 'never', jsx: 'never',
ts: 'never', ts: 'never',
tsx: 'never', tsx: 'never',
}, },
], ],
'no-unused-vars': 'warn',
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-restricted-imports': ['error', '../*'],
'import/no-relative-packages': 'off',
}, },
overrides: [ overrides: [
{ {
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'],
rules: { rules: {
'import/extensions': 'off', // Turn off the rule for test files 'import/extensions': 'off',
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['server/**/*.ts'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['**/packages/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'import/no-extraneous-dependencies': 'off', 'import/no-extraneous-dependencies': 'off',
}, },
}, },
+2 -1
View File
@@ -1,6 +1,7 @@
name: E2E Tests name: E2E Tests
on: [push, pull_request] on:
workflow_dispatch:
jobs: jobs:
e2e: e2e:
+1
View File
@@ -1,4 +1,5 @@
config config
.quest
# Logs # Logs
logs logs
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true,
"yaml.schemas": {
"packages/game/quest.json": "quests/**/game.yml"
}
} }
+11
View File
@@ -163,6 +163,17 @@ Run Cypress in container, create brand-new containers along with it:
## Deployment ## Deployment
Create production env
cp .env.prod.sample .env.prod
Edit `.env.prod`
BASE_URL=https://yourdomain.com # IMPORTANT to make login work
SECRET_KEY=xxxx # Change it to something safe!
Start the service
# Build the images (only allowed for maintainers) # Build the images (only allowed for maintainers)
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml push docker compose -f docker-compose.prod.yml push
+6 -17
View File
@@ -1,26 +1,15 @@
# Base stage # Base stage
FROM node:20-alpine as base FROM node:20-alpine as build
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock tsconfig.json ./ COPY package.json yarn.lock ./
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY app/package.json ./app/package.json COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
# Dev stage COPY tsconfig.json ./tsconfig.json
FROM base as dev COPY server ./server
WORKDIR /app/app COPY packages ./packages
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 packages/constants ./packages/constants
COPY packages/models ./packages/models
COPY app ./app COPY app ./app
RUN yarn workspace app build RUN yarn workspace app build
+8 -10
View File
@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite", "dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc && vite build",
"type-check": "vue-tsc -b", "type-check": "vue-tsc",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -16,10 +16,7 @@
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3", "@fortawesome/vue-fontawesome": "^3.0.3",
"@linux-odyssey/constants": "*", "@trpc/client": "^11.1.0",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"axios": "^1.6.0", "axios": "^1.6.0",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
@@ -37,13 +34,14 @@
}, },
"devDependencies": { "devDependencies": {
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.13", "@vue/compiler-sfc": "^3.3.13",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "^5.3.3", "@vitejs/plugin-vue": "^5.2.2",
"vite": "^4.5.3", "@vue/tsconfig": "^0.7.0",
"vue-tsc": "^2.0.10" "typescript": "~5.7.2",
"vite": "^6.3.1",
"vue-tsc": "^2.2.8"
} }
} }
+1 -1
View File
@@ -22,7 +22,7 @@ function nodeClass() {
<template> <template>
<a href="#" :class="nodeClass()"> <a href="#" :class="nodeClass()">
<font-awesome-icon <font-awesome-icon
v-if="node.type === 'folder'" v-if="node.type === 'directory'"
:icon="['far', 'folder']" :icon="['far', 'folder']"
class="text-text-primary size-8" class="text-text-primary size-8"
/> />
+24 -4
View File
@@ -31,11 +31,31 @@ const html = computed(() => {
.markdown-content :deep(ol) { .markdown-content :deep(ol) {
list-style: decimal; list-style: decimal;
padding-left: 1em; padding-left: 1em;
line-height: 1.2;
} }
.markdown-content :deep(code) { .markdown-content :deep(ul) {
background-color: #282a33; list-style: disc;
padding-left: 1em;
line-height: 1.2;
}
.markdown-content :deep(pre) {
background-color: #2e3440;
border-radius: 0.5rem;
padding: 0.5rem;
font-size: 0.9rem;
color: #d8dee9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #4c566a;
line-height: 1.5;
font-family: 'monospace';
}
.markdown-content :deep(code:not(pre code)) {
background-color: #2e3440;
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.25rem; padding: 0.2rem 0.4rem;
font-size: 0.875rem; font-size: 0.9rem;
color: #d8dee9;
font-family: 'monospace';
} }
</style> </style>
+2 -2
View File
@@ -2,8 +2,8 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { ref, defineProps, onMounted } from 'vue' import { ref, defineProps, onMounted } from 'vue'
import { getQuest } from '../utils/api'
import MarkdownText from './MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
import { trpc } from '../utils/trpc'
const props = defineProps({ const props = defineProps({
questTitle: { questTitle: {
@@ -49,7 +49,7 @@ const handleColour = () => {
} }
} }
onMounted(async () => { onMounted(async () => {
const quest = await getQuest(questId) const quest = await trpc.quests.getQuestDetail.query(questId)
questInstruction.value = quest.instruction questInstruction.value = quest.instruction
handleColour() handleColour()
}) })
+6 -6
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { DAG } from '@linux-odyssey/utils' import { DAG } from '../../../packages/utils'
import { getQuests } from '../utils/api' import { trpc } from '../utils/trpc'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import QuestIntro from './QuestIntro.vue' import QuestIntro from './QuestIntro.vue'
@@ -64,19 +64,19 @@ async function computeGraphData() {
if (!store.progress) return if (!store.progress) return
try { try {
const quests = await getQuests() const quests = await trpc.quests.getQuests.query()
const dag = new DAG(quests) const dag = new DAG(quests)
const nodesValues = dag.getNodes() const nodesValues = dag.getNodes()
const nodes = nodesValues.map((node) => ({ const nodes = nodesValues.map((node) => ({
id: node._id, id: node.id,
title: node.title, title: node.title,
x: marginX * node.layer * 2, x: marginX * node.layer * 2,
y: y:
marginY * node.index - marginY * node.index -
(marginX * dag.getLayer(node._id)) / 2 + (marginX * dag.getLayer(node.id)) / 2 +
svgHeight.value / 2, svgHeight.value / 2,
completed: store.progress[node._id]?.completed || false, completed: store.progress[node.id]?.completed || false,
unlocked: node.requirements.every( unlocked: node.requirements.every(
(req: string) => store.progress[req]?.completed (req: string) => store.progress[req]?.completed
), ),
+27 -9
View File
@@ -5,8 +5,9 @@ import MarkdownText from '../MarkdownText.vue'
const sessionStore = useSession() const sessionStore = useSession()
const current = ref(-1) const current = ref(-1)
const hintDetails = ref<HTMLDetailsElement | null>(null)
const length = computed(() => { const length = computed(() => {
return sessionStore.session?.responses.length ?? 0 return sessionStore.session?.responses?.length ?? 0
}) })
const disabled = computed(() => { const disabled = computed(() => {
@@ -14,6 +15,7 @@ const disabled = computed(() => {
}) })
watch(length, () => { watch(length, () => {
console.log('length update', length.value)
current.value = length.value - 1 current.value = length.value - 1
}) })
@@ -28,6 +30,19 @@ const right = () => {
current.value += 1 current.value += 1
} }
} }
const response = computed(() => {
if (!sessionStore.session) return null
if (current.value < 0) return null
if (current.value >= sessionStore.session.responses.length) return null
return sessionStore.session.responses[current.value]
})
watch(response, () => {
if (hintDetails.value) {
hintDetails.value.open = false
}
})
</script> </script>
<template> <template>
@@ -62,27 +77,30 @@ const right = () => {
@click="right" @click="right"
:disabled="disabled" :disabled="disabled"
> >
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" /> <font-awesome-icon
:icon="['fas', 'arrow-right']"
:class="disabled ? 'text-gray-600' : 'text-text'"
/>
</button> </button>
</div> </div>
</div> </div>
<div id="hint" class="bg-bg p-8 overflow-y-auto"> <div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul> <ul>
<li <li
v-for="response in sessionStore.session?.responses[current]" v-if="response"
:key="response.type" :key="response.type"
class="text-text font-xl whitespace-pre-wrap" class="text-text font-xl whitespace-pre-wrap"
> >
<div v-for="content in response.content" :key="content"> <MarkdownText :content="response.content" />
<MarkdownText :content="content" />
</div>
</li> </li>
<li <li
v-for="hint in sessionStore.session?.hints[current]" v-if="response?.hint"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap" class="text-text-primary font-xl whitespace-pre-wrap"
> >
<MarkdownText :content="hint" /> <details ref="hintDetails">
<summary class="cursor-pointer">提示</summary>
<MarkdownText :content="response.hint" />
</details>
</li> </li>
</ul> </ul>
<br /> <br />
+2 -2
View File
@@ -2,11 +2,11 @@
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
import useSession from '../../store/session' import useSession from '../../store/session'
import { Task } from '../../types'
const sessionStore = useSession() const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
return sessionStore.session?.tasks.map((task: Task) => { console.log('session', sessionStore.session)
return sessionStore.session?.tasks.map((task) => {
const prefix = task.completed ? '✓' : '➤' const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text' const color = task.completed ? 'text-text-primary' : 'text-text'
return { return {
+21 -10
View File
@@ -1,23 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useTerminal } from '../../store/session' import { useTerminal } from '../../store/session'
const terminal = ref<HTMLElement | null>(null) const terminal = ref<HTMLElement | null>(null)
const term = useTerminal()
onMounted(() => { onMounted(() => {
const term = useTerminal()
if (!terminal.value) return if (!terminal.value) return
term.mount(terminal.value) term.mount(terminal.value)
window.addEventListener('resize', () => term.resizeScreen())
const handleResize = () => term.resizeScreen()
window.addEventListener('resize', handleResize)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
}) })
</script> </script>
<template> <template>
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" /> <div class="flex flex-col h-full">
<button id="Terminal" class="text-text h-[5%]">終端機</button> <div class="flex items-center gap-2 p-1">
<div <font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
id="terminal" <button id="Terminal" class="text-text">終端機</button>
ref="terminal" </div>
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5" <div
></div> id="terminal"
ref="terminal"
class="bg-bg-secondary flex-grow overflow-hidden px-2"
style="height: calc(100vh - 40px)"
></div>
</div>
</template> </template>
@@ -3,7 +3,7 @@ import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth' import { logout } from '../../utils/auth'
import { surveyUrl } from '../../config' import { trpc } from '../../utils/trpc'
import { openBugReport } from '../../utils/formbricks' import { openBugReport } from '../../utils/formbricks'
import useSession from '../../store/session' import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile' import useUserProfile from '../../store/userProfile'
@@ -26,6 +26,11 @@ const handleLogout = async () => {
} }
} }
const openSurvey = async () => {
const surveyUrl = await trpc.links.surveyLink.query()
window.open(surveyUrl, '_blank')
}
onMounted(async () => { onMounted(async () => {
try { try {
await userStore.loadUserProfile() await userStore.loadUserProfile()
@@ -61,18 +66,18 @@ onMounted(async () => {
> >
{{ userStore.username }} {{ userStore.username }}
</p> </p>
<a <button
title="Survey" title="Survey"
:href="surveyUrl"
target="_blank" target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto" class="md:h-5 md:w-5 px-1.5 w-auto"
@click="openSurvey"
> >
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'file-invoice']" :icon="['fas', 'file-invoice']"
class="text-text-primary inline" class="text-text-primary inline"
/> />
<span class="text-text sm:hidden px-2">Survey</span> <span class="text-text sm:hidden px-2">Survey</span>
</a> </button>
<RouterLink <RouterLink
title="LeaderBoard" title="LeaderBoard"
to="/leaderboard" to="/leaderboard"
+59 -38
View File
@@ -1,19 +1,37 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph' import {
import { IQuest } from '@linux-odyssey/models' FileGraph,
import { createSession, getActiveSession, getQuest } from '../utils/api' type FileGraphUpdateEvent,
} from '../../../packages/file-graph'
import type { IResponse, ITask } from '../../../packages/game'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import Socket from '../utils/socket' import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal' import SocketTerminal from '../utils/terminal'
import { Session, StageResponse } from '../types' import type { Session } from '../types'
import { trpc } from '../utils/trpc'
const socket = new Socket() const socket = new Socket()
const term = new SocketTerminal(40, 80) const term = new SocketTerminal()
let hasSetup = false let hasSetup = false
interface QuestDetailResponse {
id: string
title: string
instruction: string
requirements: string[]
}
interface SessionUpdate {
status: string
responses: IResponse[]
tasks: ITask[]
graphUpdate: FileGraphUpdateEvent
}
interface Store { interface Store {
session: Session | null session: Session | null
questId: string questId: string
quest: IQuest | null quest: QuestDetailResponse | null
} }
const useSession = defineStore('session', { const useSession = defineStore('session', {
@@ -24,52 +42,51 @@ const useSession = defineStore('session', {
}), }),
actions: { actions: {
async setQuest(questId: string) { async setQuest(questId: string) {
this.quest = await getQuest(questId) this.quest = await trpc.quests.getQuestDetail.query(questId)
this.questId = questId this.questId = questId
}, },
async setSession(session: Session) { async setSession(session: SessionDetail) {
this.session = session this.session = {
this.session.graph = new FileGraph(session.graph) ...session,
graph: new FileGraph(session.graph),
pwd: '/home/commander',
}
term.reset() term.reset()
await socket.connect(session) await socket.connect(this.session)
term.focus() term.focus()
}, },
async createSession() { async createSession() {
const session = await createSession(this.questId) const session = await trpc.session.createSession.mutate({
questId: this.questId,
})
await this.setSession(session) await this.setSession(session)
socket.emit('terminal', 'echo start\n') socket.emit('terminal', 'echo start\n')
}, },
async getActiveSession() { async getActiveSession() {
const session = await getActiveSession(this.questId) const session = await trpc.session.getActiveSession.query({
questId: this.questId,
})
if (session) { if (session) {
this.setSession(session) this.setSession(session)
} }
}, },
updateGraph(event: { discover?: FileObject[]; pwd?: string }) { newUpdate(update: SessionUpdate) {
if (!this.session) return if (!this.session) return
if (event.discover) { this.session.responses = update.responses
this.session.graph.discover(event.discover) this.session.tasks = update.tasks
} this.session.status = update.status
if (event.pwd) { // if (
this.session.pwd = event.pwd // response.status === 'finished' &&
} // this.session.status !== 'finished'
// ) {
// this.finish()
// }
// this.session.status = response.status
}, },
newResponse(response: StageResponse) { finish() {
if (!this.session) return if (!this.session) return
this.session.responses.push(response.responses) this.session.status = 'finished'
this.session.hints.push(response.hints)
if (response.tasks) {
this.session.tasks = response.tasks
}
if (
response.status === 'finished' &&
this.session.status !== 'finished'
) {
this.finish()
}
this.session.status = response.status
}, },
finish() {},
reset() { reset() {
socket.reset() socket.reset()
term.reset() term.reset()
@@ -83,11 +100,15 @@ const useSession = defineStore('session', {
term.onData((data: string) => { term.onData((data: string) => {
socket.emit('terminal', data) socket.emit('terminal', data)
}) })
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => { socket.on('graph', (event: FileGraphUpdateEvent) => {
this.updateGraph(event) if (!this.session) return
this.session.graph.handleEvent(event)
if (event.pwd) {
this.session.pwd = event.pwd
}
}) })
socket.on('response', (response: StageResponse) => { socket.on('update', (update: SessionUpdate) => {
this.newResponse(response) this.newUpdate(update)
}) })
hasSetup = true hasSetup = true
}, },
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { getUserProfile } from '../utils/api' import { trpc } from '../utils/trpc'
interface IProgress { interface IProgress {
quest: string quest: string
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
}), }),
actions: { actions: {
async loadUserProfile() { async loadUserProfile() {
const res = await getUserProfile() const res = await trpc.users.getMyProfile.query()
this.$patch(res) this.$patch(res)
}, },
async resetUserProfile() { async resetUserProfile() {
+4 -6
View File
@@ -1,6 +1,5 @@
import { FileGraph } from '@linux-odyssey/file-graph' import type { FileGraph } from '../../packages/file-graph'
// eslint-disable-next-line import/no-relative-packages import type { IResponse, ITask } from '../../packages/game'
import { IResponse } from '@linux-odyssey/models'
export interface Task { export interface Task {
id: number id: number
@@ -20,7 +19,6 @@ export interface Session {
status: string status: string
graph: FileGraph graph: FileGraph
pwd: string pwd: string
hints: string[][] tasks: ITask[]
tasks: Task[] responses: IResponse[]
responses: IResponse[][]
} }
-40
View File
@@ -1,8 +1,4 @@
import axios from 'axios' import axios from 'axios'
import { IQuest } from '@linux-odyssey/models'
import { GetSessionsRequest } from '@linux-odyssey/constants'
import { Session } from '../types'
import { UserProfile } from '../store/userProfile'
const api = axios.create({ const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
@@ -12,39 +8,3 @@ const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
export default api export default api
export async function getUserProfile(): Promise<UserProfile> {
const res = await api.get<UserProfile>('/users/me')
return res.data
}
export async function getQuest(id: string): Promise<IQuest> {
const res = await api.get<IQuest>(`/quests/${id}`)
return res.data
}
export async function createSession(questId: string): Promise<Session> {
const res = await api.post<Session>('/sessions', { questId })
return res.data
}
export async function getSessions(
request: GetSessionsRequest
): Promise<Session[]> {
const res = await api.get<Session[]>('/sessions', { params: request })
return res.data
}
export async function getActiveSession(
questId: string
): Promise<Session | null> {
const res = await api.get<Session | null>('/sessions/active', {
params: { questId },
})
return res.data
}
export async function getQuests(): Promise<IQuest[]> {
const res = await api.get<IQuest[]>('/quests')
return res.data
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import { Session } from '../types' import type { Session } from '../types'
type SocketCallback = (data: any) => void type SocketCallback = (data: any) => void
+21 -24
View File
@@ -6,33 +6,39 @@ class SocketTerminal {
private term: Terminal private term: Terminal
private fitAddon: FitAddon private fitAddon: FitAddon
constructor(rows: number, cols: number) { constructor() {
this.term = new Terminal({ this.term = new Terminal({
rows, // 行数 convertEol: true,
cols, // 不指定行数,自动回车后光标从下一行开始 scrollback: 1000,
convertEol: true, // 启用时,光标将设置为下一行的开头 fontSize: 16,
// scrollback: 50, //终端中的回滚量
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
theme: { theme: {
foreground: '#ECECEC', // 字体 foreground: '#ECECEC',
background: '#000000', // 背景色 background: '#000000',
cursor: 'help', // 设置光标 cursor: 'help',
}, },
fontSize: 18,
fontWeight: 'normal',
}) })
this.fitAddon = new FitAddon() this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon) this.term.loadAddon(this.fitAddon)
this.fitAddon.fit() }
resizeScreen(): void {
try {
this.fitAddon.fit()
} catch (e) {
console.error('Failed to resize terminal:', e)
}
}
mount(reference: HTMLElement): void {
this.term.open(reference)
this.resizeScreen()
} }
write(message: string): void { write(message: string): void {
this.term.write(message) this.term.write(message)
} }
onData(callback: any): void { onData(callback: (data: string) => void): void {
this.term.onData(callback) this.term.onData(callback)
} }
@@ -40,15 +46,6 @@ class SocketTerminal {
this.term.focus() this.term.focus()
} }
resizeScreen(): void {
this.fitAddon.fit()
}
mount(reference: HTMLElement): void {
this.term.open(reference)
this.resizeScreen()
}
reset(): void { reset(): void {
this.term.reset() this.term.reset()
} }
+16
View File
@@ -0,0 +1,16 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../../../server/src/routers'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/trpc',
// You can pass any HTTP headers you wish here
// async headers() {
// return {
// authorization: getAuthCookie(),
// }
// },
}),
],
})
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isValidUsername } from '@linux-odyssey/utils' import { isValidUsername } from '../../../packages/utils'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors' import { TooManyRequestsError, ValidationError } from '../utils/errors'
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import validator from 'validator' import validator from 'validator'
import { isValidUsername } from '@linux-odyssey/utils' import { isValidUsername } from '../../../packages/utils'
import { passwordPolicy } from '@linux-odyssey/constants' import { passwordPolicy } from '../../../packages/constants'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+3 -26
View File
@@ -1,30 +1,7 @@
{ {
"compilerOptions": { "files": [],
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [ "references": [
{ "path": "../packages/utils" }, { "path": "./tsconfig.app.json" },
{ "path": "../packages/constants" }, { "path": "./tsconfig.node.json" }
{ "path": "../packages/file-graph" },
{ "path": "../packages/models" }
] ]
} }
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -1,11 +1,26 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@game': path.resolve(__dirname, '../packages/game'),
'@models': path.resolve(__dirname, '../packages/models'),
'@utils': path.resolve(__dirname, '../packages/utils'),
'@constants': path.resolve(__dirname, '../packages/constants'),
'@file-graph': path.resolve(__dirname, '../packages/file-graph'),
},
},
server: { server: {
proxy: { proxy: {
'/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
xfwd: true,
},
'/api': { '/api': {
target: process.env.API_TARGET || 'http://localhost:3000', target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false, changeOrigin: false,
+5
View File
@@ -12,6 +12,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location /trpc/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api-docs/ { location /api-docs/ {
proxy_pass http://backend:3000; proxy_pass http://backend:3000;
} }
+4 -4
View File
@@ -9,7 +9,7 @@ services:
dockerfile: app/Dockerfile dockerfile: app/Dockerfile
target: prod target: prod
networks: networks:
internal: - internal
ports: ports:
- 8080:80 - 8080:80
@@ -31,7 +31,7 @@ services:
- ./config:/app/config - ./config:/app/config
- ./quests:/app/quests:ro - ./quests:/app/quests:ro
env_file: env_file:
- .env - .env.prod
environment: environment:
- DOCKER_NETWORK=linux-odyssey-players - DOCKER_NETWORK=linux-odyssey-players
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
@@ -51,7 +51,7 @@ services:
ports: ports:
- '3001:3001' - '3001:3001'
env_file: env_file:
- .env - .env.prod
db: db:
image: mongo:6 image: mongo:6
@@ -64,7 +64,7 @@ services:
- mongo:/data/db - mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
env_file: env_file:
- .env - .env.prod
base: base:
image: linuxodyssey/quest-base image: linuxodyssey/quest-base
+10 -9
View File
@@ -34,28 +34,29 @@
"@jest/globals": "^29.7.0", "@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.21.0",
"@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.44.0", "eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.0",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^5.3.3",
"vue-eslint-parser": "^9.4.2",
"zod-to-json-schema": "^3.24.5"
}, },
"dependencies": { "dependencies": {
"latest": "^0.2.0", "latest": "^0.2.0"
"typescript-eslint": "^7.4.0"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }
+7 -6
View File
@@ -1,19 +1,20 @@
FROM node:20-alpine as build FROM node:20-alpine as build
WORKDIR /app WORKDIR /app
COPY ./package.json yarn.lock tsconfig.json ./ COPY ./package.json yarn.lock ./
COPY packages/analytics/package.json ./packages/analytics/package.json COPY packages/analytics/package.json ./packages/analytics/package.json
COPY packages/models/package.json ./packages/models/package.json
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json
COPY packages ./packages
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=3001 ENV PORT=3001
EXPOSE 3001 EXPOSE 3001
# Production stage # Production stage
FROM build as prod FROM node:20-alpine as prod
COPY packages/analytics ./packages/analytics COPY --from=build /app/packages/analytics ./packages/analytics
COPY packages/models ./packages/models
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app/packages/analytics WORKDIR /app/packages/analytics
RUN yarn build
CMD [ "yarn", "start" ] CMD [ "yarn", "start" ]
-6
View File
@@ -1,6 +0,0 @@
{
"watch": ["src", "views"],
"ext": "ts,handlebars,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --loader ts-node/esm src/index.ts"
}
+5 -8
View File
@@ -3,16 +3,14 @@
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.js", "main": "src/index.js",
"private": true, "private": true,
"type": "module",
"license": "GPL", "license": "GPL",
"scripts": { "scripts": {
"start": "node dist/index.js", "start": "node dist/analytics/src/index.js",
"build": "tsc -b -f", "build": "tsc",
"watch": "tsc -b --watch", "type-check": "tsc --noEmit",
"dev": "nodemon" "dev": "tsx watch src/index.ts"
}, },
"dependencies": { "dependencies": {
"@linux-odyssey/models": "*",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
@@ -24,9 +22,8 @@
"@swc/helpers": "^0.5.6", "@swc/helpers": "^0.5.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.20", "@types/node": "^20.11.20",
"nodemon": "^3.1.0",
"regenerator-runtime": "^0.14.1", "regenerator-runtime": "^0.14.1",
"ts-node": "^10.9.2", "tsx": "^4.19.3",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }
View File
Whitespace-only changes.
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import Pagination from '../models/pagination.js' import Pagination from '../models/pagination'
import { errorCommands } from '../models/commands.js' import { errorCommands } from '../models/commands'
export async function commandListController( export async function commandListController(
req: Request, req: Request,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { userCount } from '../models/users.js' import { userCount } from '../models/users'
import { questList } from '../models/quests.js' import { questList } from '../models/quests'
export async function homeController(req: Request, res: Response) { export async function homeController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
+1 -1
View File
@@ -1,5 +1,5 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { questList } from '../models/quests.js' import { questList } from '../models/quests'
export async function questListController(req: Request, res: Response) { export async function questListController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import Pagination from '../models/pagination.js' import Pagination from '../models/pagination'
import { sessionList, sessionDetail } from '../models/sessions.js' import { sessionList, sessionDetail } from '../models/sessions'
export async function sessionListController(req: Request, res: Response) { export async function sessionListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
+2 -2
View File
@@ -1,7 +1,7 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import mongoose from 'mongoose' import mongoose from 'mongoose'
import Pagination from '../models/pagination.js' import Pagination from '../models/pagination'
import { userList, userDetail, idToUser } from '../models/users.js' import { userList, userDetail, idToUser } from '../models/users'
export async function userListController(req: Request, res: Response) { export async function userListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
+3 -3
View File
@@ -1,9 +1,9 @@
import connectDB from '@linux-odyssey/models'
import express from 'express' import express from 'express'
import { engine } from 'express-handlebars' import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth' import basicAuth from 'express-basic-auth'
import config from './config.js' import { connectDB } from '../../models'
import router from './router.js' import config from './config'
import router from './router'
async function main() { async function main() {
await connectDB(config.db) await connectDB(config.db)
+2 -2
View File
@@ -1,5 +1,5 @@
import { Command } from '@linux-odyssey/models' import { Command } from '../../../models'
import Pagination from './pagination.js' import Pagination from './pagination'
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) { export async function errorCommands(pagination: Pagination) {
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
// eslint-disable-next-line max-classes-per-file /* eslint-disable max-classes-per-file */
import mongoose, { Types } from 'mongoose' import mongoose, { Types } from 'mongoose'
export const DESC: string = 'desc' export const DESC: string = 'desc'
+10 -4
View File
@@ -1,8 +1,14 @@
/* eslint-disable object-shorthand */ import { UserProfile } from '../../../models'
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export function questList() { export interface QuestProgress {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
return UserProfile.aggregate([ return UserProfile.aggregate([
{ {
$project: { $project: {
+2 -2
View File
@@ -1,5 +1,5 @@
import { Command, Session } from '@linux-odyssey/models' import { Command, Session } from '../../../models'
import Pagination from './pagination.js' import Pagination from './pagination'
import { import {
CommandObject, CommandObject,
SessionDetail, SessionDetail,
+10 -6
View File
@@ -1,7 +1,7 @@
import { Session, User } from '@linux-odyssey/models' import { HydratedDocument, Types } from 'mongoose'
import { Types } from 'mongoose' import { Session, User, IUser, ISession } from '../../../models'
import Pagination from './pagination.js' import Pagination from './pagination'
import { IUser, UserSessionDetail } from '../interface.js' import { UserSessionDetail } from '../interface'
function loginMethods(user: IUser): string[] { function loginMethods(user: IUser): string[] {
const methods: string[] = [] const methods: string[] = []
@@ -23,7 +23,11 @@ export function userCount() {
} }
export async function userList(pagination: Pagination) { export async function userList(pagination: Pagination) {
const users = await Session.aggregate([ const users: {
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = await Session.aggregate([
pagination.match('user'), pagination.match('user'),
{ {
$group: { $group: {
@@ -62,7 +66,7 @@ export async function userList(pagination: Pagination) {
export async function userDetail( export async function userDetail(
id: Types.ObjectId id: Types.ObjectId
): Promise<UserSessionDetail[]> { ): Promise<UserSessionDetail[]> {
const sessions = await Session.find({ const sessions: HydratedDocument<ISession>[] = await Session.find({
user: id, user: id,
}) })
return sessions.map((session): UserSessionDetail => { return sessions.map((session): UserSessionDetail => {
+5 -8
View File
@@ -1,16 +1,13 @@
import { Router } from 'express' import { Router } from 'express'
import { homeController } from './controllers/index.js' import { homeController } from './controllers/index'
import { import { userListController, userDetailController } from './controllers/users'
userListController, import { questListController } from './controllers/quests'
userDetailController, import { commandListController } from './controllers/commands'
} from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import { import {
sessionDetailController, sessionDetailController,
sessionListController, sessionListController,
} from './controllers/sessions.js' } from './controllers/sessions'
const router = Router() const router = Router()
+1 -3
View File
@@ -1,11 +1,9 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "src",
"outDir": "./dist" "outDir": "./dist"
}, },
"include": ["src"], "include": ["src", "../models"],
"references": [{ "path": "../models" }],
"ts-node": { "ts-node": {
"swc": true "swc": true
} }
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@linux-odyssey/constants",
"type": "module",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"exports": "./dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"dependencies": {
"zod": "^3.23.8"
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"include": ["*.js", "*.ts"],
"exclude": ["node_modules", "dist"]
}
+1
View File
@@ -0,0 +1 @@
export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val)
+8 -25
View File
@@ -3,7 +3,6 @@ const fs = require('fs').promises
const { exit } = require('process') const { exit } = require('process')
const minimist = require('minimist') const minimist = require('minimist')
const axios = require('axios') const axios = require('axios')
const { colorize, printResponses, printHints } = require('./print.js')
const discoverFiles = require('./discover.js') const discoverFiles = require('./discover.js')
const { const {
@@ -33,6 +32,13 @@ async function readOrNone(file) {
const commandListeners = { const commandListeners = {
ls: [discoverFiles], ls: [discoverFiles],
cd: [changeDirectory],
}
function changeDirectory() {
return {
pwd: process.cwd(),
}
} }
async function handleCommand(command) { async function handleCommand(command) {
@@ -46,19 +52,6 @@ async function handleCommand(command) {
}, {}) }, {})
} }
async function handleResponse({ responses, hints, end }) {
if (responses) {
await printResponses(responses, 90)
}
if (hints) {
await printHints(hints, 90)
}
if (end) {
console.log(colorize('Quest completed!', 'blue'))
}
await api.post('/commands/completed')
}
async function main() { async function main() {
if (!CMD_NAME) exit(0) if (!CMD_NAME) exit(0)
@@ -74,17 +67,7 @@ async function main() {
pwd: PWD, pwd: PWD,
params, params,
} }
try { await api.post('/commands', payload)
const res = await api.post('/commands', payload)
if (!res.data || !res.data.responses) return
await handleResponse(res.data)
} catch (err) {
if (err.response) {
console.error(err.response.data)
return
}
console.error(err.message)
}
} }
main() main()
+1 -1
View File
@@ -9,7 +9,7 @@ async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
{ {
path: inputPath, path: inputPath,
name, name,
type: stats.isDirectory() ? 'folder' : 'file', type: stats.isDirectory() ? 'directory' : 'file',
discovered, discovered,
}, },
] ]
-115
View File
@@ -1,115 +0,0 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-await-in-loop */
const readline = require('readline')
const RETURN_SYMBOL = '\u21B5'
// ANSI color map
const ANSI_COLORS = {
default: 0,
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
}
function colorCode(color) {
return `\x1b[${ANSI_COLORS[color]}m`
}
function colorize(content, color) {
if (!color) return content
return `${colorCode(color)}${content}${colorCode('default')}`
}
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 formatLine(content, color) {
let result = colorCode(color)
let codeBlock = false
for (const c of content) {
switch (c) {
case '`':
result += colorCode(codeBlock ? color : 'green')
codeBlock = !codeBlock
break
default:
result += c
}
}
return result + colorCode('default') + RETURN_SYMBOL
}
function printLine(content, delay, color) {
const printContent = formatLine(content, color)
return new Promise((resolve) => {
let i = 0
const timer = setInterval(() => {
process.stdout.write(printContent[i])
i += 1
if (i >= printContent.length) {
clearInterval(timer)
process.stdout.write('\n')
resolve()
}
}, delay)
waitForEnter().then(() => {
clearInterval(timer)
if (i >= printContent.length) return
process.stdout.write(printContent.slice(i))
process.stdout.write('\n')
resolve()
})
})
}
async function printResponses(responses, delay = 100) {
console.log()
console.log(colorize('==============================', 'green'))
for (const response of responses) {
const { type, content, speaker, color } = response
if (type === 'narrative') {
for (const line of content) {
await printLine(line, delay, color || 'cyan')
}
}
if (type === 'dialogue') {
console.log(colorize(`${speaker}:`, 'green'))
for (const line of content) {
await printLine(` ${line}`, delay, color || 'cyan')
}
}
console.log(colorize('------------------------------', 'green'))
console.log()
}
}
async function printHints(hints, delay = 100) {
for (const hint of hints) {
await printLine(hint, delay, 'yellow')
}
}
module.exports = {
printResponses,
printHints,
colorize,
}
File renamed without changes.
@@ -2,14 +2,14 @@ import { basename } from './utils.js'
export interface FileObject { export interface FileObject {
path: string path: string
type: string type: 'file' | 'directory'
discovered: boolean discovered: boolean
} }
export default class File implements FileObject { export default class File implements FileObject {
path: string path: string
name: string name: string
type: string type: 'file' | 'directory'
discovered: boolean discovered: boolean
constructor(obj: FileObject) { constructor(obj: FileObject) {
@@ -20,7 +20,7 @@ export default class File implements FileObject {
} }
isDirectory() { isDirectory() {
return this.type === 'folder' return this.type === 'directory'
} }
contains(file: FileObject) { contains(file: FileObject) {
@@ -1,10 +1,11 @@
import { FileObject } from './file.js' import { FileObject } from './file.js'
import FileNode from './fileNode.js' import FileNode from './fileNode.js'
export interface Event { export interface FileGraphUpdateEvent {
add?: FileObject[] add?: FileObject[]
remove?: FileObject[] remove?: FileObject[]
discover?: FileObject[] discover?: FileObject[]
pwd?: string
} }
export default class FileGraph extends FileNode { export default class FileGraph extends FileNode {
@@ -20,7 +21,7 @@ export default class FileGraph extends FileNode {
remove(files: FileObject[]) { remove(files: FileObject[]) {
// Logic to remove a file node from the graph // Logic to remove a file node from the graph
// If the file node is a folder, also remove all children // If the file node is a directory, also remove all children
// If the file node doesn't exist, throw an error // If the file node doesn't exist, throw an error
files.forEach((file) => { files.forEach((file) => {
@@ -42,7 +43,7 @@ export default class FileGraph extends FileNode {
this.merge(newNode) this.merge(newNode)
} }
handleEvent(event: Event) { handleEvent(event: FileGraphUpdateEvent) {
// eslint-disable-next-line default-case // eslint-disable-next-line default-case
switch (true) { switch (true) {
case !!event.add: case !!event.add:
@@ -6,10 +6,17 @@ import {
} from './errors.js' } from './errors.js'
import { dirname } from './utils.js' import { dirname } from './utils.js'
export default class FileNode extends File { export interface IFileNode {
path: string
type: 'file' | 'directory'
discovered: boolean
children?: IFileNode[]
}
export default class FileNode extends File implements IFileNode {
children: FileNode[] children: FileNode[]
constructor(file: FileObject | FileNode) { constructor(file: FileObject | IFileNode) {
super(file) super(file)
this.children = [] this.children = []
if ((<FileNode>file).children) if ((<FileNode>file).children)
@@ -45,7 +52,7 @@ export default class FileNode extends File {
} else if (makeParents) { } else if (makeParents) {
const parent = new FileNode({ const parent = new FileNode({
path: parentPath, path: parentPath,
type: 'folder', type: 'directory',
discovered: false, discovered: false,
children: [file], children: [file],
}) })
+6
View File
@@ -0,0 +1,6 @@
export { default as File } from './file'
export type { FileObject } from './file'
export { default as FileNode } from './fileNode'
export type { IFileNode } from './fileNode'
export { default as FileGraph } from './fileGraph'
export type { FileGraphUpdateEvent } from './fileGraph'
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@linux-odyssey/file-graph",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": "./dist/index.js",
"license": "MIT",
"scripts": {
"build": "tsc",
"dev": "tsc -w",
"test": "yarn build && jest"
}
}
-3
View File
@@ -1,3 +0,0 @@
export { default as File, FileObject } from './file.js'
export { default as FileNode } from './fileNode.js'
export { default as FileGraph, Event } from './fileGraph.js'
+2 -2
View File
@@ -1,10 +1,10 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import File from '../src/file.js' import File from '../file.js'
describe('File', () => { describe('File', () => {
let file: File let file: File
beforeEach(() => { beforeEach(() => {
file = new File({ path: '/home/user', type: 'folder', discovered: true }) file = new File({ path: '/home/user', type: 'directory', discovered: true })
}) })
const filesToCompare = [ const filesToCompare = [
+19 -18
View File
@@ -1,6 +1,7 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import FileGraph from '../src/fileGraph.js' import FileGraph from '../fileGraph.js'
import FileNode from '../src/fileNode.js' import FileNode from '../fileNode.js'
import { FileObject } from '../file.js'
describe('FileGraph', () => { describe('FileGraph', () => {
let root: FileNode let root: FileNode
@@ -8,7 +9,7 @@ describe('FileGraph', () => {
beforeEach(() => { beforeEach(() => {
root = new FileNode({ root = new FileNode({
path: '/', path: '/',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}) })
}) })
@@ -16,7 +17,7 @@ describe('FileGraph', () => {
test('add files to graph', () => { test('add files to graph', () => {
const fileGraph = new FileGraph(root) const fileGraph = new FileGraph(root)
const filesToAdd = [ const filesToAdd: FileObject[] = [
{ {
path: '/home/user/file1.txt', path: '/home/user/file1.txt',
type: 'file', type: 'file',
@@ -24,7 +25,7 @@ describe('FileGraph', () => {
}, },
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
] ]
@@ -41,12 +42,12 @@ describe('FileGraph', () => {
const filesToAdd = [ const filesToAdd = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
path: '/home/user/folder2', path: '/home/user/folder2',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
@@ -61,22 +62,22 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.add(filesToAdd) fileGraph.add(filesToAdd as FileObject[])
const filesToRemove = [ const filesToRemove = [
{ {
path: '/home/user/folder1/file1.txt', path: '/home/user/folder1/file1.txt',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
path: '/home/user/folder2', path: '/home/user/folder2',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
] ]
fileGraph.remove(filesToRemove) fileGraph.remove(filesToRemove as FileObject[])
expect(fileGraph.children[0].children[0].children.length).toBe(1) 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].name).toBe('folder1')
@@ -90,7 +91,7 @@ describe('FileGraph', () => {
const discoverFiles = [ const discoverFiles = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
@@ -110,7 +111,7 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.discover(discoverFiles) fileGraph.discover(discoverFiles as FileObject[])
expect(fileGraph.children[0].children[0].children[0].children.length).toBe( expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
3 3
) )
@@ -122,12 +123,12 @@ describe('FileGraph', () => {
const filesToAdd = [ const filesToAdd = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
path: '/home/user/folder2', path: '/home/user/folder2',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
@@ -142,12 +143,12 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.add(filesToAdd) fileGraph.add(filesToAdd as FileObject[])
const newFiles = [ const newFiles = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
}, },
{ {
@@ -167,7 +168,7 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.discover(newFiles) fileGraph.discover(newFiles as FileObject[])
expect(fileGraph.children[0].children[0].children.length).toBe(2) expect(fileGraph.children[0].children[0].children.length).toBe(2)
expect(fileGraph.children[0].children[0].children[0].children.length).toBe( expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
+84 -86
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import { DuplicateItemError, ParentNotExistsError } from '../src/errors.js' import { DuplicateItemError, ParentNotExistsError } from '../errors.js'
import FileNode from '../src/fileNode.js' import FileNode, { IFileNode } from '../fileNode.js'
import { FileObject } from '../src/file.js' import { FileObject } from '../file.js'
function createNodeFromFiles(files: FileObject[]) { function createNodeFromFiles(files: FileObject[]) {
const node = new FileNode(files[0]) const node = new FileNode(files[0])
@@ -42,16 +42,15 @@ describe('FileNode', () => {
beforeEach(() => { beforeEach(() => {
rootNode = new FileNode({ rootNode = new FileNode({
path: '/home/rudeus', path: '/home/rudeus',
name: 'rudeus', type: 'directory',
type: 'folder',
discovered: true, discovered: true,
}) })
}) })
test('should throw error when adding duplicate item', () => { test('should throw error when adding duplicate item', () => {
const child = { const child: FileObject = {
path: '/home/rudeus/test', path: '/home/rudeus/test',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
} }
@@ -67,10 +66,9 @@ describe('FileNode', () => {
}) })
test('should throw error when parent does not exist', () => { test('should throw error when parent does not exist', () => {
const child = { const child: FileObject = {
path: '/home/rudeus/alphabet/test', path: '/home/rudeus/alphabet/test',
name: 'test', type: 'directory',
type: 'folder',
discovered: true, discovered: true,
} }
@@ -81,22 +79,19 @@ describe('FileNode', () => {
}) })
test('adds nested children to the tree', () => { test('adds nested children to the tree', () => {
const files = [ const files: FileObject[] = [
{ {
path: '/home/rudeus/forgotten_scroll.txt', path: '/home/rudeus/forgotten_scroll.txt',
name: 'forgotten_scroll.txt',
type: 'file', type: 'file',
discovered: true, discovered: true,
}, },
{ {
path: '/home/rudeus/test', path: '/home/rudeus/test',
name: 'test', type: 'directory',
type: 'folder',
discovered: true, discovered: true,
}, },
{ {
path: '/home/rudeus/test/a', path: '/home/rudeus/test/a',
name: 'a',
type: 'file', type: 'file',
discovered: false, discovered: false,
}, },
@@ -111,9 +106,8 @@ describe('FileNode', () => {
}) })
test('adds nested children to the tree with makeParents option', () => { test('adds nested children to the tree with makeParents option', () => {
const file = { const file: FileObject = {
path: '/home/rudeus/test/a/b/c/d/e', path: '/home/rudeus/test/a/b/c/d/e',
name: 'a',
type: 'file', type: 'file',
discovered: false, discovered: false,
} }
@@ -129,34 +123,34 @@ describe('FileNode', () => {
}) })
test('merge-subtree', () => { test('merge-subtree', () => {
const root = { const root: FileObject = {
path: '/', path: '/',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
} }
const files1 = [ const files1: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/d', type: 'file', discovered: true }, { path: '/b/d', type: 'file' as const, discovered: true },
{ path: '/b/c/f', type: 'file', discovered: true }, { path: '/b/c/f', type: 'file' as const, discovered: true },
] ]
const files2 = [ const files2: FileObject[] = [
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/e', type: 'file', discovered: true }, { path: '/b/e', type: 'file' as const, discovered: true },
{ path: '/b/c/g', type: 'file', discovered: true }, { path: '/b/c/g', type: 'file' as const, discovered: true },
] ]
const expected = [ const expected: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/e', type: 'file', discovered: true }, { path: '/b/e', type: 'file' as const, discovered: true },
{ path: '/b/c/g', type: 'file', discovered: true }, { path: '/b/c/g', type: 'file' as const, discovered: true },
] ]
mergeTest(files1, files2, expected) mergeTest(files1, files2, expected)
@@ -166,90 +160,94 @@ describe('FileNode', () => {
// Test if the node to merge is only the middle part of the tree // Test if the node to merge is only the middle part of the tree
// (i.e. the node to merge doesn't contains all level of children) // (i.e. the node to merge doesn't contains all level of children)
// The original tree should keep the children that are not in the node to merge // The original tree should keep the children that are not in the node to merge
const root = { const root: FileObject = {
path: '/', path: '/',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
} }
const files1 = [ const files1: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/d', type: 'file', discovered: true }, { path: '/b/d', type: 'file' as const, discovered: true },
{ path: '/b/c/f', type: 'file', discovered: true }, { path: '/b/c/f', type: 'file' as const, discovered: true },
] ]
const files2 = [ const files2: FileObject[] = [
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/e', type: 'file', discovered: true }, { path: '/b/e', type: 'file' as const, discovered: true },
] ]
const expected = [ const expected: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/e', type: 'file', discovered: true }, { path: '/b/e', type: 'file' as const, discovered: true },
{ path: '/b/c/f', type: 'file', discovered: true }, { path: '/b/c/f', type: 'file' as const, discovered: true },
] ]
mergeTest(files1, files2, expected) mergeTest(files1, files2, expected)
}) })
test('merge-hidden', () => { test('merge-hidden', () => {
const root = { const root: FileObject = {
path: '/', path: '/',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
} }
const files1 = [ const files1: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/.b', type: 'file', discovered: false }, { path: '/.b', type: 'file' as const, discovered: false },
{ path: '/c', type: 'folder', discovered: true }, { path: '/c', type: 'directory' as const, discovered: true },
{ path: '/c/.d', type: 'file', discovered: false }, { path: '/c/.d', type: 'file' as const, discovered: false },
] ]
const files2 = [ const files2: FileObject[] = [
{ path: '/c', type: 'folder', discovered: true }, { path: '/c', type: 'directory' as const, discovered: true },
{ path: '/c/.d', type: 'file', discovered: true }, { path: '/c/.d', type: 'file' as const, discovered: true },
] ]
const expected = [ const expected: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/.b', type: 'file', discovered: false }, { path: '/.b', type: 'file' as const, discovered: false },
{ path: '/c', type: 'folder', discovered: true }, { path: '/c', type: 'directory' as const, discovered: true },
{ path: '/c/.d', type: 'file', discovered: true }, { path: '/c/.d', type: 'file' as const, discovered: true },
] ]
mergeTest(files1, files2, expected) mergeTest(files1, files2, expected)
}) })
test('remove-child', () => { test('remove-child', () => {
const root = { const root: FileObject = {
path: '/', path: '/',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
} }
const files = [ const files: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/c', type: 'folder', discovered: true }, { path: '/b/c', type: 'directory' as const, discovered: true },
{ path: '/b/d', type: 'file', discovered: true }, { path: '/b/d', type: 'file' as const, discovered: true },
{ path: '/b/c/f', type: 'file', discovered: true }, { path: '/b/c/f', type: 'file' as const, discovered: true },
] ]
const fileToRemove = { path: '/b/c', type: 'folder', discovered: true } const fileToRemove: FileObject = {
path: '/b/c',
type: 'directory',
discovered: true,
}
const expected = [ const expected: FileObject[] = [
root, root,
{ path: '/a', type: 'file', discovered: true }, { path: '/a', type: 'file' as const, discovered: true },
{ path: '/b', type: 'folder', discovered: true }, { path: '/b', type: 'directory' as const, discovered: true },
{ path: '/b/d', type: 'file', discovered: true }, { path: '/b/d', type: 'file' as const, discovered: true },
] ]
removeChildTest(files, fileToRemove, expected) removeChildTest(files, fileToRemove, expected)
@@ -257,9 +255,9 @@ describe('FileNode', () => {
}) })
test('load nested fileNode', () => { test('load nested fileNode', () => {
const data = { const data: IFileNode = {
path: '/', path: '/',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
children: [ children: [
{ {
@@ -269,12 +267,12 @@ test('load nested fileNode', () => {
}, },
{ {
path: '/b', path: '/b',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
children: [ children: [
{ {
path: '/b/c', path: '/b/c',
type: 'folder', type: 'directory',
discovered: true, discovered: true,
children: [ children: [
{ {
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"composite": true,
"outDir": "./dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
File renamed without changes.
+48
View File
@@ -0,0 +1,48 @@
import { Condition } from './condition/Condition'
import {
ICommand,
ICondition,
IFileExistenceChecker,
IRequirements,
IResponse,
} from './schema'
export class Event {
id: string
private condition: Condition
private requirements: string[]
constructor(
id: string,
condition: ICondition,
private response: IResponse,
requirements?: IRequirements
) {
this.id = id
this.condition = new Condition(condition)
this.requirements = requirements || []
}
async satisfies(
command: ICommand,
checker: IFileExistenceChecker
): Promise<boolean> {
return this.condition.satisfies(command, checker)
}
private checkRequirements(completed: string[]): boolean {
return this.requirements.every((req) => completed.includes(req))
}
private finished(completed: string[]): boolean {
return completed.includes(this.id)
}
active(completed: string[]): boolean {
return !this.finished(completed) && this.checkRequirements(completed)
}
getResponse(): IResponse {
return this.response
}
}
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable max-classes-per-file */
import { Event } from './Event'
import { IStageException, IGlobalException } from './schema/exception'
export class StageException extends Event {
readonly catchAll: boolean
constructor({ id, condition, response, catchAll }: IStageException) {
super(id, condition, response)
this.catchAll = catchAll
}
}
export class GlobalException extends Event {
constructor({ id, condition, response, requirements }: IGlobalException) {
super(id, condition, response, requirements)
}
}
+93
View File
@@ -0,0 +1,93 @@
/* eslint-disable no-await-in-loop */
import { Event } from './Event'
import { GlobalException } from './Exception'
import {
IQuest,
IFileExistenceChecker,
ICommand,
IResponse,
ITask,
} from './schema'
import { Stage } from './Stage'
export class Quest {
private quest: IQuest
private stages: Stage[]
private exceptions: GlobalException[] = []
private events: Map<string, Event> = new Map()
constructor(
quest: IQuest,
private checker: IFileExistenceChecker
) {
this.quest = quest
this.stages = quest.stages.map((stage) => new Stage(stage))
for (const stage of this.stages) {
this.events.set(stage.id, stage)
for (const exception of stage.getExceptions()) {
this.events.set(exception.id, exception)
}
}
for (const exception of quest.exceptions ?? []) {
const globalException = new GlobalException(exception)
this.exceptions.push(globalException)
this.events.set(globalException.id, globalException)
}
}
getActiveStages(completed: string[]) {
return this.stages.filter((stage) => stage.active(completed))
}
private getActiveExceptions(completed: string[]) {
return this.exceptions.filter((exception) => exception.active(completed))
}
async findSatisfiedEvent(
command: ICommand,
completed: string[]
): Promise<string | null> {
const stages = this.getActiveStages(completed)
let catchAllException: string | null = null
for (const stage of stages) {
const satisfies = await stage.satisfies(command, this.checker)
if (satisfies) {
return stage.id
}
}
for (const exception of stages.flatMap((stage) => stage.getExceptions())) {
if (exception.catchAll && !catchAllException) {
catchAllException = exception.id
} else if (await exception.satisfies(command, this.checker)) {
return exception.id
}
}
for (const exception of this.getActiveExceptions(completed)) {
if (await exception.satisfies(command, this.checker)) {
return exception.id
}
}
return catchAllException
}
getResponses(completed: string[]): IResponse[] {
return completed.map((id) => this.events.get(id)!.getResponse())
}
getTasks(completed: string[]): ITask[] {
const completedSet = new Set(completed)
const completedStages = this.stages
.filter((stage) => completedSet.has(stage.id))
.map((stage) => ({
id: stage.id,
name: stage.name,
completed: true,
}))
const activeStages = this.getActiveStages(completed).map((stage) => ({
id: stage.id,
name: stage.name,
completed: false,
}))
return [...completedStages, ...activeStages]
}
}
+67
View File
@@ -0,0 +1,67 @@
import { FileGraph, IFileNode } from '../file-graph'
import { Quest } from './Quest'
import {
IQuest,
ICommand,
ISession,
IFileExistenceChecker,
IResponse,
ITask,
} from './schema'
export class GameSession {
private completed: string[] = []
private quest: Quest
private graph: FileGraph
constructor(
{ completedEvents, graph }: Partial<ISession>,
quest: IQuest,
checker: IFileExistenceChecker
) {
this.completed = completedEvents || []
this.quest = new Quest(quest, checker)
this.graph = new FileGraph(
graph ?? { path: '/', type: 'directory', discovered: false }
)
}
get completedEvents() {
return this.completed
}
complete(stageId: string) {
this.completed.push(stageId)
}
getActiveStages(): string[] {
return this.quest.getActiveStages(this.completed).map((stage) => stage.id)
}
async runCommand(command: ICommand) {
if (command.params) {
this.graph.handleEvent(command.params)
}
const stageId = await this.quest.findSatisfiedEvent(command, this.completed)
if (stageId) {
this.complete(stageId)
}
return stageId
}
isFinished() {
return this.getActiveStages().length === 0
}
getResponses(): IResponse[] {
return this.quest.getResponses(this.completed)
}
getTasks(): ITask[] {
return this.quest.getTasks(this.completed)
}
getGraph(): IFileNode {
return this.graph
}
}
+26
View File
@@ -0,0 +1,26 @@
import { IStage } from './schema'
import { StageException } from './Exception'
import { Event } from './Event'
export class Stage extends Event {
name: string
private exceptions: StageException[]
constructor({
id,
name,
condition,
requirements,
exceptions,
response,
}: IStage) {
super(id, condition, response, requirements)
this.name = name || ''
this.exceptions =
exceptions?.map((exception) => new StageException(exception)) || []
}
getExceptions(): StageException[] {
return this.exceptions
}
}
+8
View File
@@ -0,0 +1,8 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { zodToJsonSchema } from 'zod-to-json-schema'
import fs from 'fs'
import { questSchema } from './schema'
const jsonSchema = zodToJsonSchema(questSchema)
fs.writeFileSync('quest.json', JSON.stringify(jsonSchema, null, 2))
+13
View File
@@ -0,0 +1,13 @@
import { ICommand } from '../schema'
import { Matcher } from './Matcher.js'
export class CommandMatcher implements Matcher {
private matcher: RegExp
constructor(command: string, flags?: string) {
this.matcher = new RegExp(command, flags)
}
match(command: ICommand): boolean {
return !!command.command && this.matcher.test(command.command)
}
}
+53
View File
@@ -0,0 +1,53 @@
import {
ICommand,
IFileExistenceChecker,
ICondition,
IFileExistenceInput,
} from '../schema'
import { PwdMatcher } from './PwdMatcher.js'
import { CommandMatcher } from './CommandMatcher.js'
import { ErrorMatcher } from './ErrorMatcher.js'
import { Matcher } from './Matcher.js'
import { OutputMatcher } from './OutputMatcher.js'
import { OrMatcher } from './OrMatcher.js'
import { NotMatcher } from './NotMatcher.js'
import { checkFiles } from './FileMatcher.js'
export class Condition {
private files: IFileExistenceInput[] = []
private matchers: Matcher[] = []
constructor({ command, output, error, pwd, or, not, files }: ICondition) {
if (command) this.matchers.push(new CommandMatcher(command))
if (output) this.matchers.push(new OutputMatcher(output))
if (pwd) this.matchers.push(new PwdMatcher(pwd))
if (or) {
const conditions = or.map((condition) => new Condition(condition))
this.matchers.push(new OrMatcher(conditions))
}
if (not) this.matchers.push(new NotMatcher(new Condition(not)))
if (files) this.files = files
this.matchers.push(new ErrorMatcher(error)) // always check unexpected error
}
match(command: ICommand): boolean {
return this.matchers.every((matcher) => {
return matcher.match(command)
})
}
async check(checker: IFileExistenceChecker): Promise<boolean> {
if (this.matchers.length === 0) return true
return checkFiles(checker, this.files)
}
async satisfies(
command: ICommand,
checker: IFileExistenceChecker
): Promise<boolean> {
// Error matcher is always true
if (this.matchers.length === 1 && this.files.length === 0) return false
// eslint-disable-next-line no-return-await
return this.match(command) && (await this.check(checker))
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Matcher } from './Matcher.js'
import { ICommand } from '../schema'
export class ErrorMatcher implements Matcher {
private matcher?: RegExp
constructor(errorPattern?: string) {
if (errorPattern) {
this.matcher = new RegExp(errorPattern)
}
}
match(command: ICommand): boolean {
if (!this.matcher) {
return !command.error
}
return this.matcher.test(command.error || '')
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IFileExistenceInput, IFileExistenceChecker } from '../schema'
export async function checkFiles(
checker: IFileExistenceChecker,
files: IFileExistenceInput[]
) {
const existences = await Promise.all(
files.map((file) => checker.exists(file))
)
return existences.every((exists, index) => exists === files[index].exists)
}
+5
View File
@@ -0,0 +1,5 @@
import { ICommand } from '../schema'
export interface Matcher {
match(command: ICommand): boolean
}
+10
View File
@@ -0,0 +1,10 @@
import { ICommand } from '../schema'
import { Matcher } from './Matcher.js'
export class NotMatcher implements Matcher {
constructor(private condition: Matcher) {}
match(command: ICommand): boolean {
return !this.condition.match(command)
}
}
+10
View File
@@ -0,0 +1,10 @@
import { ICommand } from '../schema'
import { Matcher } from './Matcher.js'
export class OrMatcher implements Matcher {
constructor(private conditions: Matcher[]) {}
match(command: ICommand): boolean {
return this.conditions.some((condition) => condition.match(command))
}
}
+13
View File
@@ -0,0 +1,13 @@
import { ICommand } from '../schema'
import { Matcher } from './Matcher.js'
export class OutputMatcher implements Matcher {
private matcher: RegExp
constructor(pattern: string, args?: string) {
this.matcher = new RegExp(pattern, args)
}
match(command: ICommand): boolean {
return !!command.output && this.matcher.test(command.output.trim())
}
}
+10
View File
@@ -0,0 +1,10 @@
import { ICommand } from '../schema'
import { Matcher } from './Matcher.js'
export class PwdMatcher implements Matcher {
constructor(private pwd: string) {}
match(command: ICommand): boolean {
return command.pwd === this.pwd
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './schema'
export * from './Quest'
export * from './Session'
export * from './Event'
export * from './Stage'
export * from './Exception'
+159
View File
@@ -0,0 +1,159 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"image": {
"type": "string"
},
"instruction": {
"type": "string"
},
"requirements": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"stages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]+$"
},
"name": {
"type": "string"
},
"requirements": {
"$ref": "#/properties/requirements",
"default": []
},
"condition": {
"type": "object",
"properties": {
"command": {
"type": "string"
},
"output": {
"type": "string"
},
"error": {
"type": "string"
},
"pwd": {
"type": "string"
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["file", "directory"]
},
"exists": {
"type": "boolean"
}
},
"required": ["path", "type", "exists"],
"additionalProperties": false
}
},
"or": {
"type": "array",
"items": {
"$ref": "#/properties/stages/items/properties/condition"
}
},
"not": {
"$ref": "#/properties/stages/items/properties/condition"
}
},
"additionalProperties": false
},
"response": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["narrative", "dialogue"],
"default": "narrative"
},
"content": {
"type": "string"
},
"hint": {
"type": "string"
}
},
"required": ["content"],
"additionalProperties": false
},
"exceptions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"condition": {
"$ref": "#/properties/stages/items/properties/condition"
},
"response": {
"$ref": "#/properties/stages/items/properties/response"
},
"catchAll": {
"type": "boolean",
"default": false
}
},
"required": ["id", "condition", "response"],
"additionalProperties": false
}
}
},
"required": ["id", "condition", "response"],
"additionalProperties": false
}
},
"exceptions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"$ref": "#/properties/stages/items/properties/exceptions/items/properties/id"
},
"condition": {
"$ref": "#/properties/stages/items/properties/condition"
},
"response": {
"$ref": "#/properties/stages/items/properties/response"
},
"requirements": {
"$ref": "#/properties/requirements",
"default": []
}
},
"required": ["id", "condition", "response"],
"additionalProperties": false
}
}
},
"required": ["id", "title", "image", "instruction", "stages"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
const fileSchema = z.object({
path: z.string(),
type: z.enum(['file', 'directory']),
discovered: z.boolean(),
})
export const commandParamsSchema = z.object({
discover: fileSchema.array().optional(),
pwd: z.string().optional(),
})
export type ICommandParams = z.infer<typeof commandParamsSchema>
export const commandSchema = z.object({
command: z.string().optional(),
output: z.string().optional(),
error: z.string().optional(),
pwd: z.string().optional(),
params: commandParamsSchema.optional(),
})
export type ICommand = z.infer<typeof commandSchema>
+44
View File
@@ -0,0 +1,44 @@
import { z } from 'zod'
export const fileInputSchema = z.object({
path: z.string(),
type: z.enum(['file', 'directory']),
})
export type IFileInput = z.infer<typeof fileInputSchema>
export const fileExistenceInputSchema = fileInputSchema.extend({
exists: z.boolean(),
})
export type IFileExistenceInput = z.infer<typeof fileExistenceInputSchema>
const baseConditionSchema = z.object({
command: z.string().optional(),
output: z.string().optional(),
error: z.string().optional(),
pwd: z.string().optional(),
files: z.array(fileExistenceInputSchema).optional(),
})
export type ICondition = z.infer<typeof baseConditionSchema> & {
or?: ICondition[]
not?: ICondition
}
export const conditionSchema: z.ZodType<ICondition> =
baseConditionSchema.extend({
or: z.lazy(() => z.array(conditionSchema).optional()),
not: z.lazy(() => conditionSchema.optional()),
})
export interface IFileExistenceChecker {
exists(file: IFileInput): Promise<boolean>
}
export class VoidFileExistenceChecker implements IFileExistenceChecker {
// eslint-disable-next-line class-methods-use-this
async exists(): Promise<boolean> {
return false
}
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
import { conditionSchema } from './condition.js'
import { responseSchema } from './response.js'
import { requirementsSchema } from './requirements.js'
export const exceptionSchema = z.object({
id: z.string(),
condition: conditionSchema,
response: responseSchema,
})
export type IException = z.infer<typeof exceptionSchema>
export const stageExceptionSchema = exceptionSchema.extend({
catchAll: z.boolean().default(false),
})
export type IStageException = z.infer<typeof stageExceptionSchema>
export const globalExceptionSchema = exceptionSchema.extend({
requirements: requirementsSchema.default([]),
})
export type IGlobalException = z.infer<typeof globalExceptionSchema>
+20
View File
@@ -0,0 +1,20 @@
import { IFileNode } from '../../file-graph'
export * from './condition.js'
export * from './stage.js'
export * from './requirements.js'
export * from './response.js'
export * from './quest.js'
export * from './exception.js'
export * from './command.js'
export interface ISession {
completedEvents: string[]
graph: IFileNode
}
export interface ITask {
id: string
name: string
completed: boolean
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod'
import { stageSchema } from './stage.js'
import { requirementsSchema } from './requirements.js'
import { globalExceptionSchema } from './exception.js'
export const questSchema = z.object({
id: z.string(),
title: z.string(),
image: z.string(),
instruction: z.string(),
requirements: requirementsSchema.default([]),
stages: z.array(stageSchema),
exceptions: z.array(globalExceptionSchema).optional(),
})
export type IQuest = z.infer<typeof questSchema>
+5
View File
@@ -0,0 +1,5 @@
import { z } from 'zod'
export const requirementsSchema = z.array(z.string())
export type IRequirements = z.infer<typeof requirementsSchema>
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod'
export const responseSchema = z.object({
type: z.enum(['narrative', 'dialogue']).default('narrative'),
content: z.string(),
hint: z.string().optional(),
})
export type IResponse = z.infer<typeof responseSchema>
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod'
import { conditionSchema } from './condition.js'
import { responseSchema } from './response.js'
import { stageExceptionSchema } from './exception.js'
import { requirementsSchema } from './requirements.js'
export const stageSchema = z.object({
id: z.string().regex(/^[a-zA-Z0-9_-]+$/),
name: z.string().optional(),
requirements: requirementsSchema.default([]),
condition: conditionSchema,
response: responseSchema,
exceptions: stageExceptionSchema.array().optional(),
})
export type IStage = z.infer<typeof stageSchema>
+107
View File
@@ -0,0 +1,107 @@
import { test, expect } from '@jest/globals'
import { IFileExistenceChecker, IFileInput } from '../schema'
import { checkFiles } from '../condition/FileMatcher.js'
import { Condition } from '../condition/Condition.js'
export class MockFileChecker implements IFileExistenceChecker {
private files: IFileInput[] = [
{
path: '/home/user/hello.txt',
type: 'file',
},
{
path: '/home/user/world.txt',
type: 'file',
},
{
path: '/home/user/Downloads',
type: 'directory',
},
]
async exists(file: IFileInput): Promise<boolean> {
return this.files.some((f) => f.path === file.path && f.type === file.type)
}
}
test('file matcher', async () => {
const checker = new MockFileChecker()
expect(
await checkFiles(checker, [
{
path: '/home/user/hello.txt',
type: 'file',
exists: true,
},
])
).toBe(true)
expect(
await checkFiles(checker, [
{
path: '/home/user/not-exists.txt',
type: 'file',
exists: true,
},
])
).toBe(false)
expect(
await checkFiles(checker, [
{
path: '/home/user/not-exists.txt',
type: 'file',
exists: false,
},
])
).toBe(true)
expect(
await checkFiles(checker, [
{
path: '/home/user/Downloads',
type: 'directory',
exists: true,
},
])
).toBe(true)
expect(
await checkFiles(checker, [
{
path: '/home/user/hello.txt',
type: 'file',
exists: true,
},
{
path: '/home/user/Downloads',
type: 'directory',
exists: true,
},
])
).toBe(true)
expect(
await checkFiles(checker, [
{
path: '/home/user/hello.txt',
type: 'file',
exists: true,
},
{
path: '/home/user/not-exist.txt',
type: 'file',
exists: true,
},
])
).toBe(false)
})
test('condition with file matcher', async () => {
const checker = new MockFileChecker()
const condition = new Condition({
files: [
{
path: '/home/user/hello.txt',
type: 'file',
exists: true,
},
],
})
expect(await condition.check(checker)).toBe(true)
})
+102
View File
@@ -0,0 +1,102 @@
import { test, expect } from '@jest/globals'
import { Condition } from '../condition/Condition'
import { OutputMatcher } from '../condition/OutputMatcher'
import { PwdMatcher } from '../condition/PwdMatcher'
import { CommandMatcher } from '../condition/CommandMatcher'
import { ErrorMatcher } from '../condition/ErrorMatcher'
import { conditionSchema } from '../schema'
import { MockFileChecker } from './asyncCondition.test'
test('command match', () => {
const matcher = new CommandMatcher('^echo start$')
expect(matcher.match({ command: 'echo start' })).toBe(true)
expect(matcher.match({ command: 'echo start1' })).toBe(false)
// Currently not supported
expect(matcher.match({ command: 'echo "start"' })).toBe(false)
})
test('error matcher', () => {
const matcher = new ErrorMatcher()
expect(matcher.match({ error: '' })).toBe(true)
expect(matcher.match({ error: 'permission denied' })).toBe(false)
})
test('error matcher with specific error', () => {
const matcher = new ErrorMatcher('permission denied')
expect(matcher.match({ error: '' })).toBe(false)
expect(matcher.match({ error: 'file not found' })).toBe(false)
expect(matcher.match({ error: 'cannot open files: permission denied' })).toBe(
true
)
})
test('pwd matcher', () => {
const matcher = new PwdMatcher('/home/user')
expect(matcher.match({ pwd: '/home/user' })).toBe(true)
expect(matcher.match({ pwd: '/home/user1' })).toBe(false)
expect(matcher.match({ pwd: '/home/user/anotherpath' })).toBe(false)
expect(matcher.match({ pwd: '/var/home/user' })).toBe(false)
})
test('output matcher', () => {
const matcher = new OutputMatcher('^start$')
expect(matcher.match({ output: 'start' })).toBe(true)
expect(matcher.match({ output: 'start1' })).toBe(false)
expect(matcher.match({ output: 'start\n' })).toBe(true)
})
test('condition match command', () => {
const condition = new Condition({
command: '^echo start$',
})
expect(
condition.match({
command: 'echo start',
})
).toBe(true)
expect(
condition.match({
command: 'echo hello',
})
).toBe(false)
expect(
condition.match({ command: 'echo start', error: 'permission denied' })
).toBe(false)
})
test('condition with OR', () => {
const condition = new Condition({
or: [{ command: '^echo start$' }, { command: '^echo hello$' }],
})
expect(condition.match({ command: 'echo start' })).toBe(true)
expect(condition.match({ command: 'echo hello' })).toBe(true)
expect(condition.match({ command: 'echo start1' })).toBe(false)
})
test('condition with NOT', () => {
const condition = new Condition({
not: { command: '^echo start$' },
})
expect(condition.match({ command: 'echo start' })).toBe(false)
expect(condition.match({ command: 'echo hello' })).toBe(true)
})
it('should parse condition', () => {
const condition = {
command: 'echo start',
output: 'start',
not: {
pwd: '/home/user',
},
}
expect(conditionSchema.safeParse(condition).error).toBeUndefined()
})
it('should not match empty condition', async () => {
const condition = new Condition({})
expect(
await condition.satisfies({ command: 'echo start' }, new MockFileChecker())
).toBe(false)
})
+100
View File
@@ -0,0 +1,100 @@
import { expect, it, describe } from '@jest/globals'
import { IQuest } from '../schema'
import { GameSession } from '../Session'
import { MockFileChecker } from './asyncCondition.test'
const quest: IQuest = {
id: 'quest1',
title: 'Quest 1',
image: 'image1',
instruction: 'Instruction 1',
requirements: [],
stages: [
{
id: 'stage1',
requirements: [],
condition: {
command: 'echo start',
},
exceptions: [
{
id: 'exception1',
condition: {
command: 'ls',
},
response: { type: 'narrative', content: 'Exception 1' },
catchAll: false,
},
{
id: 'exception2',
condition: {},
catchAll: true,
response: { type: 'narrative', content: 'Exception 2' },
},
],
response: { type: 'narrative', content: 'Narrative 1' },
},
{
id: 'stage2',
requirements: ['stage1'],
condition: {
command: 'ls',
},
response: { type: 'narrative', content: 'Narrative 2' },
},
],
exceptions: [
{
id: 'exception3',
condition: {
command: 'rm',
},
response: { type: 'narrative', content: 'Exception 3' },
requirements: [],
},
{
id: 'exception4',
condition: {
command: 'mv',
},
response: { type: 'narrative', content: 'Exception 4' },
requirements: ['stage1'],
},
],
}
describe('exceptions', () => {
it('should return the stage exception', async () => {
const session = new GameSession(
{ completedEvents: [] },
quest,
new MockFileChecker()
)
expect(await session.runCommand({ command: 'ls' })).toBe('exception1')
await session.runCommand({ command: 'echo start' })
expect(await session.runCommand({ command: 'ls' })).toBe('stage2')
})
it('should return the catch all exception', async () => {
const session = new GameSession(
{ completedEvents: [] },
quest,
new MockFileChecker()
)
expect(await session.runCommand({ command: 'what is this' })).toBe(
'exception2'
)
})
it('should return the global exception', async () => {
const session = new GameSession(
{ completedEvents: [] },
quest,
new MockFileChecker()
)
expect(await session.runCommand({ command: 'rm' })).toBe('exception3')
expect(await session.runCommand({ command: 'mv' })).toBe('exception2') // catch all
session.complete('stage1')
expect(await session.runCommand({ command: 'mv' })).toBe('exception4')
})
})
+214
View File
@@ -0,0 +1,214 @@
import { expect, describe, it } from '@jest/globals'
import { IQuest } from '../schema'
import { GameSession } from '../Session'
import { MockFileChecker } from './asyncCondition.test'
const quest: IQuest = {
id: 'quest1',
title: 'Quest 1',
image: 'image1',
instruction: 'Instruction 1',
requirements: [],
stages: [
{
id: 'stage1',
name: 'Stage 1',
requirements: [],
condition: {
command: 'echo start',
},
response: { type: 'narrative', content: 'Narrative 1' },
},
{
id: 'stage2',
name: 'Stage 2',
requirements: ['stage1'],
condition: {
command: 'ls',
},
response: { type: 'narrative', content: 'Narrative 2' },
},
{
id: 'stage3',
name: 'Stage 3',
requirements: ['stage1'],
condition: {
command: 'mkdir hello',
},
response: { type: 'narrative', content: 'Narrative 3' },
},
{
id: 'stage4',
name: 'Stage 4',
requirements: ['stage2', 'stage3'],
condition: {
command: 'echo finish',
},
response: { type: 'narrative', content: 'Narrative 4' },
},
],
exceptions: [
{
id: 'exception1',
condition: {
command: 'rm',
},
response: {
type: 'narrative',
content: 'Exception 1',
},
requirements: [],
},
],
}
describe('Session', () => {
it('should return active stages', () => {
const session = new GameSession(
{
completedEvents: [],
},
quest,
new MockFileChecker()
)
expect(session.getActiveStages()).toEqual(['stage1'])
session.complete('stage1')
expect(session.getActiveStages()).toEqual(['stage2', 'stage3'])
session.complete('stage2')
expect(session.getActiveStages()).toEqual(['stage3'])
session.complete('stage3')
expect(session.getActiveStages()).toEqual(['stage4'])
})
it('should run command', async () => {
const session = new GameSession(
{
completedEvents: [],
},
quest,
new MockFileChecker()
)
expect(await session.runCommand({ command: 'hi' })).toBeNull()
expect(await session.runCommand({ command: 'echo start' })).toEqual(
'stage1'
)
expect(await session.runCommand({ command: 'echo start' })).toBeNull()
expect(await session.runCommand({ command: 'ls' })).toEqual('stage2')
expect(await session.runCommand({ command: 'echo finish' })).toBeNull()
expect(await session.runCommand({ command: 'mkdir hello' })).toEqual(
'stage3'
)
expect(await session.runCommand({ command: 'echo finish' })).toEqual(
'stage4'
)
})
it('should return all responses', () => {
const session = new GameSession(
{
completedEvents: ['stage1'],
},
quest,
new MockFileChecker()
)
expect(session.getResponses()).toEqual([
{
type: 'narrative',
content: 'Narrative 1',
},
])
session.complete('exception1')
session.complete('stage2')
expect(session.getResponses()).toEqual([
{ type: 'narrative', content: 'Narrative 1' },
{ type: 'narrative', content: 'Exception 1' },
{ type: 'narrative', content: 'Narrative 2' },
])
})
it('should return tasks', () => {
const session = new GameSession(
{
completedEvents: [],
},
quest,
new MockFileChecker()
)
expect(session.getTasks()).toEqual([
{ id: 'stage1', name: 'Stage 1', completed: false },
])
session.complete('stage1')
expect(session.getTasks()).toEqual([
{ id: 'stage1', name: 'Stage 1', completed: true },
{ id: 'stage2', name: 'Stage 2', completed: false },
{ id: 'stage3', name: 'Stage 3', completed: false },
])
session.complete('stage2')
expect(session.getTasks()).toEqual([
{ id: 'stage1', name: 'Stage 1', completed: true },
{ id: 'stage2', name: 'Stage 2', completed: true },
{ id: 'stage3', name: 'Stage 3', completed: false },
])
session.complete('stage3')
expect(session.getTasks()).toEqual([
{ id: 'stage1', name: 'Stage 1', completed: true },
{ id: 'stage2', name: 'Stage 2', completed: true },
{ id: 'stage3', name: 'Stage 3', completed: true },
{ id: 'stage4', name: 'Stage 4', completed: false },
])
})
it('should update graph', () => {
const session = new GameSession({}, quest, new MockFileChecker())
expect(session.getGraph()).toMatchObject({
path: '/',
type: 'directory',
discovered: false,
})
session.runCommand({
command: 'ls',
params: {
discover: [
{
path: '/home/user',
type: 'directory',
discovered: true,
},
],
},
})
expect(session.getGraph()).toMatchObject({
path: '/',
type: 'directory',
discovered: false,
children: [
{
path: '/home',
type: 'directory',
discovered: false,
children: [
{
path: '/home/user',
type: 'directory',
discovered: true,
},
],
},
],
})
})
})
+83
View File
@@ -0,0 +1,83 @@
import { test, expect } from '@jest/globals'
import { Stage } from '../Stage'
import { MockFileChecker } from './asyncCondition.test'
const checker = new MockFileChecker()
test('check stage condition', async () => {
const stage = new Stage({
id: 'stage1',
name: 'List the files using `ls`',
condition: {
command: 'ls',
pwd: '/home/user',
},
requirements: [],
response: {
type: 'narrative',
content: 'Narrative 1',
},
})
expect(
await stage.satisfies(
{
command: 'ls',
pwd: '/home/user',
},
checker
)
).toBe(true)
expect(
await stage.satisfies(
{
command: 'ls',
pwd: '/home/user/Downloads',
},
checker
)
).toBe(false)
})
test('check stage with async condition truthy', async () => {
const stage = new Stage({
id: 'stage1',
name: 'Create file',
condition: {
files: [
{
path: '/home/user/hello.txt',
type: 'file',
exists: true,
},
],
},
requirements: [],
response: {
type: 'narrative',
content: 'Narrative 1',
},
})
expect(await stage.satisfies({}, checker)).toBe(true)
})
test('check stage with async condition falsy', async () => {
const stage = new Stage({
id: 'stage1',
name: 'Create file',
condition: {
files: [
{
path: '/home/user/not-exists.txt',
type: 'file',
exists: true,
},
],
},
requirements: [],
response: {
type: 'narrative',
content: 'Narrative 1',
},
})
expect(await stage.satisfies({}, checker)).toBe(false)
})
File renamed without changes.
+13
View File
@@ -0,0 +1,13 @@
import mongoose from 'mongoose'
export { User, IUser } from './user.js'
export { Session, ISession } from './session.js'
export { Command, ICommand } from './command.js'
export { UserProfile, IUserProfile, IProgress } from './userProfile.js'
export async function connectDB(uri: string) {
const db = await mongoose.connect(uri)
return db.connection.getClient()
}
console.log('Loaded models package')
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@linux-odyssey/models",
"type": "module",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL",
"exports": "./dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"dependencies": {
"@types/mongoose": "^5.11.97",
"mongoose": "^8.0.3"
}
}
@@ -1,17 +1,18 @@
import { model, Schema, Types } from 'mongoose' import { model, Schema, Types } from 'mongoose'
import { IResponse, responseSchema } from './response.js'
export interface INode { export interface INode {
path: string path: string
type: string type: 'file' | 'directory'
discovered: boolean discovered: boolean
children?: INode[] children?: INode[]
} }
const nodeSchema = new Schema<INode>({ const nodeSchema = new Schema<INode>({
path: String, path: String,
type: String, type: {
type: String,
enum: ['file', 'directory'],
},
discovered: { discovered: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -22,27 +23,6 @@ nodeSchema.add({
children: [nodeSchema], children: [nodeSchema],
}) })
export interface ITask {
id: string
name: string
completed: boolean
}
const taskSchema = new Schema<ITask>({
id: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
completed: {
type: Boolean,
default: false,
},
})
export interface ISession { export interface ISession {
_id: Types.ObjectId _id: Types.ObjectId
user: Types.ObjectId user: Types.ObjectId
@@ -53,9 +33,7 @@ export interface ISession {
updatedAt: Date updatedAt: Date
finishedAt?: Date finishedAt?: Date
lastActivityAt: Date lastActivityAt: Date
tasks: ITask[] completedEvents: string[]
hints: string[][]
responses: IResponse[][]
graph: INode graph: INode
} }
@@ -86,18 +64,12 @@ export const Session = model<ISession>(
default: Date.now, default: Date.now,
required: true, required: true,
}, },
tasks: [taskSchema], completedEvents: [String],
hints: [[String]],
responses: {
type: [[responseSchema]],
required: true,
default: [],
},
graph: { graph: {
type: nodeSchema, type: nodeSchema,
default: { default: {
path: '/', path: '/',
type: 'folder', type: 'directory',
}, },
}, },
}, },
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more