Initial commit: NestJS backend for Child Gold Land

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-02 08:08:11 +02:00
co-authored by Cursor
commit edb79180a1
93 changed files with 11565 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Server — production cutover uses PORT=3000 (replaces legacy Fastify)
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
# CORS (Vue dev server)
CORS_ORIGIN=http://localhost:5173
# SQLite — production database
# DB_PATH=E:/absolute/path/to/backend-nest/data/app.db
# JWT — must match legacy Fastify when migrating existing tokens
JWT_SECRET=gold-land-dev-secret-change-in-production
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
data/
.env
*.db
+41
View File
@@ -0,0 +1,41 @@
# Child Gold Land — Nest API (primary backend)
NestJS API for Child Gold Land (primary backend since G2).
Legacy Fastify `backend/` was removed in G4 (2026-08-01).
## Requirements
- Node.js **22+** (`nvm use 22.12.0`)
## Configuration
Copy `.env.example` to `.env` or use the checked-in local `.env` (gitignored):
| Variable | Production cutover |
|----------|-------------------|
| `PORT` | `3000` |
| `DB_PATH` | `backend-nest/data/app.db` |
| `JWT_SECRET` | Same as legacy Fastify |
| `CORS_ORIGIN` | `http://localhost:5173` |
## Development
```powershell
npm run dev
```
## Production
```powershell
npm run build
npm run start
```
## Tests
```powershell
npm run type-check
npm run test:progression
npm run test:integration
```
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"tsConfigPath": "tsconfig.build.json"
}
}
+4999
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "child-gold-land-backend-nest",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"start:prod": "node dist/main.js",
"type-check": "tsc --noEmit -p tsconfig.json",
"test:integration": "tsx test/integration.test.ts",
"test:progression": "tsx test/progression.test.ts",
"test:cutover:g1a": "tsx ../scripts/cutover/run-g1a.ts",
"test:cutover:g1b": "tsx ../scripts/cutover/run-g1b.ts",
"test:cutover:g1b2b": "tsx ../scripts/cutover/run-g1b2b.ts",
"test:cutover:g1b:dry-run": "tsx ../scripts/cutover/g1b-dry-run.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.12",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.12",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.12",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^12.2.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.6",
"@nestjs/testing": "^11.0.12",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24.13.3",
"tsx": "^4.20.3",
"typescript": "~5.8.3"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
@@ -0,0 +1,13 @@
import { Controller, Get, Inject } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { AchievementsService } from './achievements.service'
@Controller('api/achievements')
export class AchievementsController {
constructor(@Inject(AchievementsService) private readonly achievements: AchievementsService) {}
@Get()
getAchievements(@CurrentUser() userId: string) {
return this.achievements.getAchievements(userId)
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { UsersModule } from '../users/users.module'
import { AchievementsController } from './achievements.controller'
import { AchievementsService } from './achievements.service'
@Module({
imports: [UsersModule],
controllers: [AchievementsController],
providers: [AchievementsService],
})
export class AchievementsModule {}
+33
View File
@@ -0,0 +1,33 @@
import { Inject, Injectable } from '@nestjs/common'
import { ACHIEVEMENTS } from '../shared/catalog/achievements.constants'
import type { Achievement } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
export interface AchievementsResponse {
definitions: Array<{
id: string
title: string
description: string
icon: string
}>
unlocked: Achievement[]
}
@Injectable()
export class AchievementsService {
constructor(@Inject(UsersService) private readonly users: UsersService) {}
getAchievements(userId: string): AchievementsResponse {
const state = this.users.getUserState(userId)
return {
definitions: ACHIEVEMENTS.map(({ id, title, description, icon }) => ({
id,
title,
description,
icon,
})),
unlocked: state.achievements,
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { validateEnv } from './config/env.validation'
import { DatabaseModule } from './database/database.module'
import { HealthModule } from './health/health.module'
import { GoldLandModule } from './gold-land/gold-land.module'
import { MarketModule } from './market/market.module'
import { UsersModule } from './users/users.module'
import { ChallengesModule } from './challenges/challenges.module'
import { GamesModule } from './games/games.module'
import { StoreModule } from './store/store.module'
import { WalletModule } from './wallet/wallet.module'
import { AuthModule } from './auth/auth.module'
import { AchievementsModule } from './achievements/achievements.module'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
}),
DatabaseModule,
HealthModule,
GoldLandModule,
UsersModule,
WalletModule,
MarketModule,
StoreModule,
GamesModule,
ChallengesModule,
AchievementsModule,
AuthModule,
],
})
export class AppModule {}
+14
View File
@@ -0,0 +1,14 @@
import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common'
import { AuthService } from './auth.service'
@Injectable()
export class AuthSeedService implements OnModuleInit {
private readonly logger = new Logger(AuthSeedService.name)
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
onModuleInit(): void {
this.auth.seedDemoAccount()
this.logger.log('Auth seed completed (demo account if missing)')
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, HttpCode, Inject, Post, forwardRef } from '@nestjs/common'
import { Public } from '../common/decorators/public.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { RegisterDto } from './dto/register.dto'
@Controller('api/auth')
export class AuthController {
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
@Public()
@HttpCode(200)
@Post('register')
register(@Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
return this.auth.register(dto.username, dto.password, dto.name)
}
@Public()
@HttpCode(200)
@Post('login')
login(@Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
return this.auth.login(dto.username, dto.password)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { Module, forwardRef } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { APP_GUARD } from '@nestjs/core'
import { JwtModule } from '@nestjs/jwt'
import { UsersModule } from '../users/users.module'
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { AuthSeedService } from './auth-seed.service'
@Module({
imports: [
forwardRef(() => UsersModule),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
AuthSeedService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+205
View File
@@ -0,0 +1,205 @@
import { randomUUID } from 'node:crypto'
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
UnauthorizedException,
BadRequestException,
forwardRef,
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import bcrypt from 'bcryptjs'
import { DatabaseService } from '../database/database.service'
import { createDefaultUserState } from '../shared/catalog/default-user-state'
import type { AuthResponse } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
export interface AuthAccount {
id: string
username: string
passwordHash: string
userId: string
createdAt: string
}
@Injectable()
export class AuthService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(forwardRef(() => UsersService))
private readonly users: UsersService,
@Inject(JwtService) private readonly jwt: JwtService,
) {}
register(username: string, password: string, name?: string): AuthResponse {
const account = this.createAuthAccount(username, password, name ?? username)
return this.buildAuthResponse(account.userId, account.username)
}
login(username: string, password: string): AuthResponse {
const account = this.verifyLogin(username, password)
return this.buildAuthResponse(account.userId, account.username)
}
updatePassword(userId: string, currentPassword: string, newPassword: string): void {
const account = this.findAccountByUserId(userId)
if (!account) {
throw new NotFoundException('حساب کاربری پیدا نشد')
}
if (!bcrypt.compareSync(currentPassword, account.passwordHash)) {
throw new UnauthorizedException('رمز عبور فعلی اشتباه است')
}
if (newPassword.length < 6) {
throw new BadRequestException('رمز عبور جدید باید حداقل ۶ کاراکتر باشد')
}
this.database
.getDb()
.prepare('UPDATE auth_accounts SET password_hash = ? WHERE user_id = ?')
.run(bcrypt.hashSync(newPassword, 10), userId)
}
findAccountByUsername(username: string): AuthAccount | null {
const row = this.database
.getDb()
.prepare(
'SELECT id, username, password_hash, user_id, created_at FROM auth_accounts WHERE username = ?',
)
.get(username.trim().toLowerCase()) as
| {
id: string
username: string
password_hash: string
user_id: string
created_at: string
}
| undefined
return row ? this.rowToAccount(row) : null
}
findAccountByUserId(userId: string): AuthAccount | null {
const row = this.database
.getDb()
.prepare(
'SELECT id, username, password_hash, user_id, created_at FROM auth_accounts WHERE user_id = ?',
)
.get(userId) as
| {
id: string
username: string
password_hash: string
user_id: string
created_at: string
}
| undefined
return row ? this.rowToAccount(row) : null
}
seedDemoAccount(): void {
const demo = this.findAccountByUsername('demo')
if (demo) return
this.users.ensureUser('demo')
const account: AuthAccount = {
id: randomUUID(),
username: 'demo',
passwordHash: bcrypt.hashSync('123456', 10),
userId: 'demo',
createdAt: new Date().toISOString(),
}
this.database
.getDb()
.prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
)
.run(account.id, account.username, account.passwordHash, account.userId, account.createdAt)
}
private createAuthAccount(
username: string,
password: string,
displayName: string,
): AuthAccount {
const normalized = username.trim().toLowerCase()
if (normalized.length < 3) {
throw new BadRequestException('نام کاربری باید حداقل ۳ کاراکتر باشد')
}
if (password.length < 6) {
throw new BadRequestException('رمز عبور باید حداقل ۶ کاراکتر باشد')
}
if (this.findAccountByUsername(normalized)) {
throw new ConflictException('این نام کاربری قبلاً ثبت شده')
}
const userId = randomUUID()
const state = createDefaultUserState(userId)
state.name = displayName.trim() || username.trim()
const now = new Date().toISOString()
const account: AuthAccount = {
id: randomUUID(),
username: normalized,
passwordHash: bcrypt.hashSync(password, 10),
userId,
createdAt: now,
}
this.database.transaction(() => {
this.database
.getDb()
.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)')
.run(userId, JSON.stringify(state), now)
this.database
.getDb()
.prepare(
'INSERT INTO auth_accounts (id, username, password_hash, user_id, created_at) VALUES (?, ?, ?, ?, ?)',
)
.run(account.id, account.username, account.passwordHash, account.userId, account.createdAt)
})
return account
}
private verifyLogin(username: string, password: string): AuthAccount {
const account = this.findAccountByUsername(username)
if (!account || !bcrypt.compareSync(password, account.passwordHash)) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است')
}
this.users.ensureUser(account.userId)
return account
}
private buildAuthResponse(userId: string, username: string): AuthResponse {
const state = this.users.getUserState(userId)
return {
token: this.jwt.sign({ userId }),
user: {
id: state.id,
name: state.name,
username,
},
me: this.users.toMeResponse(state),
}
}
private rowToAccount(row: {
id: string
username: string
password_hash: string
user_id: string
created_at: string
}): AuthAccount {
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
userId: row.user_id,
createdAt: row.created_at,
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { IsString } from 'class-validator'
export class LoginDto {
@IsString()
username!: string
@IsString()
password!: string
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator'
export class RegisterDto {
@IsString()
username!: string
@IsString()
password!: string
@IsOptional()
@IsString()
name?: string
}
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Get, HttpCode, Inject, Post } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { ChallengesService } from './challenges.service'
import { CompleteDailyChallengeDto } from './dto/complete-daily-challenge.dto'
@Controller('api/challenges')
export class ChallengesController {
constructor(@Inject(ChallengesService) private readonly challenges: ChallengesService) {}
@Get('daily')
getDailyChallenge(@CurrentUser() userId: string) {
return this.challenges.getDailyChallenge(userId)
}
@HttpCode(200)
@Post('daily/complete')
completeDailyChallenge(
@CurrentUser() userId: string,
@Body(strictBodyPipe(CompleteDailyChallengeDto)) dto: CompleteDailyChallengeDto,
) {
return this.challenges.completeDailyChallenge(userId, dto.optionId)
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersModule } from '../users/users.module'
import { ChallengesController } from './challenges.controller'
import { ChallengesService } from './challenges.service'
@Module({
imports: [DatabaseModule, UsersModule],
controllers: [ChallengesController],
providers: [ChallengesService],
})
export class ChallengesModule {}
+68
View File
@@ -0,0 +1,68 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import {
CHALLENGE_OPTIONS,
DAILY_CHALLENGE,
type DailyChallengeCompleteResponse,
type DailyChallengeResponse,
} from '../shared/catalog/challenge.constants'
import { addTransaction, addXp, incrementMission } from '../shared/economy/user-economy.helpers'
import { UsersService } from '../users/users.service'
@Injectable()
export class ChallengesService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(UsersService) private readonly users: UsersService,
) {}
getDailyChallenge(userId: string): DailyChallengeResponse {
const state = this.users.getUserState(userId)
return {
...DAILY_CHALLENGE,
options: CHALLENGE_OPTIONS.map(({ id, label, icon, color }) => ({
id,
label,
icon,
color,
})),
completed: state.completedChallenges.includes(DAILY_CHALLENGE.id),
}
}
completeDailyChallenge(userId: string, optionId: string): DailyChallengeCompleteResponse {
const option = CHALLENGE_OPTIONS.find((o) => o.id === optionId)
if (!option) {
throw new BadRequestException('گزینه نامعتبر است')
}
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
if (state.completedChallenges.includes(DAILY_CHALLENGE.id)) {
return {
...this.users.toMeResponse(state),
feedback: option.feedback,
alreadyCompleted: true,
}
}
state.completedChallenges.push(DAILY_CHALLENGE.id)
state.coins += DAILY_CHALLENGE.coinReward
addXp(state, DAILY_CHALLENGE.xpReward)
incrementMission(state)
addTransaction(state, {
type: 'income',
title: 'چالش روزانه',
amount: DAILY_CHALLENGE.coinReward,
unit: 'سکه',
})
this.users.saveUserState(state)
return {
...this.users.toMeResponse(state),
feedback: option.feedback,
alreadyCompleted: false,
}
})
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator'
export class CompleteDailyChallengeDto {
@IsString()
@IsNotEmpty()
optionId!: string
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import type { AuthenticatedRequest } from '../types/authenticated-request'
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>()
return request.userId
},
)
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common'
export const IS_PUBLIC_KEY = 'isPublic'
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)
@@ -0,0 +1,61 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common'
import type { FastifyReply } from 'fastify'
interface ApiErrorBody {
error: string
}
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name)
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp()
const reply = ctx.getResponse<FastifyReply>()
if (exception instanceof HttpException) {
const status = exception.getStatus()
const message = this.extractMessage(exception)
reply.status(status).send({ error: message } satisfies ApiErrorBody)
return
}
this.logger.error(exception)
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: 'خطای سرور',
} satisfies ApiErrorBody)
}
private extractMessage(exception: HttpException): string {
const response = exception.getResponse()
if (typeof response === 'string') {
return response
}
if (typeof response === 'object' && response !== null) {
const body = response as Record<string, unknown>
if (typeof body.message === 'string') {
return body.message
}
if (Array.isArray(body.message) && body.message.length > 0) {
return body.message.map(String).join('؛ ')
}
if (typeof body.error === 'string') {
return body.error
}
}
return 'درخواست نامعتبر است'
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
forwardRef,
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { JwtService } from '@nestjs/jwt'
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'
import type { AuthenticatedRequest } from '../types/authenticated-request'
import { UsersService } from '../../users/users.service'
interface JwtPayload {
userId: string
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
@Inject(JwtService) private readonly jwt: JwtService,
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(forwardRef(() => UsersService))
private readonly users: UsersService,
) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
])
if (isPublic) return true
const request = context.switchToHttp().getRequest<AuthenticatedRequest>()
const authHeader = request.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('لطفاً وارد شوید')
}
try {
const token = authHeader.slice('Bearer '.length)
const payload = this.jwt.verify<JwtPayload>(token)
if (!payload?.userId || typeof payload.userId !== 'string') {
throw new Error('invalid payload')
}
this.users.ensureUser(payload.userId)
request.userId = payload.userId
return true
} catch {
throw new UnauthorizedException('نشست نامعتبر است. دوباره وارد شوید')
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { ValidationPipe, type Type } from '@nestjs/common'
export function strictBodyPipe(dto: Type<unknown>) {
return new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType: dto,
})
}
@@ -0,0 +1,5 @@
import type { FastifyRequest } from 'fastify'
export interface AuthenticatedRequest extends FastifyRequest {
userId: string
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { ConfigModule as NestConfigModule } from '@nestjs/config'
import { validateEnv } from './env.validation'
@Module({
imports: [
NestConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
}),
],
})
export class AppConfigModule {}
+60
View File
@@ -0,0 +1,60 @@
import { plainToInstance } from 'class-transformer'
import { IsIn, IsInt, IsOptional, IsString, Min, validateSync } from 'class-validator'
export class EnvironmentVariables {
@IsIn(['development', 'production', 'test'])
NODE_ENV!: 'development' | 'production' | 'test'
@IsInt()
@Min(1)
PORT!: number
@IsString()
HOST!: string
@IsString()
CORS_ORIGIN!: string
@IsOptional()
@IsString()
DB_PATH?: string
@IsOptional()
@IsString()
JWT_SECRET?: string
}
export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
const nodeEnv = (config.NODE_ENV as string | undefined) ?? 'development'
const jwtSecret = config.JWT_SECRET as string | undefined
if (nodeEnv === 'production' && (!jwtSecret || jwtSecret.trim().length === 0)) {
throw new Error('JWT_SECRET is required when NODE_ENV=production')
}
if (nodeEnv !== 'production' && !jwtSecret) {
console.warn(
'[config] JWT_SECRET is not set — using development fallback. Do not use in production.',
)
}
const normalized = {
NODE_ENV: nodeEnv,
PORT: Number(config.PORT ?? 3001),
HOST: (config.HOST as string | undefined) ?? '0.0.0.0',
CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5173',
DB_PATH: config.DB_PATH as string | undefined,
JWT_SECRET: jwtSecret ?? 'gold-land-dev-secret-change-in-production',
}
const validated = plainToInstance(EnvironmentVariables, normalized, {
enableImplicitConversion: true,
})
const errors = validateSync(validated, { skipMissingProperties: false })
if (errors.length > 0) {
throw new Error(errors.toString())
}
return validated
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { DatabaseService } from './database.service'
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
+55
View File
@@ -0,0 +1,55 @@
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Database from 'better-sqlite3'
import { mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { EXPECTED_TABLES, listUserTables, runSchemaMigrations } from './migration.service'
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DatabaseService.name)
private db: Database.Database | null = null
private readonly dbPath: string
constructor(@Optional() @Inject(ConfigService) private readonly config?: ConfigService) {
const configured = this.config?.get<string>('DB_PATH') ?? process.env.DB_PATH
this.dbPath = configured ?? join(process.cwd(), 'data', 'app.db')
}
onModuleInit(): void {
mkdirSync(dirname(this.dbPath), { recursive: true })
this.db = new Database(this.dbPath)
this.db.pragma('journal_mode = WAL')
runSchemaMigrations(this.db)
this.logger.log(`SQLite ready at ${this.dbPath}`)
}
onModuleDestroy(): void {
if (this.db) {
this.db.close()
this.db = null
this.logger.log('SQLite connection closed')
}
}
getDbPath(): string {
return this.dbPath
}
getDb(): Database.Database {
if (!this.db) {
throw new Error('Database is not initialized')
}
return this.db
}
transaction<T>(fn: () => T): T {
return this.getDb().transaction(fn)()
}
verifyMigrationsApplied(): { ok: boolean; tables: string[]; missing: string[] } {
const tables = listUserTables(this.getDb())
const missing = EXPECTED_TABLES.filter((name) => !tables.includes(name))
return { ok: missing.length === 0, tables, missing }
}
}
+1
View File
@@ -0,0 +1 @@
export const DATABASE_PATH = Symbol('DATABASE_PATH')
+65
View File
@@ -0,0 +1,65 @@
import type Database from 'better-sqlite3'
/** Idempotent schema setup — mirrors backend/db.ts + backend/migrations.ts (no destructive ops). */
export function runSchemaMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_level_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
level_id INTEGER NOT NULL,
best_score INTEGER NOT NULL DEFAULT 0,
stars INTEGER NOT NULL DEFAULT 0,
attempts_count INTEGER NOT NULL DEFAULT 0,
started_at TEXT,
completed_at TEXT,
updated_at TEXT NOT NULL,
UNIQUE(user_id, level_id)
);
CREATE TABLE IF NOT EXISTS reward_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_type TEXT NOT NULL,
source_id TEXT NOT NULL,
reward_key TEXT NOT NULL,
xp_delta INTEGER NOT NULL DEFAULT 0,
coins_delta INTEGER NOT NULL DEFAULT 0,
metadata_json TEXT,
created_at TEXT NOT NULL,
UNIQUE(user_id, reward_key)
);
CREATE INDEX IF NOT EXISTS idx_user_level_progress_user
ON user_level_progress(user_id);
CREATE INDEX IF NOT EXISTS idx_reward_ledger_user
ON reward_ledger(user_id);
CREATE TABLE IF NOT EXISTS auth_accounts (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL
);
`)
}
export const EXPECTED_TABLES = [
'users',
'user_level_progress',
'reward_ledger',
'auth_accounts',
] as const
export function listUserTables(db: Database.Database): string[] {
const rows = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
.all() as Array<{ name: string }>
return rows.map((row) => row.name)
}
+1
View File
@@ -0,0 +1 @@
export class CompleteGameDto {}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, HttpCode, Inject, Param, Post } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { CompleteGameDto } from './dto/complete-game.dto'
import { GamesService } from './games.service'
@Controller('api/games')
export class GamesController {
constructor(@Inject(GamesService) private readonly games: GamesService) {}
@Get()
getGames() {
return this.games.getGames()
}
@HttpCode(200)
@Post(':gameId/complete')
completeGame(
@CurrentUser() userId: string,
@Param('gameId') gameId: string,
@Body(strictBodyPipe(CompleteGameDto)) _dto: CompleteGameDto,
) {
return this.games.completeGame(userId, gameId)
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersModule } from '../users/users.module'
import { GamesController } from './games.controller'
import { GamesService } from './games.service'
@Module({
imports: [DatabaseModule, UsersModule],
controllers: [GamesController],
providers: [GamesService],
})
export class GamesModule {}
+57
View File
@@ -0,0 +1,57 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import { GAMES, type GameDefinition } from '../shared/catalog/games.constants'
import { addTransaction, addXp } from '../shared/economy/user-economy.helpers'
import type { MeResponse } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
@Injectable()
export class GamesService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(UsersService) private readonly users: UsersService,
) {}
getGames(): GameDefinition[] {
return GAMES
}
completeGame(userId: string, gameId: string): MeResponse {
const game = GAMES.find((g) => g.id === gameId)
if (!game) {
throw new NotFoundException('بازی پیدا نشد')
}
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
if (state.claimedRewards.includes(gameId)) {
return this.users.toMeResponse(state)
}
state.claimedRewards.push(gameId)
if (!state.completedGames.includes(gameId)) {
state.completedGames.push(gameId)
}
const rewardCoins = 'rewardCoins' in game ? game.rewardCoins ?? 0 : 0
const rewardXp = 'rewardXp' in game ? game.rewardXp ?? 0 : 0
if (rewardCoins > 0) {
state.coins += rewardCoins
addTransaction(state, {
type: 'income',
title: 'پاداش بازی',
amount: rewardCoins,
unit: 'سکه',
})
}
if (rewardXp > 0) {
addXp(state, rewardXp)
}
this.users.saveUserState(state)
return this.users.toMeResponse(state)
})
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsNumber,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator'
export class LessonAnswerDto {
@IsString()
questionId!: string
@IsOptional()
@IsString()
answerId?: string
@IsOptional()
@IsArray()
@IsString({ each: true })
orderedItemIds?: string[]
}
export class CompleteLevelDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LessonAnswerDto)
answers!: LessonAnswerDto[]
@IsOptional()
@IsNumber()
@Min(0)
durationMs?: number
}
@@ -0,0 +1,10 @@
import { HttpException } from '@nestjs/common'
export class ProgressionError extends HttpException {
readonly statusCode: number
constructor(statusCode: number, message: string) {
super(message, statusCode)
this.statusCode = statusCode
}
}
+87
View File
@@ -0,0 +1,87 @@
import {
Controller,
Get,
HttpCode,
Inject,
Param,
ParseIntPipe,
Post,
Body,
forwardRef,
} from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { GOLD_LAND_LEVELS } from './levels/levels'
import { CompleteLevelDto } from './dto/complete-level.dto'
import type { LessonSubmissionAnswer } from './lessons/lesson.types'
import { GoldLandProgressionService } from './services/progression.service'
@Controller('api/gold-land')
export class GoldLandController {
constructor(
@Inject(forwardRef(() => GoldLandProgressionService))
private readonly progression: GoldLandProgressionService,
) {}
@Get('levels')
getLevels() {
return {
levels: GOLD_LAND_LEVELS.map(
({
id,
order,
slug,
title,
description,
activityType,
activityKey,
activityRoute,
rewardXp,
rewardCoins,
isActive,
}) => ({
id,
order,
slug,
title,
description,
activityType,
activityKey,
activityRoute,
rewardXp,
rewardCoins,
isActive,
}),
),
}
}
@Get('progress')
getProgress(@CurrentUser() userId: string) {
return this.progression.getGoldLandProgress(userId)
}
@Get('levels/:levelId')
getLevel(@CurrentUser() userId: string, @Param('levelId', ParseIntPipe) levelId: number) {
return this.progression.getGoldLandLevel(userId, levelId)
}
@HttpCode(200)
@Post('levels/:levelId/start')
startLevel(@CurrentUser() userId: string, @Param('levelId', ParseIntPipe) levelId: number) {
return this.progression.startLevel(userId, levelId)
}
@HttpCode(200)
@Post('levels/:levelId/complete')
completeLevel(
@CurrentUser() userId: string,
@Param('levelId', ParseIntPipe) levelId: number,
@Body(strictBodyPipe(CompleteLevelDto)) dto: CompleteLevelDto,
) {
return this.progression.completeLevel(userId, levelId, {
answers: dto.answers as LessonSubmissionAnswer[],
durationMs: dto.durationMs,
})
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module, forwardRef } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersModule } from '../users/users.module'
import { GoldLandController } from './gold-land.controller'
import { GoldLandProgressionService } from './services/progression.service'
import { RewardService } from './services/reward.service'
@Module({
imports: [DatabaseModule, forwardRef(() => UsersModule)],
controllers: [GoldLandController],
providers: [GoldLandProgressionService, RewardService],
exports: [GoldLandProgressionService],
})
export class GoldLandModule {}
@@ -0,0 +1,73 @@
import type { LessonDefinition } from './lesson.types.js'
export const goldMarketLesson: LessonDefinition = {
id: 'gold-market',
version: 1,
title: 'بازار طلا',
steps: [
{
id: 'market-story',
type: 'story',
title: 'نوسان بازار',
body: 'امروز قیمت طلا بالا رفت. دیروز پایین بود. علی می‌خواست بدون فکر همهٔ پس‌اندازش را بخرند. دوستش گفت: «اول ببین و مقایسه کن!»',
imageKey: 'gold-chart',
},
{
id: 'market-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'در بازار طلا چه اتفاقی می‌افتد؟',
options: [
{ id: 'option-a', label: 'قیمت بالا و پایین می‌شود' },
{ id: 'option-b', label: 'قیمت همیشه ثابت است' },
{ id: 'option-c', label: 'فقط یک بار در سال تغییر می‌کند' },
],
correctAnswerId: 'option-a',
},
{
id: 'market-q2',
type: 'true_false',
title: 'سؤال ۲',
question: 'اگر دیروز قیمت بالا رفته، لزوماً فردا هم بالا می‌رود.',
correctAnswerId: 'false',
},
{
id: 'market-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'چرا تصمیم عجولانه می‌تواند اشتباه باشد؟',
options: [
{ id: 'option-a', label: 'چون ممکن است وقت کافی برای فکر کردن نداشته باشیم' },
{ id: 'option-b', label: 'چون بازار همیشه بسته است' },
{ id: 'option-c', label: 'چون طلا رنگ ندارد' },
],
correctAnswerId: 'option-a',
},
{
id: 'market-q4',
type: 'true_false',
title: 'سؤال ۴',
question: 'قبل از خرید، مقایسه و مشاهدهٔ قیمت‌ها مفید است.',
correctAnswerId: 'true',
},
{
id: 'market-q5',
type: 'single_choice',
title: 'سؤال ۵',
question: 'بهترین رویکرد برای سرمایه‌گذاری چیست؟',
options: [
{ id: 'option-a', label: 'همهٔ پول را بدون فکر در یک جا گذاشتن' },
{ id: 'option-b', label: 'فکر کردن و پراکنده نکردن تمام پول در یک دارایی' },
{ id: 'option-c', label: 'هرگز هیچ چیز نخریدن' },
],
correctAnswerId: 'option-b',
},
{
id: 'market-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'بازار طلا تغییر می‌کند. با آرامش نگاه کن، مقایسه کن و عجله نکن.',
highlight: 'رشد گذشته، آینده را تضمین نمی‌کند.',
},
],
}
+73
View File
@@ -0,0 +1,73 @@
import type { LessonDefinition } from './lesson.types.js'
export const inflationLesson: LessonDefinition = {
id: 'inflation',
version: 2,
title: 'تورم',
steps: [
{
id: 'inflation-story',
type: 'story',
title: 'قیمت‌ها بالا می‌روند',
body: 'تورم یعنی قیمت چیزها با گذشت زمان بیشتر می‌شود و پول ما ارزشش را از دست می‌دهد.',
imageKey: 'shopping-cart',
},
{
id: 'inflation-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'تورم یعنی چه؟',
options: [
{ id: 'option-a', label: 'قیمت‌ها با گذشت زمان کمتر می‌شوند' },
{ id: 'option-b', label: 'افزایش عمومی قیمت‌ها در طول زمان' },
{ id: 'option-c', label: 'پول همیشه ارزش ثابت دارد' },
],
correctAnswerId: 'option-b',
},
{
id: 'inflation-q2',
type: 'true_false',
title: 'سؤال ۲',
question: 'در تورم، با همان مقدار پول می‌توان کالاهای کمتری خرید.',
correctAnswerId: 'true',
},
{
id: 'inflation-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'اگر دیروز ۱۰۰٬۰۰۰ تومان کافی بود و امروز ۱۱۲٬۰۰۰ تومان لازم است، چه اتفاقی افتاده؟',
options: [
{ id: 'option-a', label: 'قدرت خرید پول کم شده' },
{ id: 'option-b', label: 'قیمت‌ها کاهش یافته' },
{ id: 'option-c', label: 'تورم رخ نداده' },
],
correctAnswerId: 'option-a',
},
{
id: 'inflation-q4',
type: 'true_false',
title: 'سؤال ۴',
question: 'گران شدن فقط یک کالا همیشه به معنی تورم است.',
correctAnswerId: 'false',
},
{
id: 'inflation-q5',
type: 'single_choice',
title: 'سؤال ۵',
question: 'پس‌انداز بدون برنامه در برابر تورم چه می‌شود؟',
options: [
{ id: 'option-a', label: 'همیشه ارزشش بیشتر می‌شود' },
{ id: 'option-b', label: 'ممکن است ارزش واقعی‌اش کم شود' },
{ id: 'option-c', label: 'تغییری نمی‌کند' },
],
correctAnswerId: 'option-b',
},
{
id: 'inflation-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'تورم باعث می‌شود پول تو کم‌کم قدرت خریدش را از دست بدهد.',
highlight: 'همان مقدار پول، در آینده ممکن است کالای کمتری بخرد.',
},
],
}
+146
View File
@@ -0,0 +1,146 @@
import type {
GradeLessonResult,
GradableQuestion,
LessonDefinition,
LessonStep,
LessonSubmissionAnswer,
SortStep,
SingleChoiceStep,
TrueFalseStep,
} from './lesson.types.js'
import { isChoiceLessonAnswer, isSortLessonAnswer } from './lesson.types.js'
export class LessonGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
function isGradableStep(step: LessonStep): step is SingleChoiceStep | TrueFalseStep | SortStep {
return step.type === 'single_choice' || step.type === 'true_false' || step.type === 'sort'
}
export function getGradableQuestions(lesson: LessonDefinition): GradableQuestion[] {
return lesson.steps
.filter(isGradableStep)
.map((step) => ({ questionId: step.id, step }))
}
function validateChoiceAnswer(
step: SingleChoiceStep | TrueFalseStep,
answerId: string,
): void {
if (step.type === 'single_choice') {
if (!step.options.some((o) => o.id === answerId)) {
throw new LessonGradingError(400, `گزینهٔ نامعتبر برای سؤال ${step.id}`)
}
return
}
if (answerId !== 'true' && answerId !== 'false') {
throw new LessonGradingError(400, `پاسخ true/false نامعتبر برای سؤال ${step.id}`)
}
}
function validateSortAnswer(step: SortStep, orderedItemIds: string[]): void {
if (!Array.isArray(orderedItemIds)) {
throw new LessonGradingError(400, `ترتیب نامعتبر برای سؤال ${step.id}`)
}
const validIds = new Set(step.items.map((i) => i.id))
if (orderedItemIds.length !== step.items.length) {
throw new LessonGradingError(400, `تعداد آیتم‌های sort برای سؤال ${step.id} نادرست است`)
}
const seen = new Set<string>()
for (const id of orderedItemIds) {
if (typeof id !== 'string' || !validIds.has(id)) {
throw new LessonGradingError(400, `آیتم sort نامعتبر برای سؤال ${step.id}`)
}
if (seen.has(id)) {
throw new LessonGradingError(400, `آیتم تکراری در sort سؤال ${step.id}`)
}
seen.add(id)
}
}
function isAnswerCorrect(
step: SingleChoiceStep | TrueFalseStep | SortStep,
answer: LessonSubmissionAnswer,
): boolean {
if (step.type === 'sort') {
if (!isSortLessonAnswer(answer)) return false
return answer.orderedItemIds.join(',') === step.correctOrder.join(',')
}
if (!isChoiceLessonAnswer(answer)) return false
return answer.answerId === step.correctAnswerId
}
export function validateDurationMs(durationMs: unknown): void {
if (durationMs === undefined || durationMs === null) return
if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) {
throw new LessonGradingError(400, 'durationMs نامعتبر است')
}
}
export function gradeLessonSubmission(
lesson: LessonDefinition,
answers: LessonSubmissionAnswer[],
): GradeLessonResult {
const gradable = getGradableQuestions(lesson)
if (gradable.length === 0) {
throw new LessonGradingError(400, 'این درس سؤال قابل ارزیابی ندارد')
}
if (!Array.isArray(answers)) {
throw new LessonGradingError(400, 'فرمت پاسخ‌ها نامعتبر است')
}
const byQuestion = new Map<string, LessonSubmissionAnswer>()
for (const answer of answers) {
if (!answer?.questionId || typeof answer.questionId !== 'string') {
throw new LessonGradingError(400, 'questionId نامعتبر است')
}
if (byQuestion.has(answer.questionId)) {
throw new LessonGradingError(400, `سؤال ${answer.questionId} تکراری است`)
}
byQuestion.set(answer.questionId, answer)
}
let correctCount = 0
for (const { questionId, step } of gradable) {
const submitted = byQuestion.get(questionId)
if (!submitted) {
throw new LessonGradingError(400, `پاسخ سؤال ${questionId} ارسال نشده`)
}
if (step.type === 'sort') {
if (!isSortLessonAnswer(submitted)) {
throw new LessonGradingError(400, `فرمت sort برای سؤال ${questionId} نامعتبر است`)
}
validateSortAnswer(step, submitted.orderedItemIds)
} else {
if (!isChoiceLessonAnswer(submitted)) {
throw new LessonGradingError(400, `فرمت پاسخ برای سؤال ${questionId} نامعتبر است`)
}
validateChoiceAnswer(step, submitted.answerId)
}
if (isAnswerCorrect(step, submitted)) {
correctCount += 1
}
}
for (const answer of answers) {
const known = gradable.some((g) => g.questionId === answer.questionId)
if (!known) {
throw new LessonGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
}
const totalQuestions = gradable.length
const score = Math.round((correctCount / totalQuestions) * 100)
return { score, correctCount, totalQuestions }
}
+6
View File
@@ -0,0 +1,6 @@
import { getPlayableLesson } from './lesson.registry.js'
import type { LessonDefinition } from './lesson.types.js'
export function loadLessonForLevel(activityKey: string): LessonDefinition | null {
return getPlayableLesson(activityKey)
}
+39
View File
@@ -0,0 +1,39 @@
import type { LessonDefinition } from './lesson.types.js'
import { inflationLesson } from './inflation.lesson.js'
import { whatIsMoneyLesson } from './what-is-money.lesson.js'
import { savingLesson } from './saving.lesson.js'
import { needsAndWantsLesson } from './needs-and-wants.lesson.js'
import { whatIsGoldLesson } from './what-is-gold.lesson.js'
import { goldMarketLesson } from './gold-market.lesson.js'
import { getGradableQuestions } from './lesson.grader.js'
const PLAYABLE_LESSONS: Record<string, LessonDefinition> = {
'what-is-money': whatIsMoneyLesson,
saving: savingLesson,
'needs-and-wants': needsAndWantsLesson,
'what-is-gold': whatIsGoldLesson,
'gold-market': goldMarketLesson,
inflation: inflationLesson,
}
export function getPlayableLesson(activityKey: string): LessonDefinition | null {
return PLAYABLE_LESSONS[activityKey] ?? null
}
export function hasPlayableLesson(activityKey: string): boolean {
return activityKey in PLAYABLE_LESSONS
}
export function getAllPlayableLessons(): LessonDefinition[] {
return Object.values(PLAYABLE_LESSONS)
}
export function countGradableQuestions(lesson: LessonDefinition): number {
return getGradableQuestions(lesson).length
}
export function getLessonMetadata(activityKey: string): Pick<LessonDefinition, 'id' | 'version' | 'title'> | null {
const lesson = PLAYABLE_LESSONS[activityKey]
if (!lesson) return null
return { id: lesson.id, version: lesson.version, title: lesson.title }
}
+48
View File
@@ -0,0 +1,48 @@
import type {
LessonDefinition,
LessonStep,
SanitizedLessonDefinition,
SanitizedLessonStep,
} from './lesson.types.js'
const STRIP_KEYS = new Set([
'correctAnswerId',
'correctAnswer',
'correctOption',
'answerKey',
'correctOrder',
])
function sanitizeStep(step: LessonStep): SanitizedLessonStep {
const copy = { ...step } as Record<string, unknown>
for (const key of STRIP_KEYS) {
delete copy[key]
}
return copy as SanitizedLessonStep
}
export function sanitizeLesson(lesson: LessonDefinition): SanitizedLessonDefinition {
return {
id: lesson.id,
version: lesson.version,
title: lesson.title,
steps: lesson.steps.map(sanitizeStep),
}
}
/** حذف کلیدهای حساس از هر آبجکت — برای اطمینان در response */
export function stripSensitiveFields<T>(value: T): T {
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) {
return value.map((item) => stripSensitiveFields(item)) as T
}
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (STRIP_KEYS.has(key)) continue
if (['score', 'stars', 'rewardXp', 'rewardCoins'].includes(key)) continue
result[key] = stripSensitiveFields(val)
}
return result as T
}
+109
View File
@@ -0,0 +1,109 @@
export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary'
export interface LessonOption {
id: string
label: string
}
export interface LessonStepBase {
id: string
type: LessonStepType
title?: string
}
export interface StoryStep extends LessonStepBase {
type: 'story'
body: string
imageKey?: string
}
export interface SingleChoiceStep extends LessonStepBase {
type: 'single_choice'
question: string
options: LessonOption[]
correctAnswerId: string
}
export interface TrueFalseStep extends LessonStepBase {
type: 'true_false'
question: string
correctAnswerId: 'true' | 'false'
}
export interface SortStep extends LessonStepBase {
type: 'sort'
question: string
items: LessonOption[]
correctOrder: string[]
}
export interface SummaryStep extends LessonStepBase {
type: 'summary'
body: string
highlight?: string
}
export type LessonStep =
| StoryStep
| SingleChoiceStep
| TrueFalseStep
| SortStep
| SummaryStep
export interface LessonDefinition {
id: string
version: number
title: string
steps: LessonStep[]
}
export interface GradableQuestion {
questionId: string
step: SingleChoiceStep | TrueFalseStep | SortStep
}
/** پاسخ single_choice / true_false */
export interface ChoiceLessonAnswer {
questionId: string
answerId: string
}
/** پاسخ sort */
export interface SortLessonAnswer {
questionId: string
orderedItemIds: string[]
}
export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer
export interface GradeLessonResult {
score: number
correctCount: number
totalQuestions: number
}
export type SanitizedLessonStep =
| Omit<StoryStep, never>
| Omit<SingleChoiceStep, 'correctAnswerId'>
| Omit<TrueFalseStep, 'correctAnswerId'>
| Omit<SortStep, 'correctOrder'>
| Omit<SummaryStep, never>
export interface SanitizedLessonDefinition {
id: string
version: number
title: string
steps: SanitizedLessonStep[]
}
export function isSortLessonAnswer(
answer: LessonSubmissionAnswer,
): answer is SortLessonAnswer {
return 'orderedItemIds' in answer && Array.isArray(answer.orderedItemIds)
}
export function isChoiceLessonAnswer(
answer: LessonSubmissionAnswer,
): answer is ChoiceLessonAnswer {
return 'answerId' in answer && typeof answer.answerId === 'string'
}
@@ -0,0 +1,76 @@
import type { LessonDefinition } from './lesson.types.js'
export const needsAndWantsLesson: LessonDefinition = {
id: 'needs-and-wants',
version: 1,
title: 'نیاز و خواسته',
steps: [
{
id: 'needs-story',
type: 'story',
title: 'بودجهٔ محدود',
body: 'تو ۱۰ سکه داری. هم آب و غذا لازم داری، هم یک اسباب‌بازی جذاب دیده‌ای. همهٔ چیزها یک‌اندازه مهم نیستند!',
imageKey: 'cart',
},
{
id: 'needs-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: '«نیاز» یعنی چه؟',
options: [
{ id: 'option-a', label: 'چیزی که برای زندگی سالم لازم است' },
{ id: 'option-b', label: 'هر چیزی که دوست داریم داشته باشیم' },
{ id: 'option-c', label: 'فقط چیزهای گران' },
],
correctAnswerId: 'option-a',
},
{
id: 'needs-q2',
type: 'true_false',
title: 'سؤال ۲',
question: 'خواسته‌ها بد هستند، ولی باید بعد از نیازها به آن‌ها فکر کنیم.',
correctAnswerId: 'true',
},
{
id: 'needs-q3',
type: 'sort',
title: 'سؤال ۳',
question: 'از مهم‌ترین (بالا) به کم‌اهمیت‌تر (پایین) مرتب کن:',
items: [
{ id: 'water', label: 'آب آشامیدنی' },
{ id: 'medicine', label: 'داروی ضروری' },
{ id: 'shoes', label: 'کفش مناسب' },
{ id: 'toy', label: 'اسباب‌بازی' },
{ id: 'crown', label: 'تاج تزئینی' },
{ id: 'food', label: 'خوراکی ضروری' },
],
correctOrder: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'],
},
{
id: 'needs-q4',
type: 'single_choice',
title: 'سؤال ۴',
question: 'با بودجهٔ محدود اول باید چه کار کنیم؟',
options: [
{ id: 'option-a', label: 'اول نیازها را برطرف کنیم' },
{ id: 'option-b', label: 'اول گران‌ترین خواسته را بخریم' },
{ id: 'option-c', label: 'همهٔ سکه‌ها را یک‌جا خرج کنیم' },
],
correctAnswerId: 'option-a',
},
{
id: 'needs-q5',
type: 'true_false',
title: 'سؤال ۵',
question: 'تاج تزئینی معمولاً یک خواسته است، نه یک نیاز.',
correctAnswerId: 'true',
},
{
id: 'needs-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'نیازها اولویت دارند. خواسته‌ها هم خوبند، ولی وقتی بودجه کافی داری!',
highlight: 'اول نیاز، بعد خواسته.',
},
],
}
+73
View File
@@ -0,0 +1,73 @@
import type { LessonDefinition } from './lesson.types.js'
export const savingLesson: LessonDefinition = {
id: 'saving',
version: 1,
title: 'پس‌انداز',
steps: [
{
id: 'saving-story',
type: 'story',
title: 'رویا و دوچرخه',
body: 'سارا برای دوچرخهٔ جدید ۵۰ سکه لازم داشت. همهٔ سکه‌هایش را برای خوراکی و اسباب‌بازی خرج کرد و برای دوچرخه چیزی نماند.',
imageKey: 'piggy',
},
{
id: 'saving-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'پس‌انداز یعنی چه؟',
options: [
{ id: 'option-a', label: 'همهٔ پول را یک‌جا خرج کردن' },
{ id: 'option-b', label: 'بخشی از پول را برای بعد کنار گذاشتن' },
{ id: 'option-c', label: 'پول را به دیگران هدیه دادن' },
],
correctAnswerId: 'option-b',
},
{
id: 'saving-q2',
type: 'true_false',
title: 'سؤال ۲',
question: 'داشتن یک هدف مالی به ما کمک می‌کند بهتر پس‌انداز کنیم.',
correctAnswerId: 'true',
},
{
id: 'saving-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'سارا چه اشتباهی کرد؟',
options: [
{ id: 'option-a', label: 'برای هدفش برنامه نداشت و همه را خرج کرد' },
{ id: 'option-b', label: 'دوچرخه را خیلی زود خرید' },
{ id: 'option-c', label: 'اصلاً پول دریافت نکرد' },
],
correctAnswerId: 'option-a',
},
{
id: 'saving-q4',
type: 'true_false',
title: 'سؤال ۴',
question: 'حتی با مبلغ کم هم می‌توان به‌صورت منظم پس‌انداز کرد.',
correctAnswerId: 'true',
},
{
id: 'saving-q5',
type: 'single_choice',
title: 'سؤال ۵',
question: 'تفاوت «خرج امروز» و «هدف آینده» چیست؟',
options: [
{ id: 'option-a', label: 'هدف آینده چیزی است که برایش برنامه می‌ریزیم' },
{ id: 'option-b', label: 'هیچ تفاوتی ندارند' },
{ id: 'option-c', label: 'خرج امروز همیشه مهم‌تر از هدف است' },
],
correctAnswerId: 'option-a',
},
{
id: 'saving-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'با هدف مشخص و پس‌انداز کوچک و منظم، می‌توانی به آرزوهایت برسی.',
highlight: 'اول نیازها و هدف، بعد خواسته‌های کوچک!',
},
],
}
@@ -0,0 +1,73 @@
import type { LessonDefinition } from './lesson.types.js'
export const whatIsGoldLesson: LessonDefinition = {
id: 'what-is-gold',
version: 1,
title: 'طلا چیه؟',
steps: [
{
id: 'gold-story',
type: 'story',
title: 'فلز درخشان',
body: 'طلا فلزی کمیاب است که سال‌ها در جواهرات، سکه و صنعت استفاده شده. براق است و زنگ نمی‌زند.',
imageKey: 'gold-bar',
},
{
id: 'gold-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'چرا طلا با گرم اندازه‌گیری می‌شود؟',
options: [
{ id: 'option-a', label: 'چون واحد استاندارد وزن طلا است' },
{ id: 'option-b', label: 'چون طلا همیشه مربع است' },
{ id: 'option-c', label: 'چون فقط در ایران طلا داریم' },
],
correctAnswerId: 'option-a',
},
{
id: 'gold-q2',
type: 'true_false',
title: 'سؤال ۲',
question: 'طلا کمیاب و بادوام است.',
correctAnswerId: 'true',
},
{
id: 'gold-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'کدام مورد یک کاربرد طلا است؟',
options: [
{ id: 'option-a', label: 'زیورآلات و صنعت' },
{ id: 'option-b', label: 'آبیاری مزارع بدون آب' },
{ id: 'option-c', label: 'جایگزین خواب شب' },
],
correctAnswerId: 'option-a',
},
{
id: 'gold-q4',
type: 'true_false',
title: 'سؤال ۴',
question: 'قیمت طلا همیشه ثابت می‌ماند و هرگز تغییر نمی‌کند.',
correctAnswerId: 'false',
},
{
id: 'gold-q5',
type: 'single_choice',
title: 'سؤال ۵',
question: 'کدام جمله دربارهٔ طلا درست‌تر است؟',
options: [
{ id: 'option-a', label: 'طلا همیشه سود قطعی می‌دهد' },
{ id: 'option-b', label: 'طلا دارایی با ارزش است که قیمتش می‌تواند تغییر کند' },
{ id: 'option-c', label: 'طلا فقط برای بازی است' },
],
correctAnswerId: 'option-b',
},
{
id: 'gold-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'طلا فلزی باارزش و شناخته‌شده است، اما قیمتش ثابت نیست.',
highlight: 'طلا را بشناس، ولی فکر کن قیمتش همیشه یکسان نمی‌ماند.',
},
],
}
@@ -0,0 +1,73 @@
import type { LessonDefinition } from './lesson.types.js'
export const whatIsMoneyLesson: LessonDefinition = {
id: 'what-is-money',
version: 1,
title: 'پول چیه؟',
steps: [
{
id: 'money-story',
type: 'story',
title: 'نانوا و باغبان',
body: 'نانوا نان دارد ولی سیب می‌خواهد. باغبان سیب دارد ولی نان می‌خواهد. مبادله مستقیم همیشه آسان نیست!',
imageKey: 'coin',
},
{
id: 'money-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'پول بیشتر به ما در چه کاری کمک می‌کند؟',
options: [
{ id: 'option-a', label: 'مبادله آسان‌تر کالاها' },
{ id: 'option-b', label: 'پختن نان بدون آرد' },
{ id: 'option-c', label: 'رشد درخت بدون آب' },
],
correctAnswerId: 'option-a',
},
{
id: 'money-q2',
type: 'true_false',
title: 'سؤال ۲',
question: 'پول وسیله‌ای است که به ما کمک می‌کند ارزش چیزها را با هم مقایسه کنیم.',
correctAnswerId: 'true',
},
{
id: 'money-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'چرا مبادله کالا به کالا گاهی سخت است؟',
options: [
{ id: 'option-a', label: 'چون همه همیشه همان چیز را می‌خواهند' },
{ id: 'option-b', label: 'چون پول رنگ دارد' },
{ id: 'option-c', label: 'چون فقط بزرگسالان مبادله می‌کنند' },
],
correctAnswerId: 'option-a',
},
{
id: 'money-q4',
type: 'true_false',
title: 'سؤال ۴',
question: 'می‌توانیم بخشی از پول را برای آینده نگه داریم.',
correctAnswerId: 'true',
},
{
id: 'money-q5',
type: 'single_choice',
title: 'سؤال ۵',
question: 'کدام جمله دربارهٔ پول درست‌تر است؟',
options: [
{ id: 'option-a', label: 'پول فقط برای خرید اسباب‌بازی است' },
{ id: 'option-b', label: 'پول وسیلهٔ مبادله و نگه‌داری ارزش است' },
{ id: 'option-c', label: 'پول همیشه جایگزین کار سخت می‌شود' },
],
correctAnswerId: 'option-b',
},
{
id: 'money-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'پول به ما کمک می‌کند راحت‌تر خرید کنیم و برای آینده پس‌انداز کنیم.',
highlight: 'پول وسیلهٔ مبادله است، نه فقط یک برگه یا سکه!',
},
],
}
+75
View File
@@ -0,0 +1,75 @@
import type { MeResponse } from '../../shared/types/user.types'
import type { sanitizeLesson } from '../lessons/lesson.sanitizer'
import type { LessonSubmissionAnswer } from '../lessons/lesson.types'
export type LevelStatus =
| 'locked'
| 'available'
| 'in_progress'
| 'completed'
| 'coming_soon'
export 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
}
export interface CompleteLevelSubmission {
answers: LessonSubmissionAnswer[]
durationMs?: number
}
export 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
}
export interface LevelDetailResponse {
level: Pick<
LevelItemResponse,
| 'id'
| 'order'
| 'slug'
| 'title'
| 'description'
| 'status'
| 'stars'
| 'bestScore'
| 'activityType'
| 'activityKey'
| 'activityRoute'
>
activity: {
type: string
lesson?: ReturnType<typeof sanitizeLesson>
} | null
}
export interface CompleteLevelResponse {
levelId: number
score: number
stars: 0 | 1 | 2 | 3
previousStars: 0 | 1 | 2 | 3
isFirstCompletion: boolean
bestScoreImproved: boolean
reward: { xp: number; coins: number }
unlockedLevelId: number | null
me: MeResponse
}
+182
View File
@@ -0,0 +1,182 @@
export type ActivityType = 'lesson' | 'game' | 'challenge' | 'final_exam'
export interface GoldLandLevelDefinition {
id: number
order: number
slug: string
title: string
description: string
activityType: ActivityType
activityKey: string
rewardXp: number
rewardCoins: number
isActive: boolean
/** مسیر فعالیت در فرانت (درس/بازی) */
activityRoute?: string
}
export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
{
id: 1,
order: 1,
slug: 'what-is-money',
title: 'پول چیه؟',
description: 'با مفهوم پول و کاربرد آن آشنا شو.',
activityType: 'lesson',
activityKey: 'what-is-money',
rewardXp: 40,
rewardCoins: 15,
isActive: true,
},
{
id: 2,
order: 2,
slug: 'saving',
title: 'پس‌انداز',
description: 'یاد بگیر چطور برای هدف‌هایت پس‌انداز کنی.',
activityType: 'lesson',
activityKey: 'saving',
rewardXp: 40,
rewardCoins: 15,
isActive: true,
},
{
id: 3,
order: 3,
slug: 'needs-and-wants',
title: 'نیاز و خواسته',
description: 'تفاوت نیاز و خواسته را یاد بگیر.',
activityType: 'lesson',
activityKey: 'needs-and-wants',
rewardXp: 45,
rewardCoins: 18,
isActive: true,
},
{
id: 4,
order: 4,
slug: 'what-is-gold',
title: 'طلا چیه؟',
description: 'با طلا به‌عنوان دارایی آشنا شو.',
activityType: 'lesson',
activityKey: 'what-is-gold',
rewardXp: 40,
rewardCoins: 15,
isActive: true,
},
{
id: 5,
order: 5,
slug: 'gold-market',
title: 'بازار طلا',
description: 'بازار طلا را بشناس.',
activityType: 'lesson',
activityKey: 'gold-market',
rewardXp: 50,
rewardCoins: 20,
isActive: true,
},
{
id: 6,
order: 6,
slug: 'inflation',
title: 'تورم',
description: 'تورم چیست و چطور بر پول تو اثر می‌گذارد؟',
activityType: 'lesson',
activityKey: 'inflation',
rewardXp: 40,
rewardCoins: 15,
isActive: true,
activityRoute: '/lessons/inflation',
},
{
id: 7,
order: 7,
slug: 'budgeting',
title: 'بودجه‌بندی',
description: 'پولت را هوشمندانه تقسیم کن.',
activityType: 'game',
activityKey: 'budgeting',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
},
{
id: 8,
order: 8,
slug: 'risk-and-return',
title: 'ریسک و بازده',
description: 'ریسک و پاداش در سرمایه‌گذاری.',
activityType: 'challenge',
activityKey: 'risk-return',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
},
{
id: 9,
order: 9,
slug: 'portfolio',
title: 'سبد سرمایه‌گذاری',
description: 'یک سبد متنوع بساز.',
activityType: 'game',
activityKey: 'portfolio',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
},
{
id: 10,
order: 10,
slug: 'compound-interest',
title: 'سود مرکب',
description: 'قدرت سود مرکب را ببین.',
activityType: 'lesson',
activityKey: 'compound-interest',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
},
{
id: 11,
order: 11,
slug: 'smart-shopping',
title: 'خرید هوشمند',
description: 'هوشمندانه خرید کن.',
activityType: 'game',
activityKey: 'smart-shop',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
},
{
id: 12,
order: 12,
slug: 'financial-goal',
title: 'هدف مالی',
description: 'برنامه مالی خودت را بساز.',
activityType: 'challenge',
activityKey: 'financial-goal',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
},
{
id: 13,
order: 13,
slug: 'castle-exam',
title: 'آزمون قلعه',
description: 'آزمون نهایی سرزمین طلا.',
activityType: 'final_exam',
activityKey: 'castle-exam',
rewardXp: 100,
rewardCoins: 50,
isActive: false,
},
]
export function getLevelDefinition(levelId: number) {
return GOLD_LAND_LEVELS.find((level) => level.id === levelId)
}
export const GOLD_LAND_MAX_STARS = GOLD_LAND_LEVELS.length * 3
@@ -0,0 +1,419 @@
import { Inject, Injectable, forwardRef } from '@nestjs/common'
import { DatabaseService } from '../../database/database.service'
import type { UserState } from '../../shared/types/user.types'
import { UsersService } from '../../users/users.service'
import { ProgressionError } from '../exceptions/progression.exception'
import {
getLevelDefinition,
GOLD_LAND_LEVELS,
GOLD_LAND_MAX_STARS,
} from '../levels/levels'
import type {
CompleteLevelResponse,
CompleteLevelSubmission,
LevelDetailResponse,
LevelItemResponse,
LevelProgressRow,
LevelStatus,
} from '../levels/level.types'
import { loadLessonForLevel } from '../lessons/lesson.loader'
import {
gradeLessonSubmission,
LessonGradingError,
validateDurationMs,
} from '../lessons/lesson.grader'
import { sanitizeLesson, stripSensitiveFields } from '../lessons/lesson.sanitizer'
import { RewardService } from './reward.service'
export function starsFromScore(score: number): 0 | 1 | 2 | 3 {
if (score < 60) return 0
if (score < 75) return 1
if (score < 90) return 2
return 3
}
export function isLevelUnlocked(
levelId: number,
rowsById: Map<number, LevelProgressRow>,
): boolean {
if (levelId === 1) return true
const prevStars = rowsById.get(levelId - 1)?.stars ?? 0
return prevStars >= 1
}
export function 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 (!isLevelUnlocked(levelId, rowsById)) return 'locked'
if (!definition.isActive) return 'coming_soon'
return 'available'
}
function 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'
}
}
@Injectable()
export class GoldLandProgressionService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(forwardRef(() => UsersService))
private readonly users: UsersService,
@Inject(forwardRef(() => RewardService))
private readonly rewards: RewardService,
) {}
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) {
// Only migrate levels the user actually passed (≥1 star).
if (legacy.stars < 1) 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 syncLegacyMapLevels(state: UserState, items: LevelItemResponse[]): void {
state.mapLevels = items.map((item) => ({
id: item.id,
title: item.title,
stars: item.stars,
maxStars: 3,
status: 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
}
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 = 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,
}
})
}
syncUserProgressionState(userId: string): UserState {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
}
getGoldLandProgress(userId: string) {
this.syncUserProgressionState(userId)
const levels = this.buildLevelItems(userId)
const totalStars = levels.reduce((sum, level) => sum + level.stars, 0)
const currentLevelId =
levels.find((level) => level.status === 'in_progress')?.id ??
levels.find((level) => level.status === 'available')?.id ??
null
return {
levels,
totalStars,
maxStars: GOLD_LAND_MAX_STARS,
currentLevelId,
}
}
getGoldLandLevel(userId: string, levelId: number): LevelDetailResponse {
const levels = this.buildLevelItems(userId)
const level = levels.find((item) => item.id === levelId)
if (!level) throw new ProgressionError(404, 'مرحله پیدا نشد')
const definition = getLevelDefinition(levelId)
let activity: LevelDetailResponse['activity'] = null
const canAccessContent =
level.status === 'available' ||
level.status === 'in_progress' ||
level.status === 'completed'
if (canAccessContent && definition?.activityType === 'lesson') {
const lesson = loadLessonForLevel(definition.activityKey)
if (lesson) {
activity = {
type: 'lesson',
lesson: stripSensitiveFields(sanitizeLesson(lesson)),
}
}
}
return stripSensitiveFields({
level: {
id: level.id,
order: level.order,
slug: level.slug,
title: level.title,
description: level.description,
status: level.status,
stars: level.stars,
bestScore: level.bestScore,
activityType: level.activityType,
activityKey: level.activityKey,
activityRoute: level.activityRoute,
},
activity,
})
}
startLevel(userId: string, levelId: number) {
this.assertLevelPlayable(userId, levelId)
const definition = getLevelDefinition(levelId)
if (!definition?.isActive) {
throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود')
}
const now = new Date().toISOString()
const existing = this.getProgressRow(userId, levelId)
const db = this.database.getDb()
if (existing?.completed_at) {
db.prepare(`
UPDATE user_level_progress
SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(now, now, userId, levelId)
} else if (existing) {
db.prepare(`
UPDATE user_level_progress
SET started_at = COALESCE(started_at, ?), updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(now, now, userId, levelId)
} else {
db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, updated_at
) VALUES (?, ?, 0, 0, 1, ?, ?)
`).run(userId, levelId, now, now)
}
return { levelId, status: 'in_progress' as const }
}
completeLevel(
userId: string,
levelId: number,
submission: CompleteLevelSubmission,
): CompleteLevelResponse {
this.assertLevelPlayable(userId, levelId)
const definition = getLevelDefinition(levelId)
if (!definition?.isActive) {
throw new ProgressionError(403, 'این مرحله هنوز آماده نیست')
}
const score = this.gradeSubmission(levelId, submission)
const newStars = starsFromScore(score)
if (newStars === 0) {
throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!')
}
const existing = this.getProgressRow(userId, levelId)
const previousStars = (existing?.stars ?? 0) as 0 | 1 | 2 | 3
const previousBestScore = existing?.best_score ?? 0
const bestScore = Math.max(previousBestScore, score)
const bestScoreImproved = score > previousBestScore
const finalStars = Math.max(previousStars, newStars) as 0 | 1 | 2 | 3
const isFirstCompletion = !existing?.completed_at
const now = new Date().toISOString()
return this.database.transaction(() => {
const db = this.database.getDb()
if (existing) {
db.prepare(`
UPDATE user_level_progress
SET best_score = ?, stars = ?, attempts_count = attempts_count + 1,
completed_at = COALESCE(completed_at, ?), updated_at = ?
WHERE user_id = ? AND level_id = ?
`).run(bestScore, finalStars, now, now, userId, levelId)
} else {
db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count,
started_at, completed_at, updated_at
) VALUES (?, ?, ?, ?, 1, ?, ?, ?)
`).run(userId, levelId, bestScore, finalStars, now, now, now)
}
const starDelta = Math.max(0, finalStars - previousStars)
let totalXp = 0
let totalCoins = 0
if (isFirstCompletion && finalStars > 0) {
const first = this.rewards.grantReward({
userId,
sourceType: 'level',
sourceId: String(levelId),
rewardKey: `level:${levelId}:first-completion`,
xpDelta: definition.rewardXp,
coinsDelta: definition.rewardCoins,
})
totalXp += first.xpDelta
totalCoins += first.coinsDelta
}
if (starDelta > 0) {
for (let star = previousStars + 1; star <= finalStars; star += 1) {
const starReward = this.rewards.grantReward({
userId,
sourceType: 'level',
sourceId: String(levelId),
rewardKey: `level:${levelId}:star:${star}`,
xpDelta: 5,
coinsDelta: 3,
})
totalXp += starReward.xpDelta
totalCoins += starReward.coinsDelta
}
}
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
if (levelId === 6 && !state.completedLessons.includes('inflation')) {
state.completedLessons.push('inflation')
const achievement = state.achievements.find((a) => a.id === 'inflation')
if (achievement) achievement.unlocked = true
}
this.users.saveUserState(state)
const unlockedLevelId =
finalStars >= 1 && levelId < GOLD_LAND_LEVELS.length ? levelId + 1 : null
return {
levelId,
score,
stars: finalStars,
previousStars,
isFirstCompletion,
bestScoreImproved,
reward: { xp: totalXp, coins: totalCoins },
unlockedLevelId,
me: this.users.toMeResponse(state),
}
})
}
private assertLevelPlayable(userId: string, levelId: number): LevelItemResponse {
const items = this.buildLevelItems(userId)
const item = items.find((level) => level.id === levelId)
if (!item) throw new ProgressionError(404, 'مرحله پیدا نشد')
if (item.status === 'locked') throw new ProgressionError(403, 'این مرحله هنوز قفل است')
if (item.status === 'coming_soon') {
throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود')
}
return item
}
private gradeSubmission(levelId: number, submission: CompleteLevelSubmission): number {
const definition = getLevelDefinition(levelId)
if (!definition) throw new ProgressionError(404, 'مرحله پیدا نشد')
validateDurationMs(submission.durationMs)
if (definition.activityType === 'lesson') {
const lesson = loadLessonForLevel(definition.activityKey)
if (!lesson) {
throw new ProgressionError(403, 'محتوای این درس هنوز آماده نیست')
}
try {
const result = gradeLessonSubmission(lesson, submission.answers ?? [])
return result.score
} catch (error) {
if (error instanceof LessonGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
throw new ProgressionError(403, 'ارزیابی این مرحله هنوز پیاده‌سازی نشده')
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Inject, Injectable, forwardRef } from '@nestjs/common'
import { DatabaseService } from '../../database/database.service'
import { addXp } from '../../shared/economy/user-economy.helpers'
import { UsersService } from '../../users/users.service'
export interface GrantRewardInput {
userId: string
sourceType: string
sourceId: string
rewardKey: string
xpDelta: number
coinsDelta: number
metadata?: Record<string, unknown>
}
export interface GrantRewardResult {
granted: boolean
xpDelta: number
coinsDelta: number
}
@Injectable()
export class RewardService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(forwardRef(() => UsersService))
private readonly users: UsersService,
) {}
grantReward(input: GrantRewardInput): GrantRewardResult {
const insert = this.database.getDb().prepare(`
INSERT INTO reward_ledger (
user_id, source_type, source_id, reward_key,
xp_delta, coins_delta, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
return this.database.transaction(() => {
try {
insert.run(
input.userId,
input.sourceType,
input.sourceId,
input.rewardKey,
input.xpDelta,
input.coinsDelta,
input.metadata ? JSON.stringify(input.metadata) : null,
new Date().toISOString(),
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('UNIQUE constraint failed')) {
return { granted: false, xpDelta: 0, coinsDelta: 0 }
}
throw error
}
const state = this.users.getUserState(input.userId)
if (input.xpDelta > 0) addXp(state, input.xpDelta)
if (input.coinsDelta > 0) state.coins += input.coinsDelta
this.users.saveUserState(state)
return {
granted: true,
xpDelta: input.xpDelta,
coinsDelta: input.coinsDelta,
}
})
}
hasReward(userId: string, rewardKey: string): boolean {
const row = this.database
.getDb()
.prepare('SELECT 1 FROM reward_ledger WHERE user_id = ? AND reward_key = ?')
.get(userId, rewardKey)
return Boolean(row)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common'
import { Public } from '../common/decorators/public.decorator'
@Public()
@Controller()
export class HealthController {
@Get('health')
getHealth() {
return {
status: 'ok',
service: 'child-gold-land-backend',
runtime: 'nest',
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common'
import { HealthController } from './health.controller'
@Module({
controllers: [HealthController],
})
export class HealthModule {}
+43
View File
@@ -0,0 +1,43 @@
import 'reflect-metadata'
import { ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { AppModule } from './app.module'
import { ApiExceptionFilter } from './common/filters/api-exception.filter'
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
)
app.enableShutdownHooks()
const config = app.get(ConfigService)
app.enableCors({
origin: config.get<string>('CORS_ORIGIN'),
credentials: true,
})
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
)
app.useGlobalFilters(new ApiExceptionFilter())
const port = config.get<number>('PORT') ?? 3001
const host = config.get<string>('HOST') ?? '0.0.0.0'
await app.listen(port, host)
}
bootstrap().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,9 @@
import { IsIn } from 'class-validator'
import type { TimeRange } from '../../shared/types/user.types'
const TIME_RANGES: TimeRange[] = ['1d', '7d', '30d', '90d', '1y']
export class UpdateMarketPreferencesDto {
@IsIn(TIME_RANGES)
range!: TimeRange
}
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Get, HttpCode, Inject, Patch, Query } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { UpdateMarketPreferencesDto } from './dto/update-market-preferences.dto'
import { MarketService } from './market.service'
@Controller('api/market')
export class MarketController {
constructor(@Inject(MarketService) private readonly market: MarketService) {}
@Get('gold')
getMarketGold(@Query('range') range?: string) {
return this.market.getMarketGold(range ?? '1d')
}
@HttpCode(200)
@Patch('preferences')
setMarketRange(
@CurrentUser() userId: string,
@Body(strictBodyPipe(UpdateMarketPreferencesDto)) dto: UpdateMarketPreferencesDto,
) {
return this.market.setMarketRange(userId, dto.range)
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersModule } from '../users/users.module'
import { MarketController } from './market.controller'
import { MarketService } from './market.service'
@Module({
imports: [DatabaseModule, UsersModule],
controllers: [MarketController],
providers: [MarketService],
})
export class MarketModule {}
+45
View File
@@ -0,0 +1,45 @@
import { Inject, Injectable } from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import {
DAILY_CHANGE,
GOLD_PRICE,
PRICE_HISTORY,
} from '../shared/catalog/market.constants'
import type { MeResponse, TimeRange } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
export interface MarketGoldResponse {
currentPrice: number
dailyChangePercent: number
range: TimeRange
history: number[]
updatedAt: string
}
@Injectable()
export class MarketService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(UsersService) private readonly users: UsersService,
) {}
getMarketGold(range: string = '1d'): MarketGoldResponse {
const history = PRICE_HISTORY[range as TimeRange] ?? PRICE_HISTORY['1d']
return {
currentPrice: GOLD_PRICE,
dailyChangePercent: DAILY_CHANGE,
range: range as TimeRange,
history,
updatedAt: new Date().toISOString(),
}
}
setMarketRange(userId: string, range: TimeRange): MeResponse {
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
state.marketSelectedRange = range
this.users.saveUserState(state)
return this.users.toMeResponse(state)
})
}
}
+37
View File
@@ -0,0 +1,37 @@
export type ActivityType = 'lesson' | 'game' | 'challenge' | 'final_exam'
export interface GoldLandLevelDefinition {
id: number
order: number
slug: string
title: string
description: string
activityType: ActivityType
activityKey: string
rewardXp: number
rewardCoins: number
isActive: boolean
activityRoute?: string
}
export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
{ id: 1, order: 1, slug: 'what-is-money', title: 'پول چیه؟', description: 'با مفهوم پول و کاربرد آن آشنا شو.', activityType: 'lesson', activityKey: 'what-is-money', rewardXp: 40, rewardCoins: 15, isActive: true },
{ id: 2, order: 2, slug: 'saving', title: 'پس‌انداز', description: 'یاد بگیر چطور برای هدف‌هایت پس‌انداز کنی.', activityType: 'lesson', activityKey: 'saving', rewardXp: 40, rewardCoins: 15, isActive: true },
{ id: 3, order: 3, slug: 'needs-and-wants', title: 'نیاز و خواسته', description: 'تفاوت نیاز و خواسته را یاد بگیر.', activityType: 'lesson', activityKey: 'needs-and-wants', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 4, order: 4, slug: 'what-is-gold', title: 'طلا چیه؟', description: 'با طلا به‌عنوان دارایی آشنا شو.', activityType: 'lesson', activityKey: 'what-is-gold', rewardXp: 40, rewardCoins: 15, isActive: true },
{ id: 5, order: 5, slug: 'gold-market', title: 'بازار طلا', description: 'بازار طلا را بشناس.', activityType: 'lesson', activityKey: 'gold-market', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 6, order: 6, slug: 'inflation', title: 'تورم', description: 'تورم چیست و چطور بر پول تو اثر می‌گذارد؟', activityType: 'lesson', activityKey: 'inflation', rewardXp: 40, rewardCoins: 15, isActive: true, activityRoute: '/lessons/inflation' },
{ id: 7, order: 7, slug: 'budgeting', title: 'بودجه‌بندی', description: 'پولت را هوشمندانه تقسیم کن.', activityType: 'game', activityKey: 'budgeting', rewardXp: 45, rewardCoins: 18, isActive: false },
{ id: 8, order: 8, slug: 'risk-and-return', title: 'ریسک و بازده', description: 'ریسک و پاداش در سرمایه‌گذاری.', activityType: 'challenge', activityKey: 'risk-return', rewardXp: 50, rewardCoins: 20, isActive: false },
{ id: 9, order: 9, slug: 'portfolio', title: 'سبد سرمایه‌گذاری', description: 'یک سبد متنوع بساز.', activityType: 'game', activityKey: 'portfolio', rewardXp: 50, rewardCoins: 20, isActive: false },
{ id: 10, order: 10, slug: 'compound-interest', title: 'سود مرکب', description: 'قدرت سود مرکب را ببین.', activityType: 'lesson', activityKey: 'compound-interest', rewardXp: 45, rewardCoins: 18, isActive: false },
{ id: 11, order: 11, slug: 'smart-shopping', title: 'خرید هوشمند', description: 'هوشمندانه خرید کن.', activityType: 'game', activityKey: 'smart-shop', rewardXp: 45, rewardCoins: 18, isActive: false },
{ id: 12, order: 12, slug: 'financial-goal', title: 'هدف مالی', description: 'برنامه مالی خودت را بساز.', activityType: 'challenge', activityKey: 'financial-goal', rewardXp: 50, rewardCoins: 20, isActive: false },
{ id: 13, order: 13, slug: 'castle-exam', title: 'آزمون قلعه', description: 'آزمون نهایی سرزمین طلا.', activityType: 'final_exam', activityKey: 'castle-exam', rewardXp: 100, rewardCoins: 50, isActive: false },
]
export function getLevelDefinition(levelId: number) {
return GOLD_LAND_LEVELS.find((level) => level.id === levelId)
}
export const GOLD_LAND_MAX_STARS = GOLD_LAND_LEVELS.length * 3
+180
View File
@@ -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'
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module, forwardRef } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { ProgressionSyncService } from './progression-sync.service'
import { UsersModule } from '../users/users.module'
@Module({
imports: [DatabaseModule, forwardRef(() => UsersModule)],
providers: [ProgressionSyncService],
exports: [ProgressionSyncService],
})
export class ProgressionModule {}
@@ -0,0 +1,48 @@
export interface AchievementDefinition {
id: string
title: string
description: string
icon: string
}
export const ACHIEVEMENTS: AchievementDefinition[] = [
{
id: 'first-gold',
title: 'اولین خرید طلا',
description: 'اولین خرید طلای خودت رو انجام دادی',
icon: '🥇',
},
{
id: 'saver',
title: 'پس‌اندازگر کوچک',
description: '۱۰۰ هزار تومان پس‌انداز کردی',
icon: '💰',
},
{
id: 'market',
title: 'شاگرد بازار',
description: '۵ بار در بازار معامله کردی',
icon: '📊',
},
{
id: 'inflation',
title: 'شکست‌دهنده تورم',
description: 'درس تورم رو کامل کردی',
icon: '👾',
},
]
/** Fastify catalog.ts default unlocked flags — separate from catalog definitions. */
export const DEFAULT_ACHIEVEMENT_UNLOCK_STATE: Record<AchievementDefinition['id'], boolean> = {
'first-gold': true,
saver: true,
market: false,
inflation: false,
}
export function createDefaultAchievements() {
return ACHIEVEMENTS.map((achievement) => ({
...achievement,
unlocked: DEFAULT_ACHIEVEMENT_UNLOCK_STATE[achievement.id],
}))
}
+79
View File
@@ -0,0 +1,79 @@
export interface ChallengeOptionDefinition {
id: string
label: string
icon: string
color: string
isCorrect: boolean
feedback: string
}
export interface DailyChallengeDefinition {
id: string
question: string
coinReward: number
xpReward: number
}
export const DAILY_CHALLENGE: DailyChallengeDefinition = {
id: 'daily-challenge',
question:
'امروز ۲۰ هزار تومان داری کجا سرمایه‌گذاری می‌کنی که در آینده سود بیشتری داشته باشه؟',
coinReward: 50,
xpReward: 30,
}
export const CHALLENGE_OPTIONS: ChallengeOptionDefinition[] = [
{
id: 'gold',
label: 'طلا',
icon: '🥇',
color: '#f5b62d',
isCorrect: true,
feedback: 'عالی! طلا در بلندمدت می‌تونه ارزش پولت رو حفظ کنه.',
},
{
id: 'dollar',
label: 'دلار',
icon: '💵',
color: '#20b94b',
isCorrect: false,
feedback: 'دلار گزینه‌ایه، ولی طلا برای حفظ ارزش در تورم بهتره.',
},
{
id: 'bank',
label: 'بانک',
icon: '🏦',
color: '#1987d4',
isCorrect: false,
feedback: 'پس‌انداز بانکی امنه، ولی سودش معمولاً از تورم کمتره.',
},
{
id: 'phone',
label: 'خرید گوشی',
icon: '📱',
color: '#7438c9',
isCorrect: false,
feedback: 'گوشی ارزش مالی نگه نمی‌داره. بهتره سرمایه‌گذاری کنی!',
},
]
export interface DailyChallengePublicOption {
id: string
label: string
icon: string
color: string
}
export interface DailyChallengeResponse {
id: string
question: string
coinReward: number
xpReward: number
completed: boolean
options: DailyChallengePublicOption[]
}
export type DailyChallengeCompleteResponse = import('../types/user.types').MeResponse & {
feedback: string
alreadyCompleted: boolean
}
+52
View File
@@ -0,0 +1,52 @@
import { createDefaultAchievements } from './achievements.constants'
import type { MapLevel, TimeRange, UserState } from '../types/user.types'
export const DEMO_USER_ID = 'demo'
export const MAP_LEVELS: MapLevel[] = [
{ id: 1, title: 'پول چیه؟', stars: 0, maxStars: 3, status: 'current', x: 24, y: 79 },
{ id: 2, title: 'پس‌انداز', stars: 0, maxStars: 3, status: 'locked', x: 59, y: 63 },
{ id: 3, title: 'نیاز و خواسته', stars: 0, maxStars: 3, status: 'locked', x: 32, y: 47 },
{ id: 4, title: 'طلا چیه؟', stars: 0, maxStars: 3, status: 'locked', x: 22, y: 36 },
{ id: 5, title: 'بازار طلا', stars: 0, maxStars: 3, status: 'locked', x: 42, y: 34 },
{ id: 6, title: 'تورم', stars: 0, maxStars: 3, status: 'locked', lessonRoute: '/lessons/inflation', x: 54, y: 66 },
{ id: 7, title: 'سرمایه‌گذاری', stars: 0, maxStars: 3, status: 'locked', x: 40, y: 61 },
{ id: 8, title: 'سبد دارایی', stars: 0, maxStars: 3, status: 'locked', x: 28, y: 77 },
{ id: 9, title: 'ریسک و پاداش', stars: 0, maxStars: 3, status: 'locked', x: 47, y: 63 },
{ id: 10, title: 'پس‌انداز هوشمند', stars: 0, maxStars: 3, status: 'locked', x: 33, y: 52 },
{ id: 11, title: 'اقتصاد خانواده', stars: 0, maxStars: 3, status: 'locked', x: 47, y: 41 },
{ id: 12, title: 'کارآفرینی', stars: 0, maxStars: 3, status: 'locked', x: 48, y: 30 },
{ id: 13, title: 'سرزمین طلا', stars: 0, maxStars: 3, status: 'locked', x: 54, y: 18 },
]
const INITIAL_TRANSACTIONS = [
{ id: '1', type: 'expense' as const, title: 'خرید طلا', amount: 1_600_000, unit: 'تومان' as const, date: '۱۴۰۴/۰۴/۱۲' },
{ id: '2', type: 'income' as const, title: 'ماموریت روزانه', amount: 50, unit: 'سکه' as const, date: '۱۴۰۴/۰۴/۱۱' },
{ id: '3', type: 'income' as const, title: 'پاداش بازی', amount: 20, unit: 'سکه' as const, date: '۱۴۰۴/۰۴/۱۰' },
]
export function createDefaultUserState(userId: string): UserState {
return {
id: userId,
name: 'علی',
level: 12,
xp: 850,
maxXp: 1200,
coins: 1350,
cash: 100_000,
goldGrams: 42.5,
missionProgress: 12,
missionTarget: 20,
completedChallenges: [],
completedLessons: [],
completedGames: [],
claimedRewards: [],
ownedItems: [],
achievements: createDefaultAchievements(),
mapLevels: MAP_LEVELS.map((l) => ({ ...l })),
totalStars: 0,
maxStars: 39,
marketSelectedRange: '1d' as TimeRange,
transactions: INITIAL_TRANSACTIONS.map((t) => ({ ...t })),
}
}
+60
View File
@@ -0,0 +1,60 @@
export interface GameDefinition {
id: string
title: string
description: string
icon: string
route?: string
color: string
rewardCoins?: number
rewardXp?: number
}
export const GAMES: GameDefinition[] = [
{
id: 'gold-guess',
title: 'حدس قیمت طلا',
description: 'قیمت فردا رو حدس بزن',
icon: '📈',
route: '/games/gold-guess',
color: '#7438c9',
rewardCoins: 20,
rewardXp: 50,
},
{
id: 'inflation',
title: 'تورم رو شکست بده',
description: 'ارزش پولت رو حفظ کن',
icon: '👾',
route: '/lessons/inflation',
color: '#e73343',
rewardXp: 40,
},
{
id: 'portfolio',
title: 'ساخت سبد سرمایه‌گذاری',
description: 'بهترین ترکیب رو انتخاب کن',
icon: '📊',
color: '#1987d4',
},
{
id: 'smart-shop',
title: 'خرید هوشمند',
description: 'بهترین خرید رو انجام بده',
icon: '🛒',
color: '#20b94b',
},
{
id: 'savings',
title: 'چالش پس‌انداز',
description: 'پس‌انداز کن و پیشرفت کن',
icon: '🐷',
color: '#f5b62d',
},
{
id: 'investor',
title: 'شاگرد سرمایه‌گذار',
description: 'تصمیم بگیر و سود ببر',
icon: '🎓',
color: '#f07a2a',
},
]
+28
View File
@@ -0,0 +1,28 @@
import type { TimeRange } from '../types/user.types'
export const GOLD_PRICE = 1_830_000
export const DAILY_CHANGE = 2.4
function generatePriceHistory(basePrice: number, points: number): number[] {
const history: number[] = []
let price = basePrice * 0.92
for (let i = 0; i < points; i++) {
const change = (Math.sin(i * 0.4) * 0.015 + Math.random() * 0.008) * price
price += change
history.push(Math.round(price))
}
history[history.length - 1] = basePrice
return history
}
export const PRICE_HISTORY: Record<TimeRange, number[]> = {
'1d': generatePriceHistory(GOLD_PRICE, 24),
'7d': generatePriceHistory(GOLD_PRICE, 28),
'30d': generatePriceHistory(GOLD_PRICE, 30),
'90d': generatePriceHistory(GOLD_PRICE, 30),
'1y': generatePriceHistory(GOLD_PRICE, 52),
}
export function formatFaDate(date = new Date()): string {
return date.toLocaleDateString('fa-IR')
}
+18
View File
@@ -0,0 +1,18 @@
export type StoreProductCategory = 'special' | 'visual' | 'boost'
export interface StoreProduct {
id: string
title: string
price: number
category: StoreProductCategory
emoji: string
}
export const STORE_PRODUCTS: StoreProduct[] = [
{ id: 'castle', title: 'قلعه طلایی', price: 5000, category: 'special', emoji: '🏰' },
{ id: 'suit', title: 'لباس ماجراجو', price: 2500, category: 'visual', emoji: '🧥' },
{ id: 'pet', title: 'حیوان همراه', price: 3000, category: 'special', emoji: '🦊' },
{ id: 'sea-bg', title: 'پس‌زمینه دریایی', price: 1500, category: 'visual', emoji: '🌊' },
{ id: 'crown', title: 'تاج طلایی', price: 4000, category: 'special', emoji: '👑' },
{ id: 'boost-xp', title: 'تقویت XP', price: 800, category: 'boost', emoji: '⚡' },
]
@@ -0,0 +1,37 @@
import { formatFaDate } from '../catalog/market.constants'
import type { Transaction, UserState } from '../types/user.types'
export function addXp(state: UserState, amount: number): void {
state.xp += amount
while (state.xp >= state.maxXp) {
state.xp -= state.maxXp
state.level += 1
state.maxXp = Math.round(state.maxXp * 1.15)
}
}
export function addTransaction(
state: UserState,
tx: Omit<Transaction, 'id' | 'date'> & { date?: string },
): void {
state.transactions.unshift({
id: Date.now().toString() + Math.random().toString(36).slice(2, 6),
date: tx.date ?? formatFaDate(),
type: tx.type,
title: tx.title,
amount: tx.amount,
unit: tx.unit,
})
if (state.transactions.length > 50) {
state.transactions = state.transactions.slice(0, 50)
}
}
export function unlockAchievement(state: UserState, id: string): void {
const achievement = state.achievements.find((a) => a.id === id)
if (achievement) achievement.unlocked = true
}
export function incrementMission(state: UserState): void {
state.missionProgress = Math.min(state.missionProgress + 1, state.missionTarget)
}
+98
View File
@@ -0,0 +1,98 @@
export type TransactionType = 'income' | 'expense'
export type TransactionUnit = 'تومان' | 'سکه' | 'گرم'
export type TimeRange = '1d' | '7d' | '30d' | '90d' | '1y'
export type MapLevelStatus = 'completed' | 'current' | 'unlocked' | 'locked'
export interface Transaction {
id: string
type: TransactionType
title: string
amount: number
unit: TransactionUnit
date: string
}
export interface Achievement {
id: string
title: string
description: string
icon: string
unlocked: boolean
}
export interface MapLevel {
id: number
title: string
stars: number
maxStars: number
status: MapLevelStatus
lessonRoute?: string
x: number
y: number
}
export interface UserState {
id: string
name: string
level: number
xp: number
maxXp: number
coins: number
cash: number
goldGrams: number
missionProgress: number
missionTarget: number
completedChallenges: string[]
completedLessons: string[]
completedGames: string[]
claimedRewards: string[]
ownedItems: string[]
achievements: Achievement[]
mapLevels: MapLevel[]
totalStars: number
maxStars: number
marketSelectedRange: TimeRange
transactions: Transaction[]
}
export interface MeResponse {
user: {
id: string
name: string
level: number
xp: number
maxXp: number
coins: number
missionProgress: number
missionTarget: number
}
wallet: {
cash: number
goldGrams: number
}
progress: {
completedChallenges: string[]
completedLessons: string[]
completedGames: string[]
claimedRewards: string[]
ownedItems: string[]
mapLevels: MapLevel[]
totalStars: number
maxStars: number
}
achievements: Achievement[]
transactions: Transaction[]
market: {
selectedRange: TimeRange
}
}
export interface AuthResponse {
token: string
user: {
id: string
name: string
username: string
}
me: MeResponse
}
+7
View File
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator'
export class PurchaseStoreItemDto {
@IsString()
@IsNotEmpty()
productId!: string
}
+24
View File
@@ -0,0 +1,24 @@
import { Body, Controller, Get, HttpCode, Inject, Post } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { PurchaseStoreItemDto } from './dto/purchase-store-item.dto'
import { StoreService } from './store.service'
@Controller('api/store')
export class StoreController {
constructor(@Inject(StoreService) private readonly store: StoreService) {}
@Get('products')
getProducts() {
return this.store.getProducts()
}
@HttpCode(200)
@Post('purchases')
purchase(
@CurrentUser() userId: string,
@Body(strictBodyPipe(PurchaseStoreItemDto)) dto: PurchaseStoreItemDto,
) {
return this.store.purchaseStoreItem(userId, dto.productId)
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersModule } from '../users/users.module'
import { StoreController } from './store.controller'
import { StoreService } from './store.service'
@Module({
imports: [DatabaseModule, UsersModule],
controllers: [StoreController],
providers: [StoreService],
})
export class StoreModule {}
+53
View File
@@ -0,0 +1,53 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import { STORE_PRODUCTS, type StoreProduct } from '../shared/catalog/store.constants'
import { addTransaction } from '../shared/economy/user-economy.helpers'
import type { MeResponse } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
@Injectable()
export class StoreService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(UsersService) private readonly users: UsersService,
) {}
getProducts(): StoreProduct[] {
return STORE_PRODUCTS
}
purchaseStoreItem(userId: string, productId: string): MeResponse {
const product = STORE_PRODUCTS.find((p) => p.id === productId)
if (!product) {
throw new NotFoundException('محصول پیدا نشد')
}
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
if (state.ownedItems.includes(productId)) {
throw new ConflictException('قبلاً خریدی!')
}
if (state.coins < product.price) {
throw new BadRequestException('سکه کافی نداری!')
}
state.coins -= product.price
state.ownedItems.push(productId)
addTransaction(state, {
type: 'expense',
title: `خرید ${product.title}`,
amount: product.price,
unit: 'سکه',
})
this.users.saveUserState(state)
return this.users.toMeResponse(state)
})
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator'
export class UpdatePasswordDto {
@IsString()
@IsNotEmpty()
currentPassword!: string
@IsString()
@IsNotEmpty()
newPassword!: string
}
+7
View File
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator'
export class UpdateProfileDto {
@IsString()
@IsNotEmpty()
name!: string
}
+50
View File
@@ -0,0 +1,50 @@
import { BadRequestException, Body, Controller, Get, HttpCode, Inject, Patch, forwardRef } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { AuthService } from '../auth/auth.service'
import { UsersService } from './users.service'
import { UpdateProfileDto } from './dto/update-profile.dto'
import { UpdatePasswordDto } from './dto/update-password.dto'
@Controller('api')
export class UsersController {
constructor(
@Inject(UsersService) private readonly users: UsersService,
@Inject(forwardRef(() => AuthService))
private readonly auth: AuthService,
) {}
@Get('me')
getMe(@CurrentUser() userId: string) {
return this.users.getMe(userId)
}
@Patch('me/profile')
updateProfile(
@CurrentUser() userId: string,
@Body(strictBodyPipe(UpdateProfileDto)) dto: UpdateProfileDto,
) {
const name = dto.name?.trim()
if (!name) {
throw new BadRequestException('نام نمایشی الزامی است')
}
return this.users.updateProfile(userId, name)
}
@Patch('me/password')
updatePassword(
@CurrentUser() userId: string,
@Body(strictBodyPipe(UpdatePasswordDto)) dto: UpdatePasswordDto,
) {
const currentPassword = dto.currentPassword ?? ''
const newPassword = dto.newPassword ?? ''
if (!currentPassword) {
throw new BadRequestException('رمز عبور فعلی الزامی است')
}
if (!newPassword) {
throw new BadRequestException('رمز عبور جدید الزامی است')
}
this.auth.updatePassword(userId, currentPassword, newPassword)
return { ok: true as const }
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module, forwardRef } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersService } from './users.service'
import { UsersController } from './users.controller'
import { GoldLandModule } from '../gold-land/gold-land.module'
import { AuthModule } from '../auth/auth.module'
@Module({
imports: [DatabaseModule, forwardRef(() => GoldLandModule), forwardRef(() => AuthModule)],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+107
View File
@@ -0,0 +1,107 @@
import {
Inject,
Injectable,
InternalServerErrorException,
Logger,
forwardRef,
} from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import { createDefaultUserState } from '../shared/catalog/default-user-state'
import type { MeResponse, UserState } from '../shared/types/user.types'
import { GoldLandProgressionService } from '../gold-land/services/progression.service'
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name)
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(forwardRef(() => GoldLandProgressionService))
private readonly goldLandProgression: GoldLandProgressionService,
) {}
ensureUser(userId: string): UserState {
return this.getUserState(userId)
}
getUserState(userId: string): UserState {
const row = this.database
.getDb()
.prepare('SELECT state_json FROM users WHERE id = ?')
.get(userId) as { state_json: string } | undefined
if (row) {
return this.parseStateJson(row.state_json, userId)
}
const state = createDefaultUserState(userId)
this.database
.getDb()
.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)')
.run(userId, JSON.stringify(state), new Date().toISOString())
return state
}
saveUserState(state: UserState): UserState {
this.database
.getDb()
.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?')
.run(JSON.stringify(state), new Date().toISOString(), state.id)
return state
}
toMeResponse(state: UserState): MeResponse {
return {
user: {
id: state.id,
name: state.name,
level: state.level,
xp: state.xp,
maxXp: state.maxXp,
coins: state.coins,
missionProgress: state.missionProgress,
missionTarget: state.missionTarget,
},
wallet: {
cash: state.cash,
goldGrams: state.goldGrams,
},
progress: {
completedChallenges: state.completedChallenges,
completedLessons: state.completedLessons,
completedGames: state.completedGames,
claimedRewards: state.claimedRewards,
ownedItems: state.ownedItems,
mapLevels: state.mapLevels,
totalStars: state.totalStars,
maxStars: state.maxStars,
},
achievements: state.achievements,
transactions: state.transactions,
market: {
selectedRange: state.marketSelectedRange,
},
}
}
getMe(userId: string): MeResponse {
const state = this.goldLandProgression.syncUserProgressionState(userId)
return this.toMeResponse(state)
}
updateProfile(userId: string, name: string): MeResponse {
const state = this.getUserState(userId)
state.name = name.trim()
this.saveUserState(state)
return this.toMeResponse(state)
}
private parseStateJson(raw: string, userId: string): UserState {
try {
return JSON.parse(raw) as UserState
} catch (error) {
this.logger.error(`Invalid state_json for user ${userId}`, error)
throw new InternalServerErrorException('داده کاربر نامعتبر است')
}
}
}
+6
View File
@@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator'
export class BuyGoldDto {
@IsNumber({ allowNaN: false, allowInfinity: false })
grams!: number
}
+6
View File
@@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator'
export class SellGoldDto {
@IsNumber({ allowNaN: false, allowInfinity: false })
grams!: number
}
+28
View File
@@ -0,0 +1,28 @@
import { formatFaDate } from '../shared/catalog/market.constants'
import type { Transaction, UserState } from '../shared/types/user.types'
export function addTransaction(
state: UserState,
tx: Omit<Transaction, 'id' | 'date'> & { date?: string },
): void {
state.transactions.unshift({
id: Date.now().toString() + Math.random().toString(36).slice(2, 6),
date: tx.date ?? formatFaDate(),
type: tx.type,
title: tx.title,
amount: tx.amount,
unit: tx.unit,
})
if (state.transactions.length > 50) {
state.transactions = state.transactions.slice(0, 50)
}
}
export function unlockAchievement(state: UserState, id: string): void {
const achievement = state.achievements.find((a) => a.id === id)
if (achievement) achievement.unlocked = true
}
export function incrementMission(state: UserState): void {
state.missionProgress = Math.min(state.missionProgress + 1, state.missionTarget)
}
+28
View File
@@ -0,0 +1,28 @@
import { Body, Controller, Get, HttpCode, Inject, Post } from '@nestjs/common'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { BuyGoldDto } from './dto/buy-gold.dto'
import { SellGoldDto } from './dto/sell-gold.dto'
import { WalletService } from './wallet.service'
@Controller('api/wallet')
export class WalletController {
constructor(@Inject(WalletService) private readonly wallet: WalletService) {}
@Get('transactions')
getTransactions(@CurrentUser() userId: string) {
return this.wallet.getTransactions(userId)
}
@HttpCode(200)
@Post('gold/buy')
buyGold(@CurrentUser() userId: string, @Body(strictBodyPipe(BuyGoldDto)) dto: BuyGoldDto) {
return this.wallet.buyGold(userId, dto.grams)
}
@HttpCode(200)
@Post('gold/sell')
sellGold(@CurrentUser() userId: string, @Body(strictBodyPipe(SellGoldDto)) dto: SellGoldDto) {
return this.wallet.sellGold(userId, dto.grams)
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { UsersModule } from '../users/users.module'
import { WalletController } from './wallet.controller'
import { WalletService } from './wallet.service'
@Module({
imports: [DatabaseModule, UsersModule],
controllers: [WalletController],
providers: [WalletService],
})
export class WalletModule {}
+73
View File
@@ -0,0 +1,73 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
import { DatabaseService } from '../database/database.service'
import { GOLD_PRICE } from '../shared/catalog/market.constants'
import type { MeResponse, Transaction } from '../shared/types/user.types'
import { UsersService } from '../users/users.service'
import { addTransaction, incrementMission, unlockAchievement } from '../shared/economy/user-economy.helpers'
@Injectable()
export class WalletService {
constructor(
@Inject(DatabaseService) private readonly database: DatabaseService,
@Inject(UsersService) private readonly users: UsersService,
) {}
getTransactions(userId: string): { transactions: Transaction[] } {
const state = this.users.getUserState(userId)
return { transactions: state.transactions }
}
buyGold(userId: string, grams: number): MeResponse {
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const pricePerGram = GOLD_PRICE
const cost = grams * pricePerGram
if (grams <= 0) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (state.cash < cost) {
throw new BadRequestException('موجودی نقد کافی نیست')
}
state.cash -= cost
state.goldGrams += grams
addTransaction(state, {
type: 'expense',
title: 'خرید طلا',
amount: cost,
unit: 'تومان',
})
unlockAchievement(state, 'first-gold')
incrementMission(state)
this.users.saveUserState(state)
return this.users.toMeResponse(state)
})
}
sellGold(userId: string, grams: number): MeResponse {
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const pricePerGram = GOLD_PRICE
const value = grams * pricePerGram
if (grams <= 0) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (state.goldGrams < grams) {
throw new BadRequestException('طلای کافی نداری')
}
state.goldGrams -= grams
state.cash += value
addTransaction(state, {
type: 'income',
title: 'فروش طلا',
amount: value,
unit: 'تومان',
})
this.users.saveUserState(state)
return this.users.toMeResponse(state)
})
}
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
/**
* Gold Land progression tests — always uses a temporary SQLite file.
* Run: npm run test:progression
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { LessonDefinition, LessonSubmissionAnswer } from '../src/gold-land/lessons/lesson.types'
import { ProgressionError } from '../src/gold-land/exceptions/progression.exception'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import { RewardService } from '../src/gold-land/services/reward.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const TEST_USER = 'progression-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-nest-progression-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3998'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
)
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
function resetTestUser(dbService: DatabaseService) {
const db = dbService.getDb()
db.prepare('DELETE FROM users WHERE id = ?').run(TEST_USER)
db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(TEST_USER)
db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(TEST_USER)
const state = createDefaultUserState(TEST_USER)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
TEST_USER,
JSON.stringify(state),
new Date().toISOString(),
)
}
function grantStars(dbService: DatabaseService, levelId: number, stars: number, score: number) {
const now = new Date().toISOString()
dbService.getDb().prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(user_id, level_id) DO UPDATE SET
best_score = excluded.best_score,
stars = excluded.stars,
completed_at = excluded.completed_at,
updated_at = excluded.updated_at
`).run(TEST_USER, levelId, score, stars, now, now, now)
}
function buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] {
return getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
}
function buildPartialCorrect(lesson: LessonDefinition, correctCount: number): LessonSubmissionAnswer[] {
const gradable = getGradableQuestions(lesson)
return gradable.map(({ questionId, step }, index) => {
const isCorrect = index < correctCount
if (step.type === 'sort') {
const order = isCorrect ? [...step.correctOrder] : [...step.correctOrder].reverse()
return { questionId, orderedItemIds: order }
}
if (!isCorrect) {
const wrong =
step.type === 'true_false'
? step.correctAnswerId === 'true'
? 'false'
: 'true'
: step.options.find((o) => o.id !== step.correctAnswerId)?.id ?? 'wrong'
return { questionId, answerId: wrong }
}
return { questionId, answerId: step.correctAnswerId }
})
}
function expectError(fn: () => unknown, statusCode: number) {
try {
fn()
assert.fail('Expected error')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, statusCode)
}
}
async function runTests() {
const app = await createApp()
const dbService = (await import('../src/database/database.service')).DatabaseService
const database = app.get(dbService)
const progression = app.get(GoldLandProgressionService)
const rewards = app.get(RewardService)
const { getAllPlayableLessons, countGradableQuestions } = await import(
'../src/gold-land/lessons/lesson.registry'
)
const { loadLessonForLevel } = await import('../src/gold-land/lessons/lesson.loader')
const { sanitizeLesson } = await import('../src/gold-land/lessons/lesson.sanitizer')
const { gradeLessonSubmission, LessonGradingError } = await import(
'../src/gold-land/lessons/lesson.grader'
)
const { inflationLesson } = await import('../src/gold-land/lessons/inflation.lesson')
const { needsAndWantsLesson } = await import('../src/gold-land/lessons/needs-and-wants.lesson')
const { isLevelUnlocked, starsFromScore } = await import(
'../src/gold-land/services/progression.service'
)
try {
resetTestUser(database)
let items = progression.buildLevelItems(TEST_USER)
const playable = getAllPlayableLessons()
assert.equal(playable.length, 6)
for (const lesson of playable) {
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
}
assert.equal(isLevelUnlocked(1, new Map()), true)
assert.notEqual(items.find((l) => l.id === 1)?.status, 'locked')
assert.equal(items.find((l) => l.id === 2)?.status, 'locked')
assert.equal(items.find((l) => l.id === 6)?.status, 'locked')
expectError(() => progression.startLevel(TEST_USER, 6), 403)
expectError(
() => progression.completeLevel(TEST_USER, 6, { answers: buildAllCorrect(inflationLesson) }),
403,
)
for (let id = 1; id <= 5; id++) {
grantStars(database, id, 1, 65)
}
items = progression.buildLevelItems(TEST_USER)
assert.equal(items.find((l) => l.id === 6)?.status, 'available')
const ALL_CORRECT_INFLATION = buildAllCorrect(inflationLesson)
const THREE_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 3)
const FOUR_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 4)
const ONE_CORRECT_INFLATION = buildPartialCorrect(inflationLesson, 1)
try {
progression.completeLevel(TEST_USER, 6, { answers: [{ questionId: 'unknown-q', answerId: 'option-b' }] })
assert.fail('expected invalid question')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
try {
progression.completeLevel(TEST_USER, 6, {
answers: [{ questionId: 'inflation-q1', answerId: 'invalid-option' }],
})
assert.fail('expected invalid answer')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
try {
gradeLessonSubmission(inflationLesson, [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q1', answerId: 'option-a' },
])
assert.fail('expected duplicate questionId')
} catch (error) {
assert.ok(error instanceof LessonGradingError)
assert.equal(error.statusCode, 400)
}
try {
gradeLessonSubmission(needsAndWantsLesson, [
{
questionId: 'needs-q3',
orderedItemIds: ['water', 'food', 'unknown', 'shoes', 'toy', 'crown'],
},
])
assert.fail('expected unknown sort item')
} catch (error) {
assert.ok(error instanceof LessonGradingError)
assert.equal(error.statusCode, 400)
}
try {
gradeLessonSubmission(needsAndWantsLesson, [
{
questionId: 'needs-q3',
orderedItemIds: ['water', 'water', 'medicine', 'shoes', 'toy', 'crown'],
},
])
assert.fail('expected duplicate sort item')
} catch (error) {
assert.ok(error instanceof LessonGradingError)
assert.equal(error.statusCode, 400)
}
assert.equal(gradeLessonSubmission(inflationLesson, THREE_CORRECT_INFLATION).score, 60)
assert.equal(gradeLessonSubmission(inflationLesson, FOUR_CORRECT_INFLATION).score, 80)
assert.equal(gradeLessonSubmission(inflationLesson, ALL_CORRECT_INFLATION).score, 100)
assert.equal(starsFromScore(59), 0)
assert.equal(starsFromScore(60), 1)
assert.equal(starsFromScore(74), 1)
assert.equal(starsFromScore(75), 2)
assert.equal(starsFromScore(89), 2)
assert.equal(starsFromScore(90), 3)
try {
progression.completeLevel(TEST_USER, 6, { answers: ONE_CORRECT_INFLATION })
assert.fail('expected fail below 60')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
progression.startLevel(TEST_USER, 6)
const first = progression.completeLevel(TEST_USER, 6, {
answers: THREE_CORRECT_INFLATION,
durationMs: 84000,
})
assert.equal(first.stars, 1)
assert.equal(first.score, 60)
assert.ok(first.isFirstCompletion)
assert.ok(first.bestScoreImproved)
assert.ok(rewards.hasReward(TEST_USER, 'level:6:first-completion'))
const duplicate = progression.completeLevel(TEST_USER, 6, { answers: THREE_CORRECT_INFLATION })
assert.equal(duplicate.isFirstCompletion, false)
assert.equal(duplicate.reward.xp, 0)
assert.equal(duplicate.reward.coins, 0)
try {
progression.completeLevel(TEST_USER, 6, { answers: ONE_CORRECT_INFLATION })
assert.fail('expected low score rejection')
} catch (error) {
assert.ok(error instanceof ProgressionError)
}
assert.equal(progression.buildLevelItems(TEST_USER).find((l) => l.id === 6)!.stars, 1)
const improved = progression.completeLevel(TEST_USER, 6, { answers: ALL_CORRECT_INFLATION })
assert.equal(improved.stars, 3)
assert.ok(improved.bestScoreImproved)
assert.ok(improved.reward.xp > 0)
assert.ok(rewards.hasReward(TEST_USER, 'level:6:star:3'))
const replayLower = progression.completeLevel(TEST_USER, 6, { answers: FOUR_CORRECT_INFLATION })
assert.equal(replayLower.stars, 3)
assert.equal(replayLower.reward.xp, 0)
const detail = progression.getGoldLandLevel(TEST_USER, 6)
const json = JSON.stringify(detail)
assert.ok(!json.includes('correctAnswerId'))
assert.ok(!json.includes('correctOrder'))
assert.ok(!json.includes('answerKey'))
const sanitized = sanitizeLesson(loadLessonForLevel('inflation')!)
for (const step of sanitized.steps) {
assert.ok(!('correctAnswerId' in step))
assert.ok(!('correctOrder' in step))
}
const moneyLesson = loadLessonForLevel('what-is-money')!
const moneyAnswers = buildAllCorrect(moneyLesson)
try {
progression.completeLevel(TEST_USER, 6, { answers: moneyAnswers })
assert.fail('expected wrong lesson answers for level 6')
} catch (error) {
assert.ok(error instanceof ProgressionError)
assert.equal(error.statusCode, 400)
}
resetTestUser(database)
progression.startLevel(TEST_USER, 1)
const level1Answers = buildAllCorrect(loadLessonForLevel('what-is-money')!)
const level1Result = progression.completeLevel(TEST_USER, 1, { answers: level1Answers })
assert.equal(level1Result.stars, 3)
assert.equal(level1Result.unlockedLevelId, 2)
assert.equal(progression.buildLevelItems(TEST_USER).find((l) => l.id === 2)!.status, 'available')
const level1Again = progression.completeLevel(TEST_USER, 1, { answers: level1Answers })
assert.equal(level1Again.isFirstCompletion, false)
assert.equal(level1Again.reward.xp, 0)
console.log('progression tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}