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
+284 -80

No files matched your search

+10 -2
View File
@@ -5,8 +5,9 @@ import { useToast } from 'vue-toastification'
import { ref, defineProps, onMounted } from 'vue' import { ref, defineProps, onMounted } from 'vue'
import MarkdownText from './MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
import { trpc } from '../utils/trpc' import { trpc } from '../utils/trpc'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
const { t } = useI18n() const { t, locale } = useI18n()
const props = defineProps({ const props = defineProps({
questTitle: { questTitle: {
@@ -52,8 +53,15 @@ const handleColour = () => {
} }
} }
onMounted(async () => { onMounted(async () => {
const quest = await trpc.quests.getQuestDetail.query(questId) try {
const quest = await trpc.quests.getQuestDetail.query({
questId,
locale: locale.value,
})
questInstruction.value = quest.instruction questInstruction.value = quest.instruction
} catch (error) {
toast.error('Failed to load quest detail:', error)
}
handleColour() handleColour()
}) })
</script> </script>
+9 -2
View File
@@ -2,12 +2,13 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import { DAG } from '../../../packages/utils' import { DAG } from '../../../packages/utils'
import { trpc } from '../utils/trpc' import { trpc } from '../utils/trpc'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import QuestIntro from './QuestIntro.vue' import QuestIntro from './QuestIntro.vue'
const { t } = useI18n() const { t, locale } = useI18n()
const store = useUserProfile() const store = useUserProfile()
const toast = useToast() const toast = useToast()
@@ -66,7 +67,8 @@ async function computeGraphData() {
if (!store.progress) return if (!store.progress) return
try { try {
const quests = await trpc.quests.getQuests.query() const quests = await trpc.quests.getQuests.query({ locale: locale.value })
const dag = new DAG(quests) const dag = new DAG(quests)
const nodesValues = dag.getNodes() const nodesValues = dag.getNodes()
@@ -117,6 +119,11 @@ onMounted(async () => {
}) })
watch(() => store.progress, computeGraphData, { deep: true }) watch(() => store.progress, computeGraphData, { deep: true })
watch(locale, () => {
opened.value = null
// eslint-disable-next-line no-void
void computeGraphData()
})
const curvedPath = computed(() => { const curvedPath = computed(() => {
return (source: Node, target: Node) => { return (source: Node, target: Node) => {
+31 -2
View File
@@ -4,15 +4,44 @@ import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useSession } from '../../store/session' import { useSession } from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks' 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 router = useRouter()
const sessionStore = useSession() const sessionStore = useSession()
const reset = async () => { const reset = async () => {
console.log('resetting...') 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 () => { const continuePlay = async () => {
try { try {
router.push({ name: 'map' }) router.push({ name: 'map' })
+3 -2
View File
@@ -3,8 +3,9 @@ import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { computed } from 'vue' import { computed } from 'vue'
import { useSession } from '../../store/session' import { useSession } from '../../store/session'
import { SUPPORTED_LOCALES } from '../../../../packages/constants'
const { t } = useI18n() const { t, locale } = useI18n()
const toast = useToast() const toast = useToast()
const sessionStore = useSession() const sessionStore = useSession()
@@ -13,7 +14,7 @@ const showCover = computed(() => sessionStore.session === null)
const startSession = async () => { const startSession = async () => {
try { try {
await sessionStore.createSession() await sessionStore.createSession(locale.value)
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
toast.error(err.message) toast.error(err.message)
+12 -5
View File
@@ -1,12 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { setLocale, type SupportedLocale } from '../../i18n' import { setLocale } from '../../i18n'
import type { QuestLocale } from '../../../../packages/constants'
import {
SUPPORTED_LOCALES,
LOCALE_NAME_MAP,
} from '../../../../packages/constants'
const { locale } = useI18n({ useScope: 'global' }) const { t, locale } = useI18n({ useScope: 'global' })
function changeLanguage(event: Event) { function changeLanguage(event: Event) {
const selectedLocale = (event.target as HTMLSelectElement) const selectedLocale = (event.target as HTMLSelectElement)
.value as SupportedLocale .value as QuestLocale
setLocale(selectedLocale) setLocale(selectedLocale)
} }
@@ -15,12 +20,14 @@ function changeLanguage(event: Event) {
<template> <template>
<select <select
:value="locale" :value="locale"
:title="t('language.change')"
class="bg-bg-secondary px-3 py-2 text-sm text-text shadow-md outline-none" class="bg-bg-secondary px-3 py-2 text-sm text-text shadow-md outline-none"
aria-label="Language" aria-label="Language"
@change="changeLanguage" @change="changeLanguage"
style="font-size: 2vh" style="font-size: 2vh"
> >
<option value="en">English</option> <option v-for="locale in SUPPORTED_LOCALES" :key="locale" :value="locale">
<option value="zh-TW">繁體中文</option> {{ LOCALE_NAME_MAP[locale] }}
</option>
</select> </select>
</template> </template>
+6 -8
View File
@@ -1,16 +1,14 @@
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import type { QuestLocale } from '../../../packages/constants'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import en from './locales/en.json' import en from './locales/en.json'
import zhTW from './locales/zh-TW.json' import zhTW from './locales/zh-TW.json'
export const supportedLocales = ['zh-TW', 'en'] as const function isSupportedLocale(locale: string): locale is QuestLocale {
return SUPPORTED_LOCALES.includes(locale as QuestLocale)
export type SupportedLocale = (typeof supportedLocales)[number]
function isSupportedLocale(locale: string): locale is SupportedLocale {
return supportedLocales.includes(locale as SupportedLocale)
} }
function getInitialLocale(): SupportedLocale { function getInitialLocale(): QuestLocale {
const savedLocale = localStorage.getItem('locale') const savedLocale = localStorage.getItem('locale')
if (savedLocale && isSupportedLocale(savedLocale)) { if (savedLocale && isSupportedLocale(savedLocale)) {
@@ -34,7 +32,7 @@ export const i18n = createI18n({
}, },
}) })
export function setLocale(locale: SupportedLocale) { export function setLocale(locale: QuestLocale) {
i18n.global.locale.value = locale i18n.global.locale.value = locale
localStorage.setItem('locale', locale) localStorage.setItem('locale', locale)
document.documentElement.lang = locale document.documentElement.lang = locale
+5 -4
View File
@@ -1,8 +1,6 @@
{ {
"language": { "language": {
"change": "Change language", "change": "Change language"
"traditional_chinese": "繁體中文",
"english": "English"
}, },
"landing_nav": { "landing_nav": {
"intro": "Introduction", "intro": "Introduction",
@@ -102,6 +100,9 @@
"reset": "Reset", "reset": "Reset",
"continue": "Continue", "continue": "Continue",
"command_book": "Command Book", "command_book": "Command Book",
"cheatsheets": "Cheat Sheets" "cheatsheets": "Cheat Sheets",
"reset_to_apply_language": "Reset quest to apply language changes",
"reset_failed": "Reset failed",
"language_unavailable": "This quest does not support the selected language, please choose another language"
} }
} }
+5 -4
View File
@@ -1,8 +1,6 @@
{ {
"language": { "language": {
"change": "切換語言", "change": "切換語言"
"traditional_chinese": "繁體中文",
"english": "English"
}, },
"landing_nav": { "landing_nav": {
"intro": "前言", "intro": "前言",
@@ -102,6 +100,9 @@
"reset": "重置", "reset": "重置",
"continue": "繼續", "continue": "繼續",
"command_book": "指令之書", "command_book": "指令之書",
"cheatsheets": "指令列表" "cheatsheets": "指令列表",
"reset_to_apply_language": "重置關卡以套用語言變更",
"reset_failed": "重置失敗",
"language_unavailable": "此關卡不支援所選語言,請選擇其他語言"
} }
} }
+13 -8
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { QuestLocale } from '../../../packages/constants'
import { import {
FileGraph, FileGraph,
type FileGraphUpdateEvent, type FileGraphUpdateEvent,
@@ -41,11 +42,16 @@ export const useSession = defineStore('session', {
quest: null, quest: null,
}), }),
actions: { actions: {
async setQuest(questId: string) { async setQuest(questId: string, locale: QuestLocale) {
this.quest = await trpc.quests.getQuestDetail.query(questId) this.quest = await trpc.quests.getQuestDetail.query({
questId,
locale,
})
this.questId = questId this.questId = questId
}, },
async setSession(session: SessionDetail) { async setSession(session: SessionDetail) {
await this.setQuest(session.quest, session.locale)
this.session = { this.session = {
...session, ...session,
graph: new FileGraph(session.graph), graph: new FileGraph(session.graph),
@@ -59,19 +65,18 @@ export const useSession = defineStore('session', {
) )
term.focus() term.focus()
}, },
async createSession() { async createSession(locale: QuestLocale) {
const session = await trpc.session.createSession.mutate({ const session = await trpc.session.createSession.mutate({
questId: this.questId, questId: this.questId,
locale,
}) })
await this.setSession(session) await this.setSession(session)
term.send('echo start\n') term.send('echo start\n')
}, },
async getActiveSession() { async getActiveSession(questId: string) {
const session = await trpc.session.getActiveSession.query({ const session = await trpc.session.getActiveSession.query({ questId })
questId: this.questId,
})
if (session) { if (session) {
this.setSession(session) await this.setSession(session)
} }
}, },
newUpdate(update: SessionUpdate) { newUpdate(update: SessionUpdate) {
+17 -2
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useSession } from '../store/session' import { useSession } from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
@@ -13,6 +15,8 @@ import CompleteModal from '../components/game/CompleteModal.vue'
import StartButton from '../components/game/StartButton.vue' import StartButton from '../components/game/StartButton.vue'
import { openQuestSurvey } from '../utils/formbricks' import { openQuestSurvey } from '../utils/formbricks'
const { t, locale } = useI18n()
const toast = useToast()
const sessionStore = useSession() const sessionStore = useSession()
const userStore = useUserProfile() const userStore = useUserProfile()
@@ -27,6 +31,13 @@ watch(completed, (newValue, oldValue) => {
} }
}) })
watch(locale, (newLocale) => {
const { session } = sessionStore
if (session && newLocale !== session.locale) {
toast.info(t('game.reset_to_apply_language'))
}
})
const props = defineProps({ const props = defineProps({
questId: { questId: {
type: String, type: String,
@@ -37,8 +48,12 @@ const props = defineProps({
onMounted(async () => { onMounted(async () => {
sessionStore.reset() sessionStore.reset()
sessionStore.setup() sessionStore.setup()
await sessionStore.setQuest(props.questId)
await sessionStore.getActiveSession() await sessionStore.getActiveSession(props.questId)
if (!sessionStore.session) {
await sessionStore.setQuest(props.questId, locale.value)
}
}) })
</script> </script>
+7
View File
@@ -16,3 +16,10 @@ export const getSessionsRequestSchema = z.object({
}) })
export type GetSessionsRequest = z.infer<typeof getSessionsRequestSchema> export type GetSessionsRequest = z.infer<typeof getSessionsRequestSchema>
export const SUPPORTED_LOCALES = ['zh-TW', 'en'] as const
export const LOCALE_NAME_MAP: Record<string, string> = {
'zh-TW': '繁體中文',
en: 'English',
}
export type QuestLocale = (typeof SUPPORTED_LOCALES)[number]
+8
View File
@@ -1,4 +1,6 @@
import { model, Schema, Types } from 'mongoose' import { model, Schema, Types } from 'mongoose'
import type { QuestLocale } from '../constants'
import { SUPPORTED_LOCALES } from '../constants'
export interface INode { export interface INode {
path: string path: string
@@ -27,6 +29,7 @@ export interface ISession {
_id: Types.ObjectId _id: Types.ObjectId
user: Types.ObjectId user: Types.ObjectId
quest: string quest: string
locale: QuestLocale
containerId: string | null containerId: string | null
containerName: string | null containerName: string | null
status: 'active' | 'finished' | 'inactive' status: 'active' | 'finished' | 'inactive'
@@ -52,6 +55,11 @@ export const Session = model<ISession>(
ref: 'Quest', ref: 'Quest',
required: true, required: true,
}, },
locale: {
type: String,
enum: SUPPORTED_LOCALES,
required: true,
},
containerId: String, containerId: String,
containerName: String, containerName: String,
status: { status: {
@@ -54,7 +54,7 @@ export const newCommand = asyncHandler(async (req: Request, res: Response) => {
session.lastActivityAt = new Date() session.lastActivityAt = new Date()
const quest = await questManager.get(session.quest) const quest = await questManager.get(session.quest, session.locale)
if (!quest) { if (!quest) {
res.status(404).json({ message: 'quest not found' }) res.status(404).json({ message: 'quest not found' })
return return
+9 -5
View File
@@ -1,5 +1,6 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import Docker from 'dockerode' import Docker from 'dockerode'
import type { QuestLocale } from '../../../packages/constants'
import config, { getQuestImage } from '../config.js' import config, { getQuestImage } from '../config.js'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
@@ -36,19 +37,20 @@ const newContainerOptions = (
export async function createContainer( export async function createContainer(
name: string, name: string,
questId: string, questId: string,
locale: QuestLocale,
imageId: string imageId: string
): Promise<Docker.Container> { ): Promise<Docker.Container> {
const binds: string[] = [] const binds: string[] = []
if (await questHomeExists(questId)) { if (await questHomeExists(questId, locale)) {
logger.info('Mounting quest home', questId) logger.info('Mounting quest home', questId)
binds.push( 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') { if (config.docker.mountQuest && imageId !== 'base') {
logger.info('Mounting quest directory', questId) logger.info('Mounting quest directory', questId)
binds.push( binds.push(
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander` `${config.docker.hostProjectRoot}/quests/${questId}/${locale}/home:/home/commander`
) )
} }
if (config.docker.mountCLI) { 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 { 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() return stat.isDirectory()
} catch { } catch {
return false return false
+103 -18
View File
@@ -3,6 +3,8 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import yaml from 'yaml' import yaml from 'yaml'
import type { QuestLocale } from '../../../packages/constants'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import { import {
questSchema, questSchema,
globalExceptionSchema, globalExceptionSchema,
@@ -36,16 +38,22 @@ class QuestValidationError extends Error {
} }
} }
class QuestManager { function questImageId(id: string, locale: QuestLocale) {
private quests = new Map<string, IQuest>() return `${id}-${locale.toLowerCase()}`
private questDirectory = path.join(config.projectRoot, 'quests')
get(id: string) {
return this.quests.get(id)
} }
getAll() { class QuestManager {
private quests = new Map<string, Map<QuestLocale, IQuest>>()
private questDirectory = path.join(config.projectRoot, 'quests')
get(id: string, locale: QuestLocale) {
return this.quests.get(id)?.get(locale)
}
getAll(locale: QuestLocale) {
return Array.from(this.quests.values()) return Array.from(this.quests.values())
.map((versions) => versions.get(locale))
.filter((quest): quest is IQuest => quest !== undefined)
} }
private async mapQuests(callback: QuestCallback) { private async mapQuests(callback: QuestCallback) {
@@ -83,15 +91,44 @@ class QuestManager {
async loadAndUpdateQuests() { async loadAndUpdateQuests() {
const exceptions = await this.loadGlobalExceptions() const exceptions = await this.loadGlobalExceptions()
return this.mapQuests(async (id, questPath) => { const loaded = new Map<string, Map<QuestLocale, IQuest>>()
const files = await fs.readdir(questPath)
if (!files.includes('game.yml')) { await this.mapQuests(async (id, questPath) => {
throw new QuestValidationError(`Missing game.yml`, id, null) 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 { 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( const body = await fs.readFile(
path.join(questPath, 'game.yml'), path.join(localePath, 'game.yml'),
'utf-8' 'utf-8'
) )
@@ -99,26 +136,74 @@ class QuestManager {
merge: true, merge: true,
}) })
const image = files.includes('Dockerfile') ? id : 'base' const image = files.includes('Dockerfile')
? questImageId(id, locale)
: 'base'
const quest = questSchema.parse({ const quest = questSchema.parse({
id, id,
image, image,
...questData, ...questData,
}) })
quest.exceptions = [...exceptions] quest.exceptions = [...exceptions]
this.quests.set(id, quest) versions.set(locale, quest)
} catch (error) { } catch (error) {
logger.error(`Error parsing quest ${id}`, { error }) logger.error(`Error parsing quest ${id} for locale ${locale}`, {
throw new QuestValidationError(`Error parsing quest ${id}`, id, error) error,
})
throw new QuestValidationError(
`Error parsing quest ${id} for locale ${locale}`,
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() { async getQuestDockerfiles() {
const questlist = await this.mapQuests(async (id, questPath) => { const questlist = await this.mapQuests(async (id, questPath) => {
const files = await fs.readdir(questPath) const entries = await fs.readdir(questPath, { withFileTypes: true })
return files.includes('Dockerfile') ? { id, questPath } : null 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) return questlist.filter((quest) => quest !== null)
} }
} }
+17 -7
View File
@@ -1,4 +1,6 @@
import { HydratedDocument } from 'mongoose' import { HydratedDocument } from 'mongoose'
import type { QuestLocale } from '../../../packages/constants'
import { LOCALE_NAME_MAP } from '../../../packages/constants'
import { Session, UserProfile } from '../../../packages/models' import { Session, UserProfile } from '../../../packages/models'
import type { ISession, IUser } from '../../../packages/models' import type { ISession, IUser } from '../../../packages/models'
import { import {
@@ -34,11 +36,12 @@ async function deactivateSessions(userId: string, quest: string) {
export async function createNewSession( export async function createNewSession(
userId: string, userId: string,
questId: string questId: string,
locale: QuestLocale
): Promise<ISession> { ): Promise<ISession> {
const quest = await questManager.get(questId) const quest = await questManager.get(questId, locale)
if (!quest) { 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 // deactivate all active sessions
@@ -55,12 +58,18 @@ export async function createNewSession(
const containerName = `quest-${quest.id}-${userProfile.user.username}-${Date.now()}` 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) await getAndStartContainer(container.id)
const newSession = new Session({ const newSession = new Session({
user: userId, user: userId,
quest: quest.id, quest: quest.id,
locale,
containerId: container.id, containerId: container.id,
containerName, containerName,
}) })
@@ -117,15 +126,16 @@ export async function finishSession(
export async function isQuestUnlocked( export async function isQuestUnlocked(
userId: string, userId: string,
questId: string questId: string,
locale: QuestLocale
): Promise<boolean> { ): Promise<boolean> {
const userProfile = await UserProfile.findOne({ user: userId }) const userProfile = await UserProfile.findOne({ user: userId })
if (!userProfile) { if (!userProfile) {
throw new Error(`UserProfile ${userId} not found`) throw new Error(`UserProfile ${userId} not found`)
} }
const quest = await questManager.get(questId) const quest = await questManager.get(questId, locale)
if (!quest) { if (!quest) {
throw new Error(`Quest ${questId} not found`) throw new Error(`Quest ${questId} not found for ${LOCALE_NAME_MAP[locale]}`)
} }
if (!quest.requirements) { if (!quest.requirements) {
return true return true
+13 -5
View File
@@ -2,19 +2,27 @@ import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { router, publicProcedure } from '../trpc.js' import { router, publicProcedure } from '../trpc.js'
import { questManager } from '../models/quest.js' import { questManager } from '../models/quest.js'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
export const questRouter = router({ export const questRouter = router({
getQuests: publicProcedure.query(async () => { getQuests: publicProcedure
const quests = questManager.getAll() .input(z.object({ locale: z.enum(SUPPORTED_LOCALES) }))
.query(async ({ input }) => {
const quests = questManager.getAll(input.locale)
return quests.map(({ title, id, requirements }) => ({ return quests.map(({ title, id, requirements }) => ({
id, id,
title, title,
requirements, requirements,
})) }))
}), }),
getQuestDetail: publicProcedure.input(z.string()).query(async (opts) => {
const questId = opts.input getQuestDetail: publicProcedure
const quest = questManager.get(questId) .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) { if (!quest) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
+15 -5
View File
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { SUPPORTED_LOCALES } from '../../../packages/constants'
import { ISession, Session } from '../../../packages/models' import { ISession, Session } from '../../../packages/models'
import { GameSession, VoidFileExistenceChecker } from '../../../packages/game' import { GameSession, VoidFileExistenceChecker } from '../../../packages/game'
import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js' import { createNewSession, isQuestUnlocked } from '../models/sessionManager.js'
@@ -11,7 +12,7 @@ import { genJWT } from '../utils/auth'
export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>> export type SessionDetail = Awaited<ReturnType<typeof sessionDetail>>
async function sessionDetail(session: ISession) { async function sessionDetail(session: ISession) {
const quest = questManager.get(session.quest) const quest = questManager.get(session.quest, session.locale)
if (!quest) { if (!quest) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
@@ -31,6 +32,7 @@ async function sessionDetail(session: ISession) {
_id: session._id.toString(), _id: session._id.toString(),
user: session.user.toString(), user: session.user.toString(),
quest: session.quest, quest: session.quest,
locale: session.locale,
status: session.status, status: session.status,
createdAt: session.createdAt.toISOString(), createdAt: session.createdAt.toISOString(),
lastActivityAt: session.lastActivityAt.toISOString(), lastActivityAt: session.lastActivityAt.toISOString(),
@@ -45,12 +47,20 @@ async function sessionDetail(session: ISession) {
export const sessionRouter = router({ export const sessionRouter = router({
createSession: protectedProcedure createSession: protectedProcedure
.input(z.object({ questId: z.string() })) .input(
z.object({
questId: z.string(),
locale: z.enum(SUPPORTED_LOCALES),
})
)
.mutation(async (opts) => { .mutation(async (opts) => {
const { questId } = opts.input const { questId, locale } = opts.input
const { user } = opts.ctx const { user } = opts.ctx
if ((await isQuestUnlocked(user.id, questId)) || config.testing.enabled) { if (
const session = await createNewSession(user.id, questId) (await isQuestUnlocked(user.id, questId, locale)) ||
config.testing.enabled
) {
const session = await createNewSession(user.id, questId, locale)
return sessionDetail(session) return sessionDetail(session)
} }
throw new TRPCError({ throw new TRPCError({