Initial commit: NestJS backend for Child Gold Land
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
Generated
+4999
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator'
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
username!: string
|
||||
|
||||
@IsString()
|
||||
password!: string
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString } from 'class-validator'
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
username!: string
|
||||
|
||||
@IsString()
|
||||
password!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 'درخواست نامعتبر است'
|
||||
}
|
||||
}
|
||||
@@ -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('نشست نامعتبر است. دوباره وارد شوید')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common'
|
||||
import { DatabaseService } from './database.service'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DATABASE_PATH = Symbol('DATABASE_PATH')
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export class CompleteGameDto {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: 'رشد گذشته، آینده را تضمین نمیکند.',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: 'همان مقدار پول، در آینده ممکن است کالای کمتری بخرد.',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: 'اول نیاز، بعد خواسته.',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: 'پول وسیلهٔ مبادله است، نه فقط یک برگه یا سکه!',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, 'ارزیابی این مرحله هنوز پیادهسازی نشده')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { HealthController } from './health.controller'
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
+43
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })),
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'
|
||||
|
||||
export class PurchaseStoreItemDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
productId!: string
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'
|
||||
|
||||
export class UpdatePasswordDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
currentPassword!: string
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
newPassword!: string
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('داده کاربر نامعتبر است')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsNumber } from 'class-validator'
|
||||
|
||||
export class BuyGoldDto {
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
grams!: number
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsNumber } from 'class-validator'
|
||||
|
||||
export class SellGoldDto {
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
grams!: number
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user