Initial commit: NestJS backend for Child Gold Land
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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 {
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user