mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-08 11:30:12 +08:00
feat: support changing quest language
This commit is contained in:
1 parent
9b8d7109cf
commit
66b0b7ba68
18 files changed
+284
-80
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "此關卡不支援所選語言,請選擇其他語言"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in new issue
Block a user