Files
gold_land_server/src/progression/progression-sync.service.ts
T
safavimohadeseh78andCursor dfb20ca92c
Backend CI / type-check, build, tests (push) Successful in 1m0s
feat: prepare six-lesson trial backend with reliable rewards and operations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:35:42 +03:30

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'
}
}
}