feat: support changing quest language

This commit is contained in:
neilchen0812 committed 2026-10-08 02:46:44 +10:30
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
+9 -5
View File
@@ -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
View File
@@ -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)
}
}
+17 -7
View File
@@ -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
+28 -20
View File
@@ -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 }
}),
})
+15 -5
View File
@@ -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({