mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
feat: support changing quest language
This commit is contained in:
1 parent
9b8d7109cf
commit
66b0b7ba68
18 files changed
+313
-109
No files matched your search
@@ -54,7 +54,7 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
|
||||
|
||||
session.lastActivityAt = new Date()
|
||||
|
||||
const quest = await questManager.get(session.quest)
|
||||
const quest = await questManager.get(session.quest, session.locale)
|
||||
if (!quest) {
|
||||
res.status(404).json({ message: 'quest not found' })
|
||||
return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'fs/promises'
|
||||
import Docker from 'dockerode'
|
||||
import type { QuestLocale } from '../../../packages/constants'
|
||||
import config, { getQuestImage } from '../config.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
@@ -36,19 +37,20 @@ const newContainerOptions = (
|
||||
export async function createContainer(
|
||||
name: string,
|
||||
questId: string,
|
||||
locale: QuestLocale,
|
||||
imageId: string
|
||||
): Promise<Docker.Container> {
|
||||
const binds: string[] = []
|
||||
if (await questHomeExists(questId)) {
|
||||
if (await questHomeExists(questId, locale)) {
|
||||
logger.info('Mounting quest home', questId)
|
||||
binds.push(
|
||||
`${config.docker.hostProjectRoot}/quests/${questId}/home:/etc/skel:ro`
|
||||
`${config.docker.hostProjectRoot}/quests/${questId}/${locale}/home:/etc/skel:ro`
|
||||
)
|
||||
}
|
||||
if (config.docker.mountQuest && imageId !== 'base') {
|
||||
logger.info('Mounting quest directory', questId)
|
||||
binds.push(
|
||||
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`
|
||||
`${config.docker.hostProjectRoot}/quests/${questId}/${locale}/home:/home/commander`
|
||||
)
|
||||
}
|
||||
if (config.docker.mountCLI) {
|
||||
@@ -75,9 +77,11 @@ async function createNetworkIfNotExists(network: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function questHomeExists(imageId: string) {
|
||||
async function questHomeExists(imageId: string, locale: QuestLocale) {
|
||||
try {
|
||||
const stat = await fs.stat(`${config.projectRoot}/quests/${imageId}/home`)
|
||||
const stat = await fs.stat(
|
||||
`${config.projectRoot}/quests/${imageId}/${locale}/home`
|
||||
)
|
||||
return stat.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
|
||||
+116
-31
@@ -3,6 +3,8 @@ import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import yaml from 'yaml'
|
||||
|
||||
import type { QuestLocale } from '../../../packages/constants'
|
||||
import { SUPPORTED_LOCALES } from '../../../packages/constants'
|
||||
import {
|
||||
questSchema,
|
||||
globalExceptionSchema,
|
||||
@@ -36,16 +38,22 @@ class QuestValidationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function questImageId(id: string, locale: QuestLocale) {
|
||||
return `${id}-${locale.toLowerCase()}`
|
||||
}
|
||||
|
||||
class QuestManager {
|
||||
private quests = new Map<string, IQuest>()
|
||||
private quests = new Map<string, Map<QuestLocale, IQuest>>()
|
||||
private questDirectory = path.join(config.projectRoot, 'quests')
|
||||
|
||||
get(id: string) {
|
||||
return this.quests.get(id)
|
||||
get(id: string, locale: QuestLocale) {
|
||||
return this.quests.get(id)?.get(locale)
|
||||
}
|
||||
|
||||
getAll() {
|
||||
getAll(locale: QuestLocale) {
|
||||
return Array.from(this.quests.values())
|
||||
.map((versions) => versions.get(locale))
|
||||
.filter((quest): quest is IQuest => quest !== undefined)
|
||||
}
|
||||
|
||||
private async mapQuests(callback: QuestCallback) {
|
||||
@@ -83,42 +91,119 @@ class QuestManager {
|
||||
|
||||
async loadAndUpdateQuests() {
|
||||
const exceptions = await this.loadGlobalExceptions()
|
||||
return this.mapQuests(async (id, questPath) => {
|
||||
const files = await fs.readdir(questPath)
|
||||
if (!files.includes('game.yml')) {
|
||||
throw new QuestValidationError(`Missing game.yml`, id, null)
|
||||
const loaded = new Map<string, Map<QuestLocale, IQuest>>()
|
||||
|
||||
await this.mapQuests(async (id, questPath) => {
|
||||
const entries = await fs.readdir(questPath, { withFileTypes: true })
|
||||
const versions = new Map<QuestLocale, IQuest>()
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeEntry = entries.find((entry) => entry.name === locale)
|
||||
|
||||
if (!localeEntry) {
|
||||
logger.warn(`Missing locale "${locale}" for quest "${id}"`)
|
||||
// eslint-disable-next-line no-continue
|
||||
continue
|
||||
}
|
||||
|
||||
if (!localeEntry.isDirectory()) {
|
||||
throw new QuestValidationError(
|
||||
`${locale} exists but is not a directory`,
|
||||
id,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
const localePath = path.join(questPath, locale)
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(localePath)
|
||||
|
||||
if (!files.includes('game.yml')) {
|
||||
throw new QuestValidationError(
|
||||
`Missing ${locale}/game.yml`,
|
||||
id,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
const body = await fs.readFile(
|
||||
path.join(localePath, 'game.yml'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const questData = yaml.parse(body, {
|
||||
merge: true,
|
||||
})
|
||||
|
||||
const image = files.includes('Dockerfile')
|
||||
? questImageId(id, locale)
|
||||
: 'base'
|
||||
const quest = questSchema.parse({
|
||||
id,
|
||||
image,
|
||||
...questData,
|
||||
})
|
||||
quest.exceptions = [...exceptions]
|
||||
versions.set(locale, quest)
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing quest ${id} for locale ${locale}`, {
|
||||
error,
|
||||
})
|
||||
throw new QuestValidationError(
|
||||
`Error parsing quest ${id} for locale ${locale}`,
|
||||
id,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await fs.readFile(
|
||||
path.join(questPath, 'game.yml'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const questData = yaml.parse(body, {
|
||||
merge: true,
|
||||
})
|
||||
|
||||
const image = files.includes('Dockerfile') ? id : 'base'
|
||||
const quest = questSchema.parse({
|
||||
id,
|
||||
image,
|
||||
...questData,
|
||||
})
|
||||
quest.exceptions = [...exceptions]
|
||||
this.quests.set(id, quest)
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing quest ${id}`, { error })
|
||||
throw new QuestValidationError(`Error parsing quest ${id}`, id, error)
|
||||
if (versions.size > 0) {
|
||||
loaded.set(id, versions)
|
||||
} else {
|
||||
logger.warn(`No valid locales found for quest "${id}"`)
|
||||
}
|
||||
})
|
||||
|
||||
this.quests = loaded
|
||||
}
|
||||
|
||||
async getQuestDockerfiles() {
|
||||
const questlist = await this.mapQuests(async (id, questPath) => {
|
||||
const files = await fs.readdir(questPath)
|
||||
return files.includes('Dockerfile') ? { id, questPath } : null
|
||||
const entries = await fs.readdir(questPath, { withFileTypes: true })
|
||||
const dockerfiles: { id: string; questPath: string }[] = []
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeEntry = entries.find((entry) => entry.name === locale)
|
||||
|
||||
if (!localeEntry) {
|
||||
logger.warn(`Missing locale "${locale}" for quest "${id}"`)
|
||||
// eslint-disable-next-line no-continue
|
||||
continue
|
||||
}
|
||||
|
||||
if (!localeEntry.isDirectory()) {
|
||||
throw new QuestValidationError(
|
||||
`${locale} exists but is not a directory`,
|
||||
id,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
const localePath = path.join(questPath, locale)
|
||||
const files = await fs.readdir(localePath)
|
||||
|
||||
if (files.includes('Dockerfile')) {
|
||||
dockerfiles.push({
|
||||
id: questImageId(id, locale),
|
||||
questPath: localePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return dockerfiles.length > 0 ? dockerfiles : null
|
||||
})
|
||||
|
||||
return questlist.filter((quest) => quest !== null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { HydratedDocument } from 'mongoose'
|
||||
import type { QuestLocale } from '../../../packages/constants'
|
||||
import { LOCALE_NAME_MAP } from '../../../packages/constants'
|
||||
import { Session, UserProfile } from '../../../packages/models'
|
||||
import type { ISession, IUser } from '../../../packages/models'
|
||||
import {
|
||||
@@ -34,11 +36,12 @@ async function deactivateSessions(userId: string, quest: string) {
|
||||
|
||||
export async function createNewSession(
|
||||
userId: string,
|
||||
questId: string
|
||||
questId: string,
|
||||
locale: QuestLocale
|
||||
): Promise<ISession> {
|
||||
const quest = await questManager.get(questId)
|
||||
const quest = await questManager.get(questId, locale)
|
||||
if (!quest) {
|
||||
throw new Error(`Quest ${questId} not found`)
|
||||
throw new Error(`Quest ${questId} not found for ${LOCALE_NAME_MAP[locale]}`)
|
||||
}
|
||||
|
||||
// deactivate all active sessions
|
||||
@@ -55,12 +58,18 @@ export async function createNewSession(
|
||||
|
||||
const containerName = `quest-${quest.id}-${userProfile.user.username}-${Date.now()}`
|
||||
|
||||
const container = await createContainer(containerName, quest.id, quest.image)
|
||||
const container = await createContainer(
|
||||
containerName,
|
||||
quest.id,
|
||||
locale,
|
||||
quest.image
|
||||
)
|
||||
await getAndStartContainer(container.id)
|
||||
|
||||
const newSession = new Session({
|
||||
user: userId,
|
||||
quest: quest.id,
|
||||
locale,
|
||||
containerId: container.id,
|
||||
containerName,
|
||||
})
|
||||
@@ -117,15 +126,16 @@ export async function finishSession(
|
||||
|
||||
export async function isQuestUnlocked(
|
||||
userId: string,
|
||||
questId: string
|
||||
questId: string,
|
||||
locale: QuestLocale
|
||||
): Promise<boolean> {
|
||||
const userProfile = await UserProfile.findOne({ user: userId })
|
||||
if (!userProfile) {
|
||||
throw new Error(`UserProfile ${userId} not found`)
|
||||
}
|
||||
const quest = await questManager.get(questId)
|
||||
const quest = await questManager.get(questId, locale)
|
||||
if (!quest) {
|
||||
throw new Error(`Quest ${questId} not found`)
|
||||
throw new Error(`Quest ${questId} not found for ${LOCALE_NAME_MAP[locale]}`)
|
||||
}
|
||||
if (!quest.requirements) {
|
||||
return true
|
||||
|
||||
@@ -2,26 +2,34 @@ import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { router, publicProcedure } from '../trpc.js'
|
||||
import { questManager } from '../models/quest.js'
|
||||
import { SUPPORTED_LOCALES } from '../../../packages/constants'
|
||||
|
||||
export const questRouter = router({
|
||||
getQuests: publicProcedure.query(async () => {
|
||||
const quests = questManager.getAll()
|
||||
return quests.map(({ title, id, requirements }) => ({
|
||||
id,
|
||||
title,
|
||||
requirements,
|
||||
}))
|
||||
}),
|
||||
getQuestDetail: publicProcedure.input(z.string()).query(async (opts) => {
|
||||
const questId = opts.input
|
||||
const quest = questManager.get(questId)
|
||||
if (!quest) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Quest not found.',
|
||||
})
|
||||
}
|
||||
const { id, title, instruction, requirements } = quest
|
||||
return { id, title, instruction, requirements }
|
||||
}),
|
||||
getQuests: publicProcedure
|
||||
.input(z.object({ locale: z.enum(SUPPORTED_LOCALES) }))
|
||||
.query(async ({ input }) => {
|
||||
const quests = questManager.getAll(input.locale)
|
||||
|
||||
return quests.map(({ title, id, requirements }) => ({
|
||||
id,
|
||||
title,
|
||||
requirements,
|
||||
}))
|
||||
}),
|
||||
|
||||
getQuestDetail: publicProcedure
|
||||
.input(z.object({ questId: z.string(), locale: z.enum(SUPPORTED_LOCALES) }))
|
||||
.query(async ({ input }) => {
|
||||
const { questId, locale } = input
|
||||
const quest = questManager.get(questId, locale)
|
||||
|
||||
if (!quest) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Quest not found.',
|
||||
})
|
||||
}
|
||||
const { id, title, instruction, requirements } = quest
|
||||
return { id, title, instruction, requirements }
|
||||
}),
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { SUPPORTED_LOCALES } from '../../../packages/constants'
|
||||
import { ISession, Session } from '../../../packages/models'
|
||||
import { GameSession, VoidFileExistenceChecker } from '../../../packages/game'
|
||||
import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js'
|
||||
@@ -11,7 +12,7 @@ import { genJWT } from '../utils/auth'
|
||||
export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>>
|
||||
|
||||
async function sessionDetail(session: ISession) {
|
||||
const quest = questManager.get(session.quest)
|
||||
const quest = questManager.get(session.quest, session.locale)
|
||||
if (!quest) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
@@ -31,6 +32,7 @@ async function sessionDetail(session: ISession) {
|
||||
_id: session._id.toString(),
|
||||
user: session.user.toString(),
|
||||
quest: session.quest,
|
||||
locale: session.locale,
|
||||
status: session.status,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||
@@ -45,12 +47,20 @@ async function sessionDetail(session: ISession) {
|
||||
|
||||
export const sessionRouter = router({
|
||||
createSession: protectedProcedure
|
||||
.input(z.object({ questId: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
questId: z.string(),
|
||||
locale: z.enum(SUPPORTED_LOCALES),
|
||||
})
|
||||
)
|
||||
.mutation(async (opts) => {
|
||||
const { questId } = opts.input
|
||||
const { questId, locale } = opts.input
|
||||
const { user } = opts.ctx
|
||||
if ((await isQuestUnlocked(user.id, questId)) || config.testing.enabled) {
|
||||
const session = await createNewSession(user.id, questId)
|
||||
if (
|
||||
(await isQuestUnlocked(user.id, questId, locale)) ||
|
||||
config.testing.enabled
|
||||
) {
|
||||
const session = await createNewSession(user.id, questId, locale)
|
||||
return sessionDetail(session)
|
||||
}
|
||||
throw new TRPCError({
|
||||
|
||||
Reference in new issue
Block a user