Backend CI / type-check, build, tests (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
import { Inject, Injectable, forwardRef } from '@nestjs/common'
|
|
import { DatabaseService } from '../database/database.service'
|
|
import {
|
|
GOLD_LAND_LEVELS,
|
|
GOLD_LAND_MAX_STARS,
|
|
getLevelDefinition,
|
|
} from './levels'
|
|
import type { UserState } from '../shared/types/user.types'
|
|
import { UsersService } from '../users/users.service'
|
|
|
|
type LevelStatus = 'locked' | 'available' | 'in_progress' | 'completed' | 'coming_soon'
|
|
|
|
interface LevelProgressRow {
|
|
user_id: string
|
|
level_id: number
|
|
best_score: number
|
|
stars: number
|
|
attempts_count: number
|
|
started_at: string | null
|
|
completed_at: string | null
|
|
updated_at: string
|
|
}
|
|
|
|
interface LevelItemResponse {
|
|
id: number
|
|
order: number
|
|
slug: string
|
|
title: string
|
|
description: string
|
|
status: LevelStatus
|
|
stars: 0 | 1 | 2 | 3
|
|
bestScore: number
|
|
activityType: string
|
|
activityKey: string
|
|
activityRoute?: string
|
|
rewardXp: number
|
|
rewardCoins: number
|
|
}
|
|
|
|
@Injectable()
|
|
export class ProgressionSyncService {
|
|
constructor(
|
|
@Inject(DatabaseService) private readonly database: DatabaseService,
|
|
@Inject(forwardRef(() => UsersService))
|
|
private readonly users: UsersService,
|
|
) {}
|
|
|
|
syncUserProgressionState(userId: string): UserState {
|
|
return this.database.transaction(() => {
|
|
const state = this.users.getUserState(userId)
|
|
const items = this.buildLevelItems(userId)
|
|
this.syncLegacyMapLevels(state, items)
|
|
return this.users.saveUserState(state)
|
|
})
|
|
}
|
|
|
|
private getProgressRow(userId: string, levelId: number): LevelProgressRow | undefined {
|
|
return this.database
|
|
.getDb()
|
|
.prepare('SELECT * FROM user_level_progress WHERE user_id = ? AND level_id = ?')
|
|
.get(userId, levelId) as LevelProgressRow | undefined
|
|
}
|
|
|
|
private getAllProgressRows(userId: string): LevelProgressRow[] {
|
|
return this.database
|
|
.getDb()
|
|
.prepare('SELECT * FROM user_level_progress WHERE user_id = ?')
|
|
.all(userId) as LevelProgressRow[]
|
|
}
|
|
|
|
private migrateFromLegacyState(userId: string): void {
|
|
const existing = this.getAllProgressRows(userId)
|
|
if (existing.length > 0) return
|
|
|
|
const state = this.users.getUserState(userId)
|
|
const now = new Date().toISOString()
|
|
const db = this.database.getDb()
|
|
const insert = db.prepare(`
|
|
INSERT INTO user_level_progress (
|
|
user_id, level_id, best_score, stars, attempts_count,
|
|
started_at, completed_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
|
|
this.database.transaction(() => {
|
|
for (const legacy of state.mapLevels) {
|
|
if (legacy.stars <= 0 && legacy.status !== 'completed') continue
|
|
insert.run(
|
|
userId,
|
|
legacy.id,
|
|
legacy.stars > 0 ? legacy.stars * 30 : 0,
|
|
legacy.stars,
|
|
legacy.stars > 0 ? 1 : 0,
|
|
legacy.stars > 0 ? now : null,
|
|
legacy.status === 'completed' && legacy.stars > 0 ? now : null,
|
|
now,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
private isLevelUnlocked(levelId: number, rowsById: Map<number, LevelProgressRow>): boolean {
|
|
if (levelId === 1) return true
|
|
const prevStars = rowsById.get(levelId - 1)?.stars ?? 0
|
|
return prevStars >= 1
|
|
}
|
|
|
|
private resolveLevelStatus(
|
|
levelId: number,
|
|
progress: LevelProgressRow | undefined,
|
|
rowsById: Map<number, LevelProgressRow>,
|
|
): LevelStatus {
|
|
const definition = getLevelDefinition(levelId)
|
|
if (!definition) return 'locked'
|
|
|
|
if ((progress?.stars ?? 0) > 0) return 'completed'
|
|
if (progress?.started_at && !progress.completed_at) return 'in_progress'
|
|
|
|
if (!this.isLevelUnlocked(levelId, rowsById)) return 'locked'
|
|
|
|
if (!definition.isActive) return 'coming_soon'
|
|
|
|
return 'available'
|
|
}
|
|
|
|
private buildLevelItems(userId: string): LevelItemResponse[] {
|
|
this.migrateFromLegacyState(userId)
|
|
const rows = this.getAllProgressRows(userId)
|
|
const byId = new Map(rows.map((row) => [row.level_id, row]))
|
|
|
|
return GOLD_LAND_LEVELS.map((definition) => {
|
|
const progress = byId.get(definition.id)
|
|
const status = this.resolveLevelStatus(definition.id, progress, byId)
|
|
|
|
return {
|
|
id: definition.id,
|
|
order: definition.order,
|
|
slug: definition.slug,
|
|
title: definition.title,
|
|
description: definition.description,
|
|
status,
|
|
stars: (progress?.stars ?? 0) as 0 | 1 | 2 | 3,
|
|
bestScore: progress?.best_score ?? 0,
|
|
activityType: definition.activityType,
|
|
activityKey: definition.activityKey,
|
|
activityRoute: definition.activityRoute,
|
|
rewardXp: definition.rewardXp,
|
|
rewardCoins: definition.rewardCoins,
|
|
}
|
|
})
|
|
}
|
|
|
|
private syncLegacyMapLevels(state: UserState, items: LevelItemResponse[]): void {
|
|
state.mapLevels = items.map((item) => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
stars: item.stars,
|
|
maxStars: 3,
|
|
status: this.mapStatusToLegacy(item.status),
|
|
lessonRoute: item.activityRoute,
|
|
x: 0,
|
|
y: 0,
|
|
}))
|
|
state.totalStars = items.reduce((sum, item) => sum + item.stars, 0)
|
|
state.maxStars = GOLD_LAND_MAX_STARS
|
|
}
|
|
|
|
private mapStatusToLegacy(status: LevelStatus): UserState['mapLevels'][number]['status'] {
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'completed'
|
|
case 'in_progress':
|
|
return 'current'
|
|
case 'available':
|
|
return 'unlocked'
|
|
case 'coming_soon':
|
|
return 'locked'
|
|
default:
|
|
return 'locked'
|
|
}
|
|
}
|
|
}
|