diff --git a/app/src/components/QuestIntro.vue b/app/src/components/QuestIntro.vue index 20dac85..0a8655e 100644 --- a/app/src/components/QuestIntro.vue +++ b/app/src/components/QuestIntro.vue @@ -5,8 +5,9 @@ import { useToast } from 'vue-toastification' import { ref, defineProps, onMounted } from 'vue' import MarkdownText from './MarkdownText.vue' import { trpc } from '../utils/trpc' +import { SUPPORTED_LOCALES } from '../../../packages/constants' -const { t } = useI18n() +const { t, locale } = useI18n() const props = defineProps({ questTitle: { @@ -52,8 +53,15 @@ const handleColour = () => { } } onMounted(async () => { - const quest = await trpc.quests.getQuestDetail.query(questId) - questInstruction.value = quest.instruction + try { + const quest = await trpc.quests.getQuestDetail.query({ + questId, + locale: locale.value, + }) + questInstruction.value = quest.instruction + } catch (error) { + toast.error('Failed to load quest detail:', error) + } handleColour() }) diff --git a/app/src/components/QuestMap.vue b/app/src/components/QuestMap.vue index 7237031..437624b 100644 --- a/app/src/components/QuestMap.vue +++ b/app/src/components/QuestMap.vue @@ -2,12 +2,13 @@ import { useI18n } from 'vue-i18n' import { ref, onMounted, watch, computed } from 'vue' import { useToast } from 'vue-toastification' +import { SUPPORTED_LOCALES } from '../../../packages/constants' import { DAG } from '../../../packages/utils' import { trpc } from '../utils/trpc' import useUserProfile from '../store/userProfile' import QuestIntro from './QuestIntro.vue' -const { t } = useI18n() +const { t, locale } = useI18n() const store = useUserProfile() const toast = useToast() @@ -66,7 +67,8 @@ async function computeGraphData() { if (!store.progress) return try { - const quests = await trpc.quests.getQuests.query() + const quests = await trpc.quests.getQuests.query({ locale: locale.value }) + const dag = new DAG(quests) const nodesValues = dag.getNodes() @@ -117,6 +119,11 @@ onMounted(async () => { }) watch(() => store.progress, computeGraphData, { deep: true }) +watch(locale, () => { + opened.value = null + // eslint-disable-next-line no-void + void computeGraphData() +}) const curvedPath = computed(() => { return (source: Node, target: Node) => { diff --git a/app/src/components/game/ControlPalette.vue b/app/src/components/game/ControlPalette.vue index dd80732..f93d840 100644 --- a/app/src/components/game/ControlPalette.vue +++ b/app/src/components/game/ControlPalette.vue @@ -4,15 +4,44 @@ import { useRouter } from 'vue-router' import { useToast } from 'vue-toastification' import { useSession } from '../../store/session' import { openBugReport, openQuestSurvey } from '../../utils/formbricks' +import { trpc } from '../../utils/trpc' +import type { QuestLocale } from '../../../../packages/constants' -const { t } = useI18n() +const { t, locale } = useI18n() +const toast = useToast() const router = useRouter() const sessionStore = useSession() const reset = async () => { console.log('resetting...') - await sessionStore.createSession() + const selectedLocale = locale.value as QuestLocale + const questId = sessionStore.session?.quest + + if (!questId) return + + try { + const quests = await trpc.quests.getQuests.query({ + locale: selectedLocale, + }) + + console.log({ + selectedLocale, + questId, + availableQuestIds: quests.map((quest) => quest.id), + }) + + if (!quests.some((quest) => quest.id === questId)) { + toast.warning(t('game.language_unavailable')) + return + } + + await sessionStore.createSession(selectedLocale) + } catch (error) { + console.error('Failed to reset quest:', error) + toast.error(t('game.reset_failed')) + } } + const continuePlay = async () => { try { router.push({ name: 'map' }) diff --git a/app/src/components/game/StartButton.vue b/app/src/components/game/StartButton.vue index 4377fb5..93dae80 100644 --- a/app/src/components/game/StartButton.vue +++ b/app/src/components/game/StartButton.vue @@ -3,8 +3,9 @@ import { useI18n } from 'vue-i18n' import { useToast } from 'vue-toastification' import { computed } from 'vue' import { useSession } from '../../store/session' +import { SUPPORTED_LOCALES } from '../../../../packages/constants' -const { t } = useI18n() +const { t, locale } = useI18n() const toast = useToast() const sessionStore = useSession() @@ -13,7 +14,7 @@ const showCover = computed(() => sessionStore.session === null) const startSession = async () => { try { - await sessionStore.createSession() + await sessionStore.createSession(locale.value) } catch (err: any) { console.error(err) toast.error(err.message) diff --git a/app/src/components/header/LanguageSelector.vue b/app/src/components/header/LanguageSelector.vue index 5b2ad59..81011ac 100644 --- a/app/src/components/header/LanguageSelector.vue +++ b/app/src/components/header/LanguageSelector.vue @@ -1,12 +1,17 @@ diff --git a/packages/constants/index.ts b/packages/constants/index.ts index c555e8e..75eefca 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -16,3 +16,10 @@ export const getSessionsRequestSchema = z.object({ }) export type GetSessionsRequest = z.infer + +export const SUPPORTED_LOCALES = ['zh-TW', 'en'] as const +export const LOCALE_NAME_MAP: Record = { + 'zh-TW': '繁體中文', + en: 'English', +} +export type QuestLocale = (typeof SUPPORTED_LOCALES)[number] diff --git a/packages/models/session.ts b/packages/models/session.ts index 30f7d4e..9872be8 100644 --- a/packages/models/session.ts +++ b/packages/models/session.ts @@ -1,4 +1,6 @@ import { model, Schema, Types } from 'mongoose' +import type { QuestLocale } from '../constants' +import { SUPPORTED_LOCALES } from '../constants' export interface INode { path: string @@ -27,6 +29,7 @@ export interface ISession { _id: Types.ObjectId user: Types.ObjectId quest: string + locale: QuestLocale containerId: string | null containerName: string | null status: 'active' | 'finished' | 'inactive' @@ -52,6 +55,11 @@ export const Session = model( ref: 'Quest', required: true, }, + locale: { + type: String, + enum: SUPPORTED_LOCALES, + required: true, + }, containerId: String, containerName: String, status: { diff --git a/server/src/api/controllers/commandController.ts b/server/src/api/controllers/commandController.ts index c49e80a..aeab0a3 100644 --- a/server/src/api/controllers/commandController.ts +++ b/server/src/api/controllers/commandController.ts @@ -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 diff --git a/server/src/containers/docker.ts b/server/src/containers/docker.ts index ef4b84b..a3b03a4 100644 --- a/server/src/containers/docker.ts +++ b/server/src/containers/docker.ts @@ -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 { 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 diff --git a/server/src/models/quest.ts b/server/src/models/quest.ts index fa5f71d..cab531a 100644 --- a/server/src/models/quest.ts +++ b/server/src/models/quest.ts @@ -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() + private quests = new Map>() 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>() + + await this.mapQuests(async (id, questPath) => { + const entries = await fs.readdir(questPath, { withFileTypes: true }) + const versions = new Map() + + 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) } } diff --git a/server/src/models/sessionManager.ts b/server/src/models/sessionManager.ts index 56ca590..c58266e 100644 --- a/server/src/models/sessionManager.ts +++ b/server/src/models/sessionManager.ts @@ -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 { - 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 { 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 diff --git a/server/src/routers/questRouter.ts b/server/src/routers/questRouter.ts index 7d00fdf..fc84084 100644 --- a/server/src/routers/questRouter.ts +++ b/server/src/routers/questRouter.ts @@ -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 } + }), }) diff --git a/server/src/routers/sessionRouter.ts b/server/src/routers/sessionRouter.ts index 3c5ecbf..0226597 100644 --- a/server/src/routers/sessionRouter.ts +++ b/server/src/routers/sessionRouter.ts @@ -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> 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({