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({