feat: allow manual unlocks in learn missions

This commit is contained in:
Remi
2025-09-22 07:38:46 +02:00
committed by itsrubberduck
parent 142f7aaa18
commit 839795f6a3
5 changed files with 126 additions and 3 deletions

View File

@@ -86,7 +86,15 @@
<v-icon size="26">mdi-lock-alert</v-icon>
<div class="tile-overlay-text">
<div class="tile-overlay-title">Clearance pending</div>
<div class="tile-overlay-sub">Complete earlier missions to unlock this briefing.</div>
<div class="tile-overlay-sub">
Complete earlier missions to
<button
type="button"
class="tile-overlay-link"
@click.stop.prevent="attemptUnlockModule(m.id)"
>unlock</button>
this briefing.
</div>
</div>
</div>
</div>
@@ -764,6 +772,10 @@
</div>
<div class="muted small">{{ l.desc }}</div>
<div class="tags">
<span
v-if="lessonOrderTag(current.id, l.id)"
class="tag tag-index"
>{{ lessonOrderTag(current.id, l.id) }}</span>
<span v-for="k in l.keywords" :key="k" class="tag">{{ k }}</span>
</div>
</button>
@@ -2348,6 +2360,7 @@ audioReveal.value = !cfg.value.audioChallenge
const XP_PER_LEVEL = 300
const xp = ref(0)
const progress = ref<LearnProgress>({})
const unlockedModules = ref<string[]>([])
const level = computed(() => 1 + Math.floor(xp.value / XP_PER_LEVEL))
const nextLevel = computed(() => level.value + 1)
const levelProgress = computed(() => Math.min(100, Math.round(((xp.value % XP_PER_LEVEL) / XP_PER_LEVEL) * 100)))
@@ -2419,8 +2432,17 @@ const currentBriefingArt = computed(() => current.value?.meta?.briefingArt || '/
type LearnStateResponse = LearnState
function sanitizeModuleList(value: unknown): string[] {
if (!Array.isArray(value)) return []
const sanitized = value
.filter(item => typeof item === 'string')
.map(item => item.trim())
.filter(item => item.length > 0)
return Array.from(new Set(sanitized))
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
const dirtyState = reactive({xp: false, progress: false, config: false})
const dirtyState = reactive({xp: false, progress: false, config: false, unlocked: false})
const savingState = ref(false)
const pendingSave = ref(false)
@@ -2433,19 +2455,23 @@ async function loadLearnState() {
xp.value = Number.isFinite(response.xp) ? Math.max(0, Math.round(response.xp)) : 0
progress.value = (response.progress ?? {}) as LearnProgress
cfg.value = {...defaultCfg, ...(response.config || {})}
unlockedModules.value = sanitizeModuleList(response.unlockedModules)
} else {
xp.value = 0
progress.value = {} as LearnProgress
cfg.value = {...defaultCfg}
unlockedModules.value = []
}
dirtyState.xp = false
dirtyState.progress = false
dirtyState.config = false
dirtyState.unlocked = false
} catch (err) {
console.error('Failed to load learn state', err)
xp.value = 0
progress.value = {} as LearnProgress
cfg.value = {...defaultCfg}
unlockedModules.value = []
} finally {
audioReveal.value = !cfg.value.audioChallenge
}
@@ -2469,7 +2495,7 @@ function schedulePersist(immediate = false) {
async function persistLearnState(force = false) {
if (!isClient) return
if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config) {
if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config && !dirtyState.unlocked) {
return
}
if (savingState.value) {
@@ -2483,6 +2509,7 @@ async function persistLearnState(force = false) {
xp: Math.max(0, Math.round(xp.value)),
progress: JSON.parse(JSON.stringify(progress.value)),
config: {...cfg.value},
unlockedModules: [...unlockedModules.value],
}
try {
@@ -2490,6 +2517,7 @@ async function persistLearnState(force = false) {
dirtyState.xp = false
dirtyState.progress = false
dirtyState.config = false
dirtyState.unlocked = false
} catch (err) {
console.error('Failed to persist learn state', err)
} finally {
@@ -2519,6 +2547,10 @@ if (isClient) {
dirtyState.xp = true
schedulePersist()
})
watch(unlockedModules, () => {
dirtyState.unlocked = true
schedulePersist()
}, {deep: true})
watch(() => cfg.value.tts, markConfigDirty)
watch(() => cfg.value.audioChallenge, () => {
markConfigDirty()
@@ -2952,12 +2984,25 @@ function evaluate() {
}
function isModuleUnlocked(id: string) {
if (unlockedModules.value.includes(id)) return true
if (id === 'normalize') return true
const order = modules.value.findIndex(module => module.id === id)
const previous = modules.value[order - 1]
return previous ? pct(previous.id) >= 80 : true
}
function attemptUnlockModule(modId: string) {
if (!isClient) return
if (!modId) return
if (isModuleUnlocked(modId)) return
const confirmed = window.confirm('Unlock this mission briefing early?')
if (!confirmed) return
const next = new Set(unlockedModules.value)
next.add(modId)
unlockedModules.value = Array.from(next)
toastNow('Mission unlocked')
}
function openModule(id: string, options: { autoStart?: boolean } = {}) {
const module = modules.value.find(item => item.id === id) || null
current.value = module
@@ -3073,6 +3118,25 @@ function pct(modId: string) {
return Math.round((doneCount(modId) / module.lessons.length) * 100)
}
function moduleNumber(modId: string): number {
const index = modules.value.findIndex(module => module.id === modId)
return index >= 0 ? index + 1 : 0
}
function lessonNumber(modId: string, lesId: string): number {
const module = modules.value.find(item => item.id === modId)
if (!module) return 0
const index = module.lessons.findIndex(lesson => lesson.id === lesId)
return index >= 0 ? index + 1 : 0
}
function lessonOrderTag(modId: string, lesId: string): string {
const modNum = moduleNumber(modId)
const lesNum = lessonNumber(modId, lesId)
if (!modNum || !lesNum) return ''
return `${modNum}.${lesNum}`
}
function avgScore(modId: string) {
const module = modules.value.find(item => item.id === modId)
if (!module) return 0
@@ -3285,10 +3349,12 @@ function resetAll() {
progress.value = {} as LearnProgress
xp.value = 0
cfg.value = {...defaultCfg}
unlockedModules.value = []
audioReveal.value = !cfg.value.audioChallenge
dirtyState.progress = true
dirtyState.xp = true
dirtyState.config = true
dirtyState.unlocked = true
schedulePersist(true)
}
@@ -4399,6 +4465,25 @@ onMounted(() => {
color: rgba(248, 250, 252, .75)
}
.tile-overlay-link {
pointer-events: auto;
background: none;
border: none;
padding: 0;
margin: 0 2px;
font: inherit;
color: inherit;
cursor: pointer;
text-decoration: none;
}
.tile-overlay-link:hover,
.tile-overlay-link:focus-visible {
text-decoration: underline;
color: #f8fafc;
outline: none;
}
.line {
height: 8px;
border: 1px solid var(--border);
@@ -4886,6 +4971,13 @@ onMounted(() => {
padding: 2px 6px
}
.tag-index {
border-style: solid;
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
font-weight: 600;
}
.console {
display: flex;
flex-direction: column;

View File

@@ -15,10 +15,14 @@ export default defineEventHandler(async (event) => {
profile.progress && typeof profile.progress === 'object'
? JSON.parse(JSON.stringify(profile.progress))
: {}
const unlockedModules = Array.isArray(profile.unlockedModules)
? Array.from(new Set((profile.unlockedModules as unknown[]).filter(item => typeof item === 'string').map(item => item.trim())))
: []
return {
xp: typeof profile.xp === 'number' ? profile.xp : 0,
progress,
config,
unlockedModules,
}
})

View File

@@ -8,6 +8,7 @@ interface LearnStateUpdateBody {
xp?: number
progress?: LearnProgress
config?: Partial<LearnConfig>
unlockedModules?: string[]
}
function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | undefined {
@@ -76,6 +77,19 @@ function sanitizeConfig(input: Partial<LearnConfig> | undefined): LearnConfig |
return config
}
function sanitizeUnlockedModules(input: string[] | undefined): string[] | undefined {
if (!Array.isArray(input)) {
return undefined
}
const sanitized = input
.filter(value => typeof value === 'string')
.map(value => value.trim())
.filter(value => value.length > 0)
return Array.from(new Set(sanitized))
}
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody<LearnStateUpdateBody>(event)
@@ -99,6 +113,11 @@ export default defineEventHandler(async (event) => {
profile.config = config
}
const unlockedModules = sanitizeUnlockedModules(body.unlockedModules)
if (unlockedModules !== undefined) {
profile.unlockedModules = unlockedModules
}
await profile.save()
const responseConfig = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
@@ -106,10 +125,14 @@ export default defineEventHandler(async (event) => {
profile.progress && typeof profile.progress === 'object'
? JSON.parse(JSON.stringify(profile.progress))
: {}
const responseUnlockedModules = Array.isArray(profile.unlockedModules)
? Array.from(new Set((profile.unlockedModules as unknown[]).filter(item => typeof item === 'string').map(item => item.trim())))
: []
return {
xp: typeof profile.xp === 'number' ? profile.xp : 0,
progress: responseProgress,
config: responseConfig,
unlockedModules: responseUnlockedModules,
}
})

View File

@@ -6,6 +6,7 @@ export interface LearnProfileDocument extends mongoose.Document {
xp: number
progress: LearnProgress
config: LearnConfig
unlockedModules: string[]
createdAt: Date
updatedAt: Date
}
@@ -15,6 +16,7 @@ const learnProfileSchema = new mongoose.Schema<LearnProfileDocument>(
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true },
xp: { type: Number, default: 0 },
progress: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
unlockedModules: { type: [String], default: () => [] },
config: {
tts: { type: Boolean, default: false },
radioLevel: { type: Number, default: 4, min: 1, max: 5 },

View File

@@ -17,6 +17,7 @@ export interface LearnState {
xp: number
progress: LearnProgress
config: LearnConfig
unlockedModules: string[]
}
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
@@ -36,5 +37,6 @@ export function createDefaultLearnState(): LearnState {
xp: 0,
progress: {} as LearnProgress,
config: createDefaultLearnConfig(),
unlockedModules: [],
}
}