diff --git a/packages/models/src/commandResponder.ts b/packages/models/src/commandResponder.ts index 5ec08a6..adddcab 100644 --- a/packages/models/src/commandResponder.ts +++ b/packages/models/src/commandResponder.ts @@ -3,10 +3,9 @@ import { conditionSchema, ICondition } from './condition.js' import { responseSchema, IResponse } from './response.js' import { hintSchema, IHint } from './hint.js' -export interface ICommandResponder { +export interface ICommandResponder extends IHint { condition?: ICondition responses: IResponse[] - hints?: IHint[] } export const commandResponderSchema = new Schema({ diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index f52f223..efdfd11 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -2,10 +2,14 @@ import mongoose from 'mongoose' export { User, IUser } from './user.js' export { Quest, IQuest } from './quest.js' -export { Session, ISession } from './session.js' +export { Session, ISession, ITask } from './session.js' export { Command, ICommand } from './command.js' export { UserProfile, IUserProfile } from './userProfile.js' export { IFileCondition, ICondition } from './condition.js' +export { IStage } from './stage.js' +export { IRequirement } from './requirement.js' +export { IResponse } from './response.js' +export { IException } from './exception.js' export default async function connectDB(uri: string) { const db = await mongoose.connect(uri) diff --git a/packages/models/src/session.ts b/packages/models/src/session.ts index f7926be..fdc749f 100644 --- a/packages/models/src/session.ts +++ b/packages/models/src/session.ts @@ -42,6 +42,7 @@ const taskSchema = new Schema({ }) export interface ISession { + _id: Types.ObjectId user: Types.ObjectId quest: string containerId: string | null diff --git a/server/package.json b/server/package.json index edc3559..57a852f 100644 --- a/server/package.json +++ b/server/package.json @@ -49,6 +49,7 @@ "@types/express": "^4.17.21", "@types/express-session": "^1.17.10", "@types/jsonwebtoken": "^9.0.5", + "@types/minimist": "^1.2.5", "@types/node-schedule": "^2.1.6", "@types/passport": "^1.0.16", "@types/passport-github2": "^1.2.9", diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.ts similarity index 52% rename from server/src/api/controllers/commandController.js rename to server/src/api/controllers/commandController.ts index 34a5640..5e4f5f2 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.ts @@ -1,5 +1,6 @@ -import { Command, Session } from '@linux-odyssey/models' +import { Command, Quest, Session } from '@linux-odyssey/models' import { matchedData } from 'express-validator' +import type { Request, Response } from 'express' import CommandHandler from '../../game/commandHandler.js' import { pushToSession } from '../socket.js' import { finishSession } from '../../models/sessionManager.js' @@ -7,10 +8,10 @@ import { asyncHandler } from '../../middleware/error.js' const commandCompleteCallbacks = new Map() -export const newCommand = asyncHandler(async (req, res) => { +export const newCommand = asyncHandler(async (req: Request, res: Response) => { const { command, pwd, output, error, params } = matchedData(req) - const { sessionId } = req.user + const { sessionId } = req.user as any const session = await Session.findById(sessionId) if (!session) { res.status(404).json({ message: 'session not found' }) @@ -38,7 +39,13 @@ export const newCommand = asyncHandler(async (req, res) => { await c.save() - const commandHandler = new CommandHandler(session, c, params) + const quest = await Quest.findById(session.quest) + if (!quest) { + res.status(404).json({ message: 'quest not found' }) + return + } + + const commandHandler = new CommandHandler(session, quest, c, params) const response = await commandHandler.run() if (response) { c.stage = response.stage @@ -49,33 +56,37 @@ export const newCommand = asyncHandler(async (req, res) => { } commandCompleteCallbacks.set(session.id, response.callback) pushToSession(session.id, 'status', 'pending') + res.status(201).json(response) + } else { + res.status(200).end() } - res.status(201).json(response) await session.save() }) -export const completedCommand = asyncHandler(async (req, res) => { - const { sessionId } = req.user +export const completedCommand = asyncHandler( + async (req: Request, res: Response) => { + const { sessionId } = req.user as any - const session = await Session.findById(sessionId) + const session = await Session.findById(sessionId) - if (!session) { - res.status(404).json({ message: 'session not found' }) - return + if (!session) { + res.status(404).json({ message: 'session not found' }) + return + } + + if (session.status === 'inactive') { + res.status(400).json({ message: 'session is not active' }) + return + } + + const callback = commandCompleteCallbacks.get(session.id) + if (!callback) { + res.status(200).json({ message: 'no callback found' }) + return + } + callback() + commandCompleteCallbacks.delete(session.id) + res.json({ message: 'ok' }) } - - if (session.status === 'inactive') { - res.status(400).json({ message: 'session is not active' }) - return - } - - const callback = commandCompleteCallbacks.get(session.id) - if (!callback) { - res.status(200).json({ message: 'no callback found' }) - return - } - callback() - commandCompleteCallbacks.delete(session.id) - res.json({ message: 'ok' }) -}) +) diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.ts similarity index 65% rename from server/src/game/commandHandler.js rename to server/src/game/commandHandler.ts index e7c5b66..e500c4a 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.ts @@ -1,23 +1,41 @@ import minimist from 'minimist' +import { HydratedDocument } from 'mongoose' import { FileGraph } from '@linux-odyssey/file-graph' -import { Quest } from '@linux-odyssey/models' +import { + ICommand, + ICondition, + IFileCondition, + IQuest, + ISession, + IStage, + IException, +} from '@linux-odyssey/models' import { pushToSession } from '../api/socket.js' -import SessionHandler from './sessionHandler.js' +import SessionHandler, { ExecuteResult } from './sessionHandler.js' import { checkFile } from '../containers/cli.js' import logger from '../utils/logger.js' // 檢查 pattern 是否符合 input -const checkMatch = (pattern, input) => { +const checkMatch = (pattern?: string[], input?: string) => { if (!pattern || pattern.length === 0) return true return pattern.some((p) => { const regex = new RegExp(p) - return regex.test(input) + return input && regex.test(input) }) } export default class CommandHandler extends SessionHandler { - constructor(session, commandInput, params) { - super(session) + commandInput: ICommand + argv: minimist.ParsedArgs + params: Record + + constructor( + session: HydratedDocument, + quest: HydratedDocument, + commandInput: ICommand, + params: Record + ) { + super(session, quest) this.commandInput = commandInput this.argv = minimist(this.commandInput.command.split(' ')) @@ -56,7 +74,7 @@ export default class CommandHandler extends SessionHandler { this.session.graph = graph } catch (e) { logger.error('Update file graph failed', { - error: e.message, + error: (e as Error).message, session: this.session.id, graph: this.session.graph, discover: this.params.discover, @@ -64,7 +82,7 @@ export default class CommandHandler extends SessionHandler { } } - async isMatch(condition) { + async isMatch(condition: ICondition): Promise { return ( this.checkKeys(condition) && (await this.checkFiles(condition.files)) && @@ -73,18 +91,25 @@ export default class CommandHandler extends SessionHandler { ) } - checkKeys(condition) { - const keys = ['command', 'output', 'error', 'pwd'] - return keys.every((k) => checkMatch(condition[k], this.commandInput[k])) + checkKeys(condition: ICondition) { + return ( + checkMatch(condition.command, this.commandInput.command) && + checkMatch(condition.output, this.commandInput.output) && + checkMatch(condition.error, this.commandInput.error) && + checkMatch(condition.pwd, this.commandInput.pwd) + ) } - async checkFiles(files) { - if (files.length === 0) { + async checkFiles(files?: IFileCondition[]): Promise { + if (!files || files.length === 0) { return true } try { const checks = await Promise.all( - files.map((f) => checkFile(this.session.containerId, f)) + files.map( + (f) => + this.session.containerId && checkFile(this.session.containerId, f) + ) ) return checks.every((c) => c === true) } catch (e) { @@ -92,22 +117,22 @@ export default class CommandHandler extends SessionHandler { } } - async checkNot(condition) { + async checkNot(condition?: ICondition): Promise { if (!condition) { return true } return !(await this.isMatch(condition)) } - async checkOr(conditions) { - if (conditions.length === 0) { + async checkOr(conditions?: ICondition[]): Promise { + if (!conditions || conditions.length === 0) { return true } const matches = await Promise.all(conditions.map((c) => this.isMatch(c))) return matches.some((m) => m === true) } - async checkException(stage) { + async checkException(stage: IStage): Promise { const exceptions = stage.exceptions || [] for (const exception of exceptions) { // eslint-disable-next-line no-await-in-loop @@ -119,22 +144,25 @@ export default class CommandHandler extends SessionHandler { return null } - async run() { + async run(): Promise { try { - this.quest = await Quest.findById(this.session.quest) const stages = this.getStages() if (stages.length === 0) { logger.warn('stage not found', { session: this.session.id }) - return {} + return null } this.handleEvent() this.handleCommand() + console.log(stages) + const matches = await Promise.all( - stages.map((stage) => this.isMatch(stage.condition)) + stages.map((stage) => stage.condition && this.isMatch(stage.condition)) ) + console.log(matches) + const stage = stages.find((_, i) => matches[i]) if (stage) { const response = this.execute(stage) @@ -161,22 +189,25 @@ export default class CommandHandler extends SessionHandler { this.quest.exceptions ) for (const exception of globalExceptions) { - // eslint-disable-next-line no-await-in-loop - if (await this.isMatch(exception.condition)) { + if ( + exception.condition && + // eslint-disable-next-line no-await-in-loop + (await this.isMatch(exception.condition)) + ) { return this.executeException(exception) } } } - return {} + return null } catch (err) { logger.error('CommandHandler error', { - error: err.message, + error: (err as Error).message, session: this.session._id, quest: this.quest._id, command: this.commandInput, }) - return {} + return null } } } diff --git a/server/src/game/sessionHandler.js b/server/src/game/sessionHandler.ts similarity index 66% rename from server/src/game/sessionHandler.js rename to server/src/game/sessionHandler.ts index e3e4af0..6808f5c 100644 --- a/server/src/game/sessionHandler.js +++ b/server/src/game/sessionHandler.ts @@ -1,27 +1,51 @@ +import { HydratedDocument } from 'mongoose' +import type { + IException, + IQuest, + IRequirement, + IResponse, + ISession, + IStage, + ITask, +} from '@linux-odyssey/models' import { pushToSession } from '../api/socket.js' +export interface ExecuteResult { + responses: IResponse[] + hints: string[] + callback: () => void + stage?: string + end?: boolean +} + export default class SessionHandler { - constructor(session) { + session: HydratedDocument + quest: HydratedDocument + + constructor( + session: HydratedDocument, + quest: HydratedDocument + ) { this.session = session - this.quest = session.quest + this.quest = quest } - getSession() { + getSession(): HydratedDocument { return this.session } - getTask(stage) { + getTask(stage: Partial): ITask | undefined { return this.session.tasks.find((t) => t.id === stage.id) } - isCompleted(stage) { + isCompleted(stage: Partial): boolean { const task = this.getTask(stage) return task ? task.completed : false } - getUnlockedComponents(components) { + getUnlockedComponents(components: T[]): T[] { return components.filter((component) => - component.requirements.every((r) => this.isCompleted({ id: r })) + component.requirements.every((r: string) => this.isCompleted({ id: r })) ) } @@ -43,7 +67,7 @@ export default class SessionHandler { this.session.tasks.push(...newTasks) } - execute(stage) { + execute(stage: IStage): ExecuteResult { this.session.tasks .filter((t) => t.id === stage.id) .forEach((t) => { @@ -67,7 +91,7 @@ export default class SessionHandler { } } - executeException(exception) { + executeException(exception: IException): ExecuteResult { const { hints, responses } = exception if (hints && hints.length > 0) this.session.hints.push(hints) diff --git a/server/src/models/sessionManager.js b/server/src/models/sessionManager.ts similarity index 66% rename from server/src/models/sessionManager.js rename to server/src/models/sessionManager.ts index 80fb01e..76370e5 100644 --- a/server/src/models/sessionManager.js +++ b/server/src/models/sessionManager.ts @@ -1,28 +1,36 @@ +import { HydratedDocument, Types } from 'mongoose' import { Session, Quest, UserProfile } from '@linux-odyssey/models' +import type { ISession, IUser } from '@linux-odyssey/models' import { createContainer, deleteContainer } from '../containers/docker.js' import SessionHandler from '../game/sessionHandler.js' import logger from '../utils/logger.js' -async function deactivateSessions(user, quest) { +async function deactivateSessions(userId: Types.ObjectId, quest: string) { const sessions = await Session.find({ - user, + user: userId, quest, status: 'active', }) return Promise.all( sessions.map((session) => { - return deleteContainer(session.containerId) - .catch((err) => logger.error('Failed to delete container', err)) - .finally(() => { - return Session.findByIdAndUpdate(session._id, { - $set: { status: 'inactive', containerId: null }, - }).catch((err) => logger.error('Failed to update session', err)) - }) + return ( + session.containerId && + deleteContainer(session.containerId) + .catch((err) => logger.error('Failed to delete container', err)) + .finally(() => { + return Session.findByIdAndUpdate(session._id, { + $set: { status: 'inactive', containerId: null }, + }).catch((err) => logger.error('Failed to update session', err)) + }) + ) }) ) } -export async function createNewSession(user, questId) { +export async function createNewSession( + user: HydratedDocument, + questId: string +): Promise { const quest = await Quest.findById(questId) if (!quest) { throw new Error(`Quest ${questId} not found`) @@ -38,13 +46,13 @@ export async function createNewSession(user, questId) { quest.image ) - const sessionHandler = new SessionHandler( - new Session({ - user, - quest, - containerId: container.id, - }) - ) + const newSession = new Session({ + user, + quest, + containerId: container.id, + }) + + const sessionHandler = new SessionHandler(newSession, quest) sessionHandler.addNewTasks() @@ -62,6 +70,7 @@ export async function createNewSession(user, questId) { quest: quest._id, sessions: [session._id], startedAt: new Date(), + completed: false, }) } else { progress.sessions.push(session._id) @@ -70,7 +79,10 @@ export async function createNewSession(user, questId) { return session } -export async function getOrCreateActiveSession(user, questId) { +export async function getOrCreateActiveSession( + user: HydratedDocument, + questId: string +): Promise { const session = await Session.findOne({ user, quest: questId, @@ -82,7 +94,9 @@ export async function getOrCreateActiveSession(user, questId) { return createNewSession(user, questId) } -export async function finishSession(session) { +export async function finishSession( + session: HydratedDocument +): Promise { session.status = 'finished' await session.save() @@ -102,7 +116,10 @@ export async function finishSession(session) { await userProfile.save() } -export async function isQuestUnlocked(user, questId) { +export async function isQuestUnlocked( + user: HydratedDocument, + questId: string +): Promise { const userProfile = await UserProfile.findOne({ user: user._id }) if (!userProfile) { throw new Error(`UserProfile ${user._id} not found`) diff --git a/yarn.lock b/yarn.lock index 42fd647..7e2a52b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1545,6 +1545,13 @@ __metadata: languageName: node linkType: hard +"@types/minimist@npm:^1.2.5": + version: 1.2.5 + resolution: "@types/minimist@npm:1.2.5" + checksum: 10c0/3f791258d8e99a1d7d0ca2bda1ca6ea5a94e5e7b8fc6cde84dd79b0552da6fb68ade750f0e17718f6587783c24254bbca0357648dd59dc3812c150305cabdc46 + languageName: node + linkType: hard + "@types/mongoose@npm:^5.11.97": version: 5.11.97 resolution: "@types/mongoose@npm:5.11.97" @@ -8614,6 +8621,7 @@ __metadata: "@types/express": "npm:^4.17.21" "@types/express-session": "npm:^1.17.10" "@types/jsonwebtoken": "npm:^9.0.5" + "@types/minimist": "npm:^1.2.5" "@types/node-schedule": "npm:^2.1.6" "@types/passport": "npm:^1.0.16" "@types/passport-github2": "npm:^1.2.9"